From cb8baef766bd1fd9620a3f73daec8207d6f38836 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 19:47:00 -0500 Subject: [PATCH 1/9] activity: read kind and goal cells and keep the old page --- t3/added/brigade/scripts/activity.py | 56 ++++++++++++++++++++++++---- tests/test_activity.py | 49 +++++++++++++++++++++++- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/t3/added/brigade/scripts/activity.py b/t3/added/brigade/scripts/activity.py index d5f1cd35..98b9196a 100644 --- a/t3/added/brigade/scripts/activity.py +++ b/t3/added/brigade/scripts/activity.py @@ -117,13 +117,15 @@ STORE_LOCK = "restaurant.lock" UNITS_TABLE = "dishes.tsv" LOG_TABLE = "log.tsv" +GOALS_TABLE = "goals.tsv" STORE_COLUMNS = { UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", "kind", "goal"), LOG_TABLE: ("at", "kind", "id", "state", "note"), + GOALS_TABLE: ("id", "at", "state", "name", "purpose", "done", "share", "closed"), } # How many trailing cells a row of each table may lack. A missing cell reads as empty. brigade.py allows the same. ADDED_COLUMNS = {UNITS_TABLE: 2} -TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log"} +TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log", GOALS_TABLE: "goals table"} # The forms read_store accepts at these keys of the store's record. # Each is absent, null, or a string that encodes as UTF-8 and holds no NUL character. RECORD_WORDS = {"restaurant": "name", "projectRoot": "project root", "thread": "thread", "previousThread": "previous thread"} @@ -145,6 +147,7 @@ OTHER_MODEL = "other model" UNGROUPED = "Not tied to a work item" UNNAMED = "work item" +KINDS = ("feature", "fix", "polish", "upkeep", "study") TITLE_CHARS = 60 LABEL_CHARS = 40 @@ -188,6 +191,18 @@ class SourceError(ActivityError): status = 3 +@dataclass(frozen=True) +class GoalRecord: + id: str + name: str + + +@dataclass(frozen=True) +class Goal: + id: str + name: str + + @dataclass(frozen=True) class Unit: id: str @@ -197,6 +212,8 @@ class Unit: worker: str earlier_workers: tuple[str, ...] task: str + kind: str = "" + goal: str = "" @dataclass(frozen=True) @@ -206,6 +223,7 @@ class Store: coordinators: tuple[str, ...] units: tuple[Unit, ...] project_root: str = "" + goals: tuple[GoalRecord, ...] = () @dataclass(frozen=True) @@ -287,6 +305,8 @@ class Item: no_link: str = "" # The work item's position among the store's work items, so two work items that print alike stay two. No form prints it. key: int = 0 + kind: str = "" + goal: str = "" @dataclass(frozen=True) @@ -328,6 +348,8 @@ class Page: items: tuple[Item, ...] hidden: Hidden fold: int = 0 + goals: tuple[Goal, ...] = () + flight: Optional["Flight"] = None def parser(): @@ -473,6 +495,18 @@ def record_text(meta, key): return value +def goals_of(directory): + seen = set() + kept = [] + for row in read_table(directory, GOALS_TABLE): + ident = row["id"] + if not ident or ident in seen: + continue + seen.add(ident) + kept.append(GoalRecord(ident, row["name"])) + return tuple(kept) + + def read_store(directory): try: try: @@ -487,6 +521,7 @@ def read_store(directory): raise ActivityError("this is the executive admin's store; pass one coordinator's store directory") units = read_table(directory, UNITS_TABLE) log = read_table(directory, LOG_TABLE) + goals = goals_of(directory) except OSError as error: raise ActivityError(f"cannot read the store ({error.strerror}); check its permissions and run this again") from None earlier = {} @@ -494,12 +529,12 @@ def read_store(directory): if row["kind"] == "worker" and row["state"] == "retired" and row["note"]: earlier.setdefault(row["id"], []).append(row["note"]) by_id = { - row["id"]: Unit(row["id"], row["state"], row["summary"], row["pr"], row["thread"], tuple(earlier.get(row["id"], ())), row["task"]) + row["id"]: Unit(row["id"], row["state"], row["summary"], row["pr"], row["thread"], tuple(earlier.get(row["id"], ())), row["task"], row["kind"], row["goal"]) for row in units } name, root = record_text(meta, "restaurant"), record_text(meta, "projectRoot") coordinators = tuple(value for value in (record_text(meta, "thread"), record_text(meta, "previousThread")) if value.strip()) - return Store(name, frozenset(re.findall(r"[a-z0-9]+", f"{name} {Path(root).name}".lower())), coordinators, tuple(by_id.values()), root) + return Store(name, frozenset(re.findall(r"[a-z0-9]+", f"{name} {Path(root).name}".lower())), coordinators, tuple(by_id.values()), root, goals) def roots_of(store): @@ -1185,16 +1220,21 @@ def with_parents(agents): return [agent for agent in agents if agent.thread in kept] -def item_of(unit, privacy, key): +def item_of(unit, privacy, key, goal_ids=()): word, tone = STATE_WORDS.get(unit.state, UNKNOWN_STATE) link, why = privacy.link(unit.pr) - return Item(privacy.field(unit.id) or UNNAMED, privacy.field(unit.summary), word, tone, link, unit.state not in FINISHED_STATES, why, key) + kind = unit.kind if unit.kind in KINDS else "" + goal = unit.goal if unit.goal in goal_ids else "" + return Item(privacy.field(unit.id) or UNNAMED, privacy.field(unit.summary), word, tone, link, unit.state not in FINISHED_STATES, why, key, kind, goal) def build_page(store, t3, window, privacy): """Each string of the page that comes from the store or from T3 is a value privacy.field, privacy.model, or privacy.link returned, or a label short() cut from a field() value. The other strings are the constants of this file and numbers. No page type holds a thread id, a sub-agent id, or a path. """ + goals = tuple(Goal(record.id, clip(privacy.field(record.name), SUMMARY_BYTES)) for record in store.goals) + goal_ids = {goal.id for goal in goals} + def row(agent, depth, label): turn = open_turn(agent) return Row(depth, label, privacy.model(agent.model), PROVIDERS.get(agent.provider, OTHER_PROVIDER)[0], status_of(agent), @@ -1218,7 +1258,7 @@ def order(entry): active = [agent for agent in agents if stretches(agent)] subagents = sum(agent.parent is not None for agent in active) by_request += sum(placed[agent.thread].evidence is Evidence.REQUEST for agent in active) - groups.append(Group(item_of(units[unit], privacy, keys[unit]) if unit else None, rows, len(active) - subagents, subagents)) + groups.append(Group(item_of(units[unit], privacy, keys[unit], goal_ids) if unit else None, rows, len(active) - subagents, subagents)) everyone = [row for group in groups for row in group.rows if row.stands_for] subagents = sum(group.subagents for group in groups) totals = Totals(sum(row.status is Status.RUNNING for row in everyone), len(everyone) - subagents, subagents, sum(row.status is Status.FAILED for row in everyone)) @@ -1236,9 +1276,9 @@ def number(unit): digits = re.search(r"[0-9]{1,18}", unit.id) return (int(digits.group()) if digits else 0, unit.id) - items = tuple(item_of(unit, privacy, keys[unit.id]) for unit in sorted(units.values(), key=number) if unit.state not in FINISHED_STATES) + items = tuple(item_of(unit, privacy, keys[unit.id], goal_ids) for unit in sorted(units.values(), key=number) if unit.state not in FINISHED_STATES) hidden = Hidden(other_threads=t3.other_threads, unknown_status=t3.unknown_status, by_request_name=by_request, unstarted=t3.unstarted) - return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden) + return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden, goals=goals) KEPT_ITEMS = (24, 16, 12, 8, 6, 4, 2, 0) diff --git a/tests/test_activity.py b/tests/test_activity.py index 3d256c72..181e7068 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -1,3 +1,4 @@ +import base64 import fcntl import hashlib import html @@ -12,6 +13,7 @@ import sys import tempfile import unittest +import zlib from dataclasses import replace from datetime import datetime, timedelta, timezone from pathlib import Path @@ -394,7 +396,7 @@ def test_wrong_field_count_on_a_line_before_one_that_is_not_utf8_exits_1_with_th def test_store_columns_equal_brigade_tables(self): tables = runpy.run_path(str(SCRIPT.with_name("brigade.py")))["TABLES"] - self.assertEqual(MOD["STORE_COLUMNS"], {name: tables[name] for name in ("dishes.tsv", "log.tsv")}) + self.assertEqual({name: MOD["STORE_COLUMNS"][name] for name in ("dishes.tsv", "log.tsv")}, {name: tables[name] for name in ("dishes.tsv", "log.tsv")}) class ArgumentTest(ActivityCase): @@ -3275,5 +3277,50 @@ def test_window_is_labelled_by_its_whole_seconds_as_hours_minutes_and_seconds_in self.assertEqual(data_of(self.document("--hours", hours))["w"][1:], [seconds, words]) + +def frozen_bytes(script, fixture, *args): + words = [sys.executable, "-c", FROZEN, repr(fixture.now), str(script), "--at", str(fixture.store), "--t3-home", str(fixture.base), *args] + environment = {"HOME": str(fixture.home), "PATH": os.environ.get("PATH", "")} + result = subprocess.run(words, capture_output=True, env=environment, cwd=fixture.root) + if (result.returncode, result.stderr) != (0, b""): + raise AssertionError(result.stderr) + return result.stdout + + +PRIOR_ACTIVITY = """ +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 +""" + + +class PriorPageTest(OutputCase): + def test_a_store_from_before_kinds_matches_the_old_page(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + script = Path(directory.name) / "activity.py" + script.write_bytes(zlib.decompress(base64.b64decode(PRIOR_ACTIVITY))) + fixture = one_running(self.fixture).write() + self.assertEqual([len(line.split("\t")) for line in (fixture.store / "dishes.tsv").read_text().splitlines()], [15, 15, 15]) + self.assertFalse((fixture.store / "goals.tsv").exists()) + for args in ((), ("--text",)): + self.assertEqual(fixture.stdout_bytes(*args), frozen_bytes(script, fixture, *args)) + + second = one_running(Fixture(self.root / "outside")).write() + (second.store / "goals.tsv").write_text( + "id\tat\tstate\tname\tpurpose\tdone\tshare\tclosed\n" + "G1\t2026-10-10T11:00:00.000Z\topen\tUnused goal name\tpurpose text\tdone test\t1\t\n") + table = second.store / "dishes.tsv" + header, *rows = table.read_text().splitlines() + table.write_text("\n".join([header, *("\t".join([row, "nope", "G9"]) for row in rows)]) + "\n") + html, text = second.stdout_bytes(), second.stdout_bytes("--text") + self.assertEqual((html, text), (frozen_bytes(script, second), frozen_bytes(script, second, "--text"))) + for output in (html, text): + self.assertNotIn(b"Unassigned", output) + self.assertNotIn(b"Unused goal name", output) + self.assertNotIn(b"nope", output) + self.assertNotIn(" \u00b7 ".encode(), text) + self.assertNotIn(" \u00b7 ", DATA.search(html.decode()).group(1)) + + + if __name__ == "__main__": unittest.main() From 4ac33161c00228a4b6cdb7b14023837d65552ca6 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 20:31:40 -0500 Subject: [PATCH 2/9] activity: group the work-item strip by goal and tag each kind --- changes/pstack-t3%2Fd173.md | 1 + skills/brigade/scripts/activity.py | 182 ++++++++++++++++++++++++--- t3/added/brigade/scripts/activity.py | 128 +++++++++++++++++-- tests/test_activity.py | 163 +++++++++++++++++++++++- 4 files changed, 446 insertions(+), 28 deletions(-) create mode 100644 changes/pstack-t3%2Fd173.md diff --git a/changes/pstack-t3%2Fd173.md b/changes/pstack-t3%2Fd173.md new file mode 100644 index 00000000..d3d5b69e --- /dev/null +++ b/changes/pstack-t3%2Fd173.md @@ -0,0 +1 @@ +- The activity page groups each shown work item under its goal name and prints one count line per shown group, using the chip's state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. An item with no resolved goal is under Unassigned. A page with no such kind and no resolved goal stays the page the store printed before kinds and goals. diff --git a/skills/brigade/scripts/activity.py b/skills/brigade/scripts/activity.py index d5f1cd35..ada92d8c 100644 --- a/skills/brigade/scripts/activity.py +++ b/skills/brigade/scripts/activity.py @@ -117,13 +117,15 @@ STORE_LOCK = "restaurant.lock" UNITS_TABLE = "dishes.tsv" LOG_TABLE = "log.tsv" +GOALS_TABLE = "goals.tsv" STORE_COLUMNS = { UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", "kind", "goal"), LOG_TABLE: ("at", "kind", "id", "state", "note"), + GOALS_TABLE: ("id", "at", "state", "name", "purpose", "done", "share", "closed"), } # How many trailing cells a row of each table may lack. A missing cell reads as empty. brigade.py allows the same. ADDED_COLUMNS = {UNITS_TABLE: 2} -TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log"} +TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log", GOALS_TABLE: "goals table"} # The forms read_store accepts at these keys of the store's record. # Each is absent, null, or a string that encodes as UTF-8 and holds no NUL character. RECORD_WORDS = {"restaurant": "name", "projectRoot": "project root", "thread": "thread", "previousThread": "previous thread"} @@ -145,6 +147,8 @@ OTHER_MODEL = "other model" UNGROUPED = "Not tied to a work item" UNNAMED = "work item" +KINDS = ("feature", "fix", "polish", "upkeep", "study") +UNASSIGNED = "Unassigned" TITLE_CHARS = 60 LABEL_CHARS = 40 @@ -188,6 +192,18 @@ class SourceError(ActivityError): status = 3 +@dataclass(frozen=True) +class GoalRecord: + id: str + name: str + + +@dataclass(frozen=True) +class Goal: + id: str + name: str + + @dataclass(frozen=True) class Unit: id: str @@ -197,6 +213,8 @@ class Unit: worker: str earlier_workers: tuple[str, ...] task: str + kind: str = "" + goal: str = "" @dataclass(frozen=True) @@ -206,6 +224,7 @@ class Store: coordinators: tuple[str, ...] units: tuple[Unit, ...] project_root: str = "" + goals: tuple[GoalRecord, ...] = () @dataclass(frozen=True) @@ -287,6 +306,8 @@ class Item: no_link: str = "" # The work item's position among the store's work items, so two work items that print alike stay two. No form prints it. key: int = 0 + kind: str = "" + goal: str = "" @dataclass(frozen=True) @@ -328,6 +349,20 @@ class Page: items: tuple[Item, ...] hidden: Hidden fold: int = 0 + goals: tuple[Goal, ...] = () + flight: Optional["Flight"] = None + + +@dataclass(frozen=True) +class StripGroup: + heading: str + count: str + items: tuple[Item, ...] + + +@dataclass(frozen=True) +class Flight: + groups: tuple[StripGroup, ...] def parser(): @@ -473,6 +508,18 @@ def record_text(meta, key): return value +def goals_of(directory): + seen = set() + kept = [] + for row in read_table(directory, GOALS_TABLE): + ident = row["id"] + if not ident or ident in seen: + continue + seen.add(ident) + kept.append(GoalRecord(ident, row["name"])) + return tuple(kept) + + def read_store(directory): try: try: @@ -487,6 +534,7 @@ def read_store(directory): raise ActivityError("this is the executive admin's store; pass one coordinator's store directory") units = read_table(directory, UNITS_TABLE) log = read_table(directory, LOG_TABLE) + goals = goals_of(directory) except OSError as error: raise ActivityError(f"cannot read the store ({error.strerror}); check its permissions and run this again") from None earlier = {} @@ -494,12 +542,12 @@ def read_store(directory): if row["kind"] == "worker" and row["state"] == "retired" and row["note"]: earlier.setdefault(row["id"], []).append(row["note"]) by_id = { - row["id"]: Unit(row["id"], row["state"], row["summary"], row["pr"], row["thread"], tuple(earlier.get(row["id"], ())), row["task"]) + row["id"]: Unit(row["id"], row["state"], row["summary"], row["pr"], row["thread"], tuple(earlier.get(row["id"], ())), row["task"], row["kind"], row["goal"]) for row in units } name, root = record_text(meta, "restaurant"), record_text(meta, "projectRoot") coordinators = tuple(value for value in (record_text(meta, "thread"), record_text(meta, "previousThread")) if value.strip()) - return Store(name, frozenset(re.findall(r"[a-z0-9]+", f"{name} {Path(root).name}".lower())), coordinators, tuple(by_id.values()), root) + return Store(name, frozenset(re.findall(r"[a-z0-9]+", f"{name} {Path(root).name}".lower())), coordinators, tuple(by_id.values()), root, goals) def roots_of(store): @@ -1185,16 +1233,21 @@ def with_parents(agents): return [agent for agent in agents if agent.thread in kept] -def item_of(unit, privacy, key): +def item_of(unit, privacy, key, goal_ids=()): word, tone = STATE_WORDS.get(unit.state, UNKNOWN_STATE) link, why = privacy.link(unit.pr) - return Item(privacy.field(unit.id) or UNNAMED, privacy.field(unit.summary), word, tone, link, unit.state not in FINISHED_STATES, why, key) + kind = unit.kind if unit.kind in KINDS else "" + goal = unit.goal if unit.goal in goal_ids else "" + return Item(privacy.field(unit.id) or UNNAMED, privacy.field(unit.summary), word, tone, link, unit.state not in FINISHED_STATES, why, key, kind, goal) def build_page(store, t3, window, privacy): """Each string of the page that comes from the store or from T3 is a value privacy.field, privacy.model, or privacy.link returned, or a label short() cut from a field() value. The other strings are the constants of this file and numbers. No page type holds a thread id, a sub-agent id, or a path. """ + goals = tuple(Goal(record.id, clip(privacy.field(record.name), SUMMARY_BYTES)) for record in store.goals) + goal_ids = {goal.id for goal in goals} + def row(agent, depth, label): turn = open_turn(agent) return Row(depth, label, privacy.model(agent.model), PROVIDERS.get(agent.provider, OTHER_PROVIDER)[0], status_of(agent), @@ -1218,7 +1271,7 @@ def order(entry): active = [agent for agent in agents if stretches(agent)] subagents = sum(agent.parent is not None for agent in active) by_request += sum(placed[agent.thread].evidence is Evidence.REQUEST for agent in active) - groups.append(Group(item_of(units[unit], privacy, keys[unit]) if unit else None, rows, len(active) - subagents, subagents)) + groups.append(Group(item_of(units[unit], privacy, keys[unit], goal_ids) if unit else None, rows, len(active) - subagents, subagents)) everyone = [row for group in groups for row in group.rows if row.stands_for] subagents = sum(group.subagents for group in groups) totals = Totals(sum(row.status is Status.RUNNING for row in everyone), len(everyone) - subagents, subagents, sum(row.status is Status.FAILED for row in everyone)) @@ -1236,9 +1289,9 @@ def number(unit): digits = re.search(r"[0-9]{1,18}", unit.id) return (int(digits.group()) if digits else 0, unit.id) - items = tuple(item_of(unit, privacy, keys[unit.id]) for unit in sorted(units.values(), key=number) if unit.state not in FINISHED_STATES) + items = tuple(item_of(unit, privacy, keys[unit.id], goal_ids) for unit in sorted(units.values(), key=number) if unit.state not in FINISHED_STATES) hidden = Hidden(other_threads=t3.other_threads, unknown_status=t3.unknown_status, by_request_name=by_request, unstarted=t3.unstarted) - return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden) + return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden, goals=goals, flight=flight_of(items, goals)) KEPT_ITEMS = (24, 16, 12, 8, 6, 4, 2, 0) @@ -1409,6 +1462,49 @@ def counted(hidden, cuts, unlisted): dropped_items=hidden.dropped_items + gone_items, dropped_agents=hidden.dropped_agents + gone_agents) +def tally(items): + counts = {} + for item in items: + counts[item.state] = counts.get(item.state, 0) + 1 + ordered = [word for state, (word, _) in STATE_WORDS.items() if state not in FINISHED_STATES] + ordered.append(UNKNOWN_STATE[0]) + seen = set(ordered) + parts = [f"{counts[word]} {word}" for word in ordered if counts.get(word)] + for item in items: + word = item.state + if word not in seen: + parts.append(f"{counts[word]} {word}") + seen.add(word) + return ", ".join(parts) + + +def flight_of(items, goals): + ids = {goal.id for goal in goals} + grouped = {goal.id: [] for goal in goals} + loose = [] + labeled = False + for item in items: + resolved = item.goal in ids + if item.kind or resolved: + labeled = True + (grouped[item.goal] if resolved else loose).append(item) + if not labeled: + return None + groups = [StripGroup(goal.name, tally(grouped[goal.id]), tuple(grouped[goal.id])) for goal in goals if grouped[goal.id]] + if loose: + groups.append(StripGroup(UNASSIGNED, tally(loose), tuple(loose))) + return Flight(tuple(groups)) + + +def cut_items(page, items): + kept = tuple(items) + return replace(page, items=kept, flight=flight_of(kept, page.goals)) + + +def count_line(group): + return f"{group.heading} · {group.count}" if group.heading else group.count + + def cap_everything(page): def running(row): @@ -1427,7 +1523,8 @@ def running(row): items = tuple(shown(item) for item, keep in zip(page.items, listed) if keep) hidden = counted(page.hidden, cuts, len(page.items) - len(items)) coordinator = narrow(page.coordinator, COORDINATOR_SPANS) if page.coordinator else None - return replace(page, name=clip(page.name, NAME_BYTES), coordinator=coordinator, groups=tuple(groups), items=items, hidden=hidden, fold=page.fold + 1) + page = replace(page, name=clip(page.name, NAME_BYTES), coordinator=coordinator, groups=tuple(groups), hidden=hidden, fold=page.fold + 1) + return cut_items(page, items) FOLDS = (fold_finished_items, fold_quiet_subagents, *(partial(keep_finished_items, limit) for limit in KEPT_ITEMS), cap_everything) @@ -1473,12 +1570,15 @@ def fixed_height(page, said): def height(page): """The modelled height of the HTML page at 390 px wide. fixed_height, plus GROUP_PX a group and ROW_PX a row, plus HEADING_PX once for each of the two open lists that has a line and OPEN_PX a line in it, plus STRIP_PX once and CHIP_PX a chip when the page has chips. + A grouped strip adds LINE_PX for each count line and GROUP_PX for each heading. """ rows = [row for group in page.groups for row in group.rows] total = fixed_height(page, notes(page, items_of(page))) + GROUP_PX * len(page.groups) + ROW_PX * len(rows) for status in OPEN: lines = sum(row.open_seconds is not None and row.status is status for row in rows) total += HEADING_PX + OPEN_PX * lines if lines else 0 + if page.flight is not None: + total += (LINE_PX + GROUP_PX) * len(page.flight.groups) return total + (STRIP_PX + CHIP_PX * len(page.items) if page.items else 0) @@ -1525,7 +1625,7 @@ def tier(row): cuts.append((group, rows or None)) if rows: groups.append(replace(group, rows=rows)) - return replace(page, groups=tuple(groups), items=items, hidden=counted(page.hidden, cuts, len(page.items) - len(items))) + return cut_items(replace(page, groups=tuple(groups), hidden=counted(page.hidden, cuts, len(page.items) - len(items))), items) def fit(page, budget, clean, frame=None): @@ -1554,7 +1654,7 @@ def shortened(page): The name, each item's id and summary, and each group row's label and model are clipped as cap_everything clips them, and each link it would leave out is left out. """ groups = tuple(replace(group, item=shown(group.item) if group.item else None, rows=tuple(narrow(row) for row in group.rows)) for group in page.groups) - return replace(page, name=clip(page.name, NAME_BYTES), groups=groups, items=tuple(shown(item) for item in page.items)) + return cut_items(replace(page, name=clip(page.name, NAME_BYTES), groups=groups), (shown(item) for item in page.items)) def fit_text(page, budget, clean): @@ -1819,6 +1919,32 @@ def tight(code): } """) +STRIP_CSS = ".strip>.grp,.strip>.lg{flex:1 0 100%;border:0;background:transparent;border-radius:0;padding:2px 0}.strip>.lg{flex-direction:column;align-items:flex-start}" +STRIP_HOOK = squeezed(""" + if (D && D.v === 1 && D.F && D.F.length) { + const link = (parent, text, url) => { + const safe = url.startsWith('https://'), node = add(parent, safe ? 'a' : 'span', '', text); + if (safe) { node.href = url; node.target = '_blank'; node.rel = 'noopener'; } + return node; + }; + const box = add(root, 'div', 'strip'), counts = add(box, 'div', 'lg'); + for (const [count, heading, indexes] of D.F) { + add(counts, 'div', '', count); + add(box, 'div', 'grp', heading); + for (const index of indexes) { + const item = D.I[index], chip = link(box, '', item[4]); + if (item[6]) add(chip, 'b', 'chip', item[6]); + add(chip, 'b', 'one', item[0]); + add(chip, 'span', 'sum', item[1]); + add(chip, 'b', 'chip ' + item[3], item[2]); + } + } + const foot = root.querySelector('.foot'), title = add(root, 'h2', '', 'Work items in flight'); + root.insertBefore(title, foot); + root.insertBefore(box, foot); + } +""") + def wire(page): models, providers = [], [name for name, _ in page.legend] @@ -1839,7 +1965,15 @@ def line(row): items = items_of(page) own, totals = page.coordinator, page.totals - return { + grouped = page.flight is not None + + def cells(item): + row = [item.id, item.summary, item.state, item.tone, item.pr, 0 if grouped else int(item in page.items)] + if grouped: + row.append(item.kind) + return row + + data = { "v": WIRE_VERSION, "c": page.name, "w": [int(page.window.start), round(page.window.end - page.window.start), span(page.window)], @@ -1847,11 +1981,15 @@ def line(row): "S": [status.value for status in WIRE_STATUS], "P": [[name, colors[name], agents] for name, agents in page.legend], "k": own and [model(own.model), WIRE_STATUS.index(own.status), dur(own.seconds), bars(own.spans)], - "I": [[item.id, item.summary, item.state, item.tone, item.pr, int(item in page.items)] for item in items], + "I": [cells(item) for item in items], "G": [[items.index(group.item) if group.item else -1, [line(row) for row in group.rows]] for group in page.groups], "M": models, "N": notes(page, items), } + if grouped: + places = {id(item): index for index, item in enumerate(items)} + data["F"] = [[count_line(group), group.heading, [places[id(item)] for item in group.items]] for group in page.flight.groups] + return data def checksum(text): @@ -1864,10 +2002,12 @@ def checksum(text): def render_html(page, clean): """The document. The only strings in it that are not constants of this file are in the data element, and encode() passes each of those to clean.""" data = encode(wire(page), clean) + style = STYLE if page.flight is None else STYLE + STRIP_CSS + script = RENDERER if page.flight is None else RENDERER + STRIP_HOOK return ( "" - f"Agent activity
" - f"") + f"Agent activity
" + f"") def span(window): @@ -1909,9 +2049,19 @@ def worked(rows): failed.append(line(name, row.model, worked([row]))) drawn = {group.item: f"{people(group.agents, group.subagents)}, {worked(group.rows)}" for group in page.groups if group.item} listed = [*drawn, *(item for item in page.items if item not in drawn)][:TEXT_ITEMS] - items = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] more = len(drawn) + sum(item not in drawn for item in page.items) - len(listed) loose = [line(UNGROUPED, f"{people(group.agents, group.subagents)}, {worked(group.rows)}") for group in page.groups if not group.item] + flat = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] + text_flight = flight_of(listed, page.goals) if page.flight is not None else None + if text_flight is None: + items = flat + else: + items = [] + for group in text_flight.groups: + items.append(line(group.heading, group.count)) + items.append(" " + group.heading) + for item in group.items: + items.append(" " + line(item.kind, item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr)) totals = page.totals lines = [ f"Agent activity for {page.name}, last {span(page.window)}", diff --git a/t3/added/brigade/scripts/activity.py b/t3/added/brigade/scripts/activity.py index 98b9196a..ada92d8c 100644 --- a/t3/added/brigade/scripts/activity.py +++ b/t3/added/brigade/scripts/activity.py @@ -148,6 +148,7 @@ UNGROUPED = "Not tied to a work item" UNNAMED = "work item" KINDS = ("feature", "fix", "polish", "upkeep", "study") +UNASSIGNED = "Unassigned" TITLE_CHARS = 60 LABEL_CHARS = 40 @@ -352,6 +353,18 @@ class Page: flight: Optional["Flight"] = None +@dataclass(frozen=True) +class StripGroup: + heading: str + count: str + items: tuple[Item, ...] + + +@dataclass(frozen=True) +class Flight: + groups: tuple[StripGroup, ...] + + def parser(): top = argparse.ArgumentParser(prog="activity.py", description=__doc__) top.add_argument("--at", help="the coordinator's store directory (default $BRIGADE_DIR)") @@ -1278,7 +1291,7 @@ def number(unit): items = tuple(item_of(unit, privacy, keys[unit.id], goal_ids) for unit in sorted(units.values(), key=number) if unit.state not in FINISHED_STATES) hidden = Hidden(other_threads=t3.other_threads, unknown_status=t3.unknown_status, by_request_name=by_request, unstarted=t3.unstarted) - return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden, goals=goals) + return Page(privacy.field(store.name) or "this coordinator", window, totals, legend, own, tuple(groups), items, hidden, goals=goals, flight=flight_of(items, goals)) KEPT_ITEMS = (24, 16, 12, 8, 6, 4, 2, 0) @@ -1449,6 +1462,49 @@ def counted(hidden, cuts, unlisted): dropped_items=hidden.dropped_items + gone_items, dropped_agents=hidden.dropped_agents + gone_agents) +def tally(items): + counts = {} + for item in items: + counts[item.state] = counts.get(item.state, 0) + 1 + ordered = [word for state, (word, _) in STATE_WORDS.items() if state not in FINISHED_STATES] + ordered.append(UNKNOWN_STATE[0]) + seen = set(ordered) + parts = [f"{counts[word]} {word}" for word in ordered if counts.get(word)] + for item in items: + word = item.state + if word not in seen: + parts.append(f"{counts[word]} {word}") + seen.add(word) + return ", ".join(parts) + + +def flight_of(items, goals): + ids = {goal.id for goal in goals} + grouped = {goal.id: [] for goal in goals} + loose = [] + labeled = False + for item in items: + resolved = item.goal in ids + if item.kind or resolved: + labeled = True + (grouped[item.goal] if resolved else loose).append(item) + if not labeled: + return None + groups = [StripGroup(goal.name, tally(grouped[goal.id]), tuple(grouped[goal.id])) for goal in goals if grouped[goal.id]] + if loose: + groups.append(StripGroup(UNASSIGNED, tally(loose), tuple(loose))) + return Flight(tuple(groups)) + + +def cut_items(page, items): + kept = tuple(items) + return replace(page, items=kept, flight=flight_of(kept, page.goals)) + + +def count_line(group): + return f"{group.heading} · {group.count}" if group.heading else group.count + + def cap_everything(page): def running(row): @@ -1467,7 +1523,8 @@ def running(row): items = tuple(shown(item) for item, keep in zip(page.items, listed) if keep) hidden = counted(page.hidden, cuts, len(page.items) - len(items)) coordinator = narrow(page.coordinator, COORDINATOR_SPANS) if page.coordinator else None - return replace(page, name=clip(page.name, NAME_BYTES), coordinator=coordinator, groups=tuple(groups), items=items, hidden=hidden, fold=page.fold + 1) + page = replace(page, name=clip(page.name, NAME_BYTES), coordinator=coordinator, groups=tuple(groups), hidden=hidden, fold=page.fold + 1) + return cut_items(page, items) FOLDS = (fold_finished_items, fold_quiet_subagents, *(partial(keep_finished_items, limit) for limit in KEPT_ITEMS), cap_everything) @@ -1513,12 +1570,15 @@ def fixed_height(page, said): def height(page): """The modelled height of the HTML page at 390 px wide. fixed_height, plus GROUP_PX a group and ROW_PX a row, plus HEADING_PX once for each of the two open lists that has a line and OPEN_PX a line in it, plus STRIP_PX once and CHIP_PX a chip when the page has chips. + A grouped strip adds LINE_PX for each count line and GROUP_PX for each heading. """ rows = [row for group in page.groups for row in group.rows] total = fixed_height(page, notes(page, items_of(page))) + GROUP_PX * len(page.groups) + ROW_PX * len(rows) for status in OPEN: lines = sum(row.open_seconds is not None and row.status is status for row in rows) total += HEADING_PX + OPEN_PX * lines if lines else 0 + if page.flight is not None: + total += (LINE_PX + GROUP_PX) * len(page.flight.groups) return total + (STRIP_PX + CHIP_PX * len(page.items) if page.items else 0) @@ -1565,7 +1625,7 @@ def tier(row): cuts.append((group, rows or None)) if rows: groups.append(replace(group, rows=rows)) - return replace(page, groups=tuple(groups), items=items, hidden=counted(page.hidden, cuts, len(page.items) - len(items))) + return cut_items(replace(page, groups=tuple(groups), hidden=counted(page.hidden, cuts, len(page.items) - len(items))), items) def fit(page, budget, clean, frame=None): @@ -1594,7 +1654,7 @@ def shortened(page): The name, each item's id and summary, and each group row's label and model are clipped as cap_everything clips them, and each link it would leave out is left out. """ groups = tuple(replace(group, item=shown(group.item) if group.item else None, rows=tuple(narrow(row) for row in group.rows)) for group in page.groups) - return replace(page, name=clip(page.name, NAME_BYTES), groups=groups, items=tuple(shown(item) for item in page.items)) + return cut_items(replace(page, name=clip(page.name, NAME_BYTES), groups=groups), (shown(item) for item in page.items)) def fit_text(page, budget, clean): @@ -1859,6 +1919,32 @@ def tight(code): } """) +STRIP_CSS = ".strip>.grp,.strip>.lg{flex:1 0 100%;border:0;background:transparent;border-radius:0;padding:2px 0}.strip>.lg{flex-direction:column;align-items:flex-start}" +STRIP_HOOK = squeezed(""" + if (D && D.v === 1 && D.F && D.F.length) { + const link = (parent, text, url) => { + const safe = url.startsWith('https://'), node = add(parent, safe ? 'a' : 'span', '', text); + if (safe) { node.href = url; node.target = '_blank'; node.rel = 'noopener'; } + return node; + }; + const box = add(root, 'div', 'strip'), counts = add(box, 'div', 'lg'); + for (const [count, heading, indexes] of D.F) { + add(counts, 'div', '', count); + add(box, 'div', 'grp', heading); + for (const index of indexes) { + const item = D.I[index], chip = link(box, '', item[4]); + if (item[6]) add(chip, 'b', 'chip', item[6]); + add(chip, 'b', 'one', item[0]); + add(chip, 'span', 'sum', item[1]); + add(chip, 'b', 'chip ' + item[3], item[2]); + } + } + const foot = root.querySelector('.foot'), title = add(root, 'h2', '', 'Work items in flight'); + root.insertBefore(title, foot); + root.insertBefore(box, foot); + } +""") + def wire(page): models, providers = [], [name for name, _ in page.legend] @@ -1879,7 +1965,15 @@ def line(row): items = items_of(page) own, totals = page.coordinator, page.totals - return { + grouped = page.flight is not None + + def cells(item): + row = [item.id, item.summary, item.state, item.tone, item.pr, 0 if grouped else int(item in page.items)] + if grouped: + row.append(item.kind) + return row + + data = { "v": WIRE_VERSION, "c": page.name, "w": [int(page.window.start), round(page.window.end - page.window.start), span(page.window)], @@ -1887,11 +1981,15 @@ def line(row): "S": [status.value for status in WIRE_STATUS], "P": [[name, colors[name], agents] for name, agents in page.legend], "k": own and [model(own.model), WIRE_STATUS.index(own.status), dur(own.seconds), bars(own.spans)], - "I": [[item.id, item.summary, item.state, item.tone, item.pr, int(item in page.items)] for item in items], + "I": [cells(item) for item in items], "G": [[items.index(group.item) if group.item else -1, [line(row) for row in group.rows]] for group in page.groups], "M": models, "N": notes(page, items), } + if grouped: + places = {id(item): index for index, item in enumerate(items)} + data["F"] = [[count_line(group), group.heading, [places[id(item)] for item in group.items]] for group in page.flight.groups] + return data def checksum(text): @@ -1904,10 +2002,12 @@ def checksum(text): def render_html(page, clean): """The document. The only strings in it that are not constants of this file are in the data element, and encode() passes each of those to clean.""" data = encode(wire(page), clean) + style = STYLE if page.flight is None else STYLE + STRIP_CSS + script = RENDERER if page.flight is None else RENDERER + STRIP_HOOK return ( "" - f"Agent activity
" - f"") + f"Agent activity
" + f"") def span(window): @@ -1949,9 +2049,19 @@ def worked(rows): failed.append(line(name, row.model, worked([row]))) drawn = {group.item: f"{people(group.agents, group.subagents)}, {worked(group.rows)}" for group in page.groups if group.item} listed = [*drawn, *(item for item in page.items if item not in drawn)][:TEXT_ITEMS] - items = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] more = len(drawn) + sum(item not in drawn for item in page.items) - len(listed) loose = [line(UNGROUPED, f"{people(group.agents, group.subagents)}, {worked(group.rows)}") for group in page.groups if not group.item] + flat = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] + text_flight = flight_of(listed, page.goals) if page.flight is not None else None + if text_flight is None: + items = flat + else: + items = [] + for group in text_flight.groups: + items.append(line(group.heading, group.count)) + items.append(" " + group.heading) + for item in group.items: + items.append(" " + line(item.kind, item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr)) totals = page.totals lines = [ f"Agent activity for {page.name}, last {span(page.window)}", diff --git a/tests/test_activity.py b/tests/test_activity.py index 181e7068..7cad3e2f 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -338,14 +338,22 @@ def test_work_item_row_of_16_cells_lacks_one_trailing_cell_and_is_read_with_an_e self.assertEqual(self.kinds_and_goals(), [("D7", "upkeep", ""), ("D6", "", "")]) self.assertEqual(MOD["read_table"](self.fixture.store, "dishes.tsv"), self.brigade_rows("dishes.tsv")) - def test_page_and_text_for_17_cell_work_item_rows_equal_those_for_the_same_rows_with_the_kind_and_goal_cells_removed(self): + def test_rows_outside_the_five_kinds_and_without_a_goal_row_print_the_15_cell_page_and_a_real_kind_does_not(self): fixture = one_running(self.fixture).write() narrow = (fixture.stdout_bytes(), fixture.stdout_bytes("--text")) - self.widened({"D7": ("feature", "G1"), "D6": ("fix", "G2")}) - self.assertEqual(self.kinds_and_goals(), [("D7", "feature", "G1"), ("D6", "fix", "G2")]) + self.widened({"D7": ("nope", "G9"), "D6": ("later", "G2")}) + self.assertEqual(self.kinds_and_goals(), [("D7", "nope", "G9"), ("D6", "later", "G2")]) self.assertEqual((fixture.stdout_bytes(), fixture.stdout_bytes("--text")), narrow) self.assertIn(b"Agent activity page", narrow[0]) self.assertEqual(narrow[1].split(b"\n")[0], b"Agent activity for kit, last 3h") + fixture.write_store() + self.widened({"D7": ("feature", "G1"), "D6": ("fix", "G2")}) + self.assertEqual(self.kinds_and_goals(), [("D7", "feature", "G1"), ("D6", "fix", "G2")]) + wide = (fixture.stdout_bytes(), fixture.stdout_bytes("--text")) + self.assertNotEqual(wide, narrow) + self.assertIn(b"feature", wide[0]) + self.assertIn(b"feature", wide[1]) + self.assertNotIn(b"nope", wide[0]) def test_work_item_row_of_14_cells_or_18_cells_exits_1_with_its_line_number(self): for extra in (None, ("feature", "G1", "one more")): @@ -1575,6 +1583,25 @@ def test_notes_say_no_agent_ran_for_an_empty_window_and_count_unknown_statuses_r const make = tag => ({ tagName: tag.toUpperCase(), kids: [], style: {}, className: '', own: '', appendChild(kid) { this.kids.push(kid); return kid; }, + insertBefore(node, ref) { + const from = this.kids.indexOf(node); + if (from >= 0) this.kids.splice(from, 1); + const at = ref ? this.kids.indexOf(ref) : -1; + if (at < 0) this.kids.push(node); else this.kids.splice(at, 0, node); + return node; + }, + querySelector(sel) { + const name = sel[0] === '.' ? sel.slice(1) : sel; + const walk = (node) => { + if ((node.className || '').split(' ').includes(name)) return node; + for (const kid of node.kids || []) { + const found = walk(kid); + if (found) return found; + } + return null; + }; + return walk(this); + }, set textContent(value) { this.kids = []; this.own = String(value); }, get textContent() { return this.own; }, }); @@ -3321,6 +3348,136 @@ def test_a_store_from_before_kinds_matches_the_old_page(self): self.assertNotIn(" \u00b7 ", DATA.search(html.decode()).group(1)) +def write_goals(fixture, rows): + header = "id\tat\tstate\tname\tpurpose\tdone\tshare\tclosed" + (fixture.store / "goals.tsv").write_text("\n".join([header, *("\t".join(row) for row in rows)]) + "\n") + + +def widen_items(fixture, cells): + path = fixture.store / "dishes.tsv" + header, *rows = path.read_text().splitlines() + widened = ["\t".join([row, *cells.get(row.split("\t")[0], ())]) for row in rows] + path.write_text("\n".join([header, *widened]) + "\n") + + +def painted(case, document): + data, program = DATA.search(document).group(1), PROGRAM.search(document).group(1) + script = case.fixture.root / "render.js" + script.write_text(f"const DATA = {json.dumps(data)};{STUB}{program}{REPORT}") + result = subprocess.run(["node", str(script)], capture_output=True, text=True, env={"PATH": os.environ["PATH"], "TZ": "UTC"}) + case.assertEqual((result.returncode, result.stderr), (0, ""), result.stderr) + return json.loads(result.stdout) + + +class GoalStripTest(OutputCase): + def test_a_goal_name_loses_each_kind_of_private_text(self): + fixture = self.fixture + fixture.coordinator() + fixture.unit("D7", "in-progress", "Parser work", thread=worker(1)) + fixture.thread(worker(1), title="D7 worker", turns=(("running", 30, None),)) + fixture.write() + email = "ada@private.example" + uuid = "7ead0a2b-e89b-12d3-a456-426614174000" + file_url = "file:/home/private/secret" + drive = "c:/secret/notes" + purpose = "purpose-sentinel-qq" + done = "done-sentinel-zz" + name = " ".join(["Kept", worker(1), str(fixture.store), email, uuid, file_url, drive, "parser"]) + write_goals(fixture, [("Gkept", "2026-10-10T11:00:00.000Z", "done", name, purpose, done, "1", "")]) + widen_items(fixture, {"D7": ("feature", "Gkept")}) + html, text = self.document(), self.out("--text") + for needle in (worker(1), str(fixture.store), email, uuid, file_url, drive, purpose, done, "Gkept"): + self.assertNotIn(needle, html) + self.assertNotIn(needle, text) + for kept in ("Kept", "parser"): + self.assertIn(kept, html) + self.assertIn(kept, text) + + def test_each_shown_item_is_under_its_goal_once_and_the_counts_match(self): + fixture = self.fixture + fixture.meta["thread"] = "" + for ident, state, summary in ( + ("D1", "in-progress", "Parser work"), + ("D2", "queued", "Queue one"), + ("D3", "queued", "Queue two"), + ("D4", "blocked", "Loose one"), + ("D5", "in-review", "Loose two")): + fixture.unit(ident, state, summary) + fixture.write() + stamp = "2026-10-10T11:00:00.000Z" + write_goals(fixture, [ + ("G1", stamp, "open", "Ship the parser", "purpose text", "done test", "1", ""), + ("G2", stamp, "done", "Retire the queue", "purpose text", "done test", "1", ""), + ]) + widen_items(fixture, {"D1": ("", "G1"), "D2": ("", "G2"), "D3": ("", "G2"), "D4": ("", ""), "D5": ("", "G9")}) + html, text = self.document(), self.out("--text") + data = data_of(html) + self.assertEqual([item[0] for item in data["I"]], ["D1", "D2", "D3", "D4", "D5"]) + self.assertEqual([item[5] for item in data["I"]], [0, 0, 0, 0, 0]) + self.assertEqual(data["F"], [ + ["Ship the parser · 1 working", "Ship the parser", [0]], + ["Retire the queue · 2 landing", "Retire the queue", [1, 2]], + ["Unassigned · 1 in review, 1 blocked", "Unassigned", [3, 4]], + ]) + seen = [] + for count, _heading, indexes in data["F"]: + seen += indexes + self.assertEqual(sum(int(part) for part in count.split() if part.isdigit()), len(indexes)) + self.assertEqual(seen, [0, 1, 2, 3, 4]) + lines = text.splitlines() + self.assertEqual(lines[lines.index("Work items") + 1:lines.index("Notes")], [ + " Ship the parser 1 working", + " Ship the parser", + " D1 working Parser work no activity in this window", + " Retire the queue 2 landing", + " Retire the queue", + " D2 landing Queue one no activity in this window", + " D3 landing Queue two no activity in this window", + " Unassigned 1 in review, 1 blocked", + " Unassigned", + " D4 blocked Loose one no activity in this window", + " D5 in review Loose two no activity in this window", + ]) + for ident in ("D1", "D2", "D3", "D4", "D5"): + self.assertEqual(html.count(ident), 1) + self.assertEqual(text.count(ident), 1) + drawn = painted(self, html)["texts"] + self.assertEqual(drawn[-22:], [ + "Ship the parser · 1 working", "Ship the parser", "D1", "Parser work", "working", + "Retire the queue · 2 landing", "Retire the queue", "D2", "Queue one", "landing", "D3", "Queue two", "landing", + "Unassigned · 1 in review, 1 blocked", "Unassigned", "D4", "Loose one", "blocked", "D5", "Loose two", "in review", + "Work items in flight", + ]) + for ident in ("D1", "D2", "D3", "D4", "D5"): + self.assertEqual(drawn.count(ident), 1) + + def test_a_kind_outside_the_five_shows_no_tag(self): + fixture = one_running(self.fixture).write() + widen_items(fixture, {"D7": ("feature", ""), "D6": ("nope", "")}) + html, text = self.document(), self.out("--text") + self.assertNotIn("nope", html) + self.assertNotIn("nope", text) + data = data_of(html) + self.assertEqual([(item[0], item[6]) for item in data["I"]], [("D6", ""), ("D7", "feature")]) + item_lines = [line for line in text.splitlines() if " D6 " in line or " D7 " in line] + self.assertEqual(item_lines, [ + " feature D7 working Agent activity page 1 agent, 63m at work " + PR7, + " D6 landing Queue fix no activity in this window " + PR6, + ]) + drawn = painted(self, html)["texts"] + self.assertEqual(drawn.count("feature"), 1) + self.assertNotIn("nope", drawn) + + def test_the_page_does_not_write_the_store_or_the_database(self): + fixture = one_running(self.fixture).write() + write_goals(fixture, [("G1", "2026-10-10T11:00:00.000Z", "open", "Ship the parser", "purpose text", "done test", "1", "")]) + widen_items(fixture, {"D7": ("feature", "G1"), "D6": ("fix", "G1")}) + before = listing(fixture.store), listing(fixture.database.parent) + self.assertIn("goals.tsv", before[0]) + self.document() + self.out("--text") + self.assertEqual((listing(fixture.store), listing(fixture.database.parent)), before) + if __name__ == "__main__": unittest.main() From 4c52beb00d66776df1603bf18fad113441e266c2 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 20:52:19 -0500 Subject: [PATCH 3/9] activity: assert a goal with no shown item stays off the page --- tests/test_activity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_activity.py b/tests/test_activity.py index 7cad3e2f..0123221d 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -3406,6 +3406,7 @@ def test_each_shown_item_is_under_its_goal_once_and_the_counts_match(self): fixture.write() stamp = "2026-10-10T11:00:00.000Z" write_goals(fixture, [ + ("G0", stamp, "paused", "Empty shelf", "purpose text", "done test", "1", ""), ("G1", stamp, "open", "Ship the parser", "purpose text", "done test", "1", ""), ("G2", stamp, "done", "Retire the queue", "purpose text", "done test", "1", ""), ]) @@ -3441,6 +3442,9 @@ def test_each_shown_item_is_under_its_goal_once_and_the_counts_match(self): for ident in ("D1", "D2", "D3", "D4", "D5"): self.assertEqual(html.count(ident), 1) self.assertEqual(text.count(ident), 1) + for absent in ("Empty shelf", "purpose text", "done test", "G0", "G1", "G2", "G9"): + self.assertNotIn(absent, html) + self.assertNotIn(absent, text) drawn = painted(self, html)["texts"] self.assertEqual(drawn[-22:], [ "Ship the parser · 1 working", "Ship the parser", "D1", "Parser work", "working", From be1553d1db85828015731d654598286222aaf92f Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 20:52:29 -0500 Subject: [PATCH 4/9] activity: reserve each strip group's height in the frame cut --- skills/brigade/scripts/activity.py | 4 +++- t3/added/brigade/scripts/activity.py | 4 +++- tests/test_activity.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/skills/brigade/scripts/activity.py b/skills/brigade/scripts/activity.py index ada92d8c..d60dedf5 100644 --- a/skills/brigade/scripts/activity.py +++ b/skills/brigade/scripts/activity.py @@ -1599,7 +1599,9 @@ def framed(page, limit): drawn = [group.item for group in page.groups] listed = first(page.items, MAX_STRIP, lambda item: item in drawn) items = tuple(item for item, keep in zip(page.items, listed) if keep) - left -= STRIP_PX + CHIP_PX * len(items) if items else 0 + flight = flight_of(items, page.goals) + groups = len(flight.groups) if flight is not None else 0 + left -= (STRIP_PX + CHIP_PX * len(items) + (LINE_PX + GROUP_PX) * groups) if items else 0 def tier(row): return 0 if row.open_seconds is not None else 1 if row.status is Status.FAILED else 2 diff --git a/t3/added/brigade/scripts/activity.py b/t3/added/brigade/scripts/activity.py index ada92d8c..d60dedf5 100644 --- a/t3/added/brigade/scripts/activity.py +++ b/t3/added/brigade/scripts/activity.py @@ -1599,7 +1599,9 @@ def framed(page, limit): drawn = [group.item for group in page.groups] listed = first(page.items, MAX_STRIP, lambda item: item in drawn) items = tuple(item for item, keep in zip(page.items, listed) if keep) - left -= STRIP_PX + CHIP_PX * len(items) if items else 0 + flight = flight_of(items, page.goals) + groups = len(flight.groups) if flight is not None else 0 + left -= (STRIP_PX + CHIP_PX * len(items) + (LINE_PX + GROUP_PX) * groups) if items else 0 def tier(row): return 0 if row.open_seconds is not None else 1 if row.status is Status.FAILED else 2 diff --git a/tests/test_activity.py b/tests/test_activity.py index 0123221d..d07cf27d 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -2133,6 +2133,17 @@ def test_framed_keeps_the_running_row_then_the_failed_rows_then_each_other_row_t self.assertEqual(cut.hidden, MOD["Hidden"](dropped_items=1, dropped_agents=2, cut_agents=1)) self.assertEqual((MOD["height"](cut), cut.fold), (607, 0)) + def test_the_frame_keeps_room_for_each_strip_group(self): + goal = MOD["Goal"]("G1", "Ship") + chip = MOD["Item"]("D1", "one", "working", "go", "", True, "", 1, "feature", "G1") + built = page(group(None, *(row(f"worker {number}") for number in range(4))), items=(chip,), + hidden=MOD["Hidden"](dropped_items=1, dropped_agents=1, cut_agents=1, cut_in_flight=1)) + built = replace(built, goals=(goal,), flight=MOD["flight_of"]((chip,), (goal,))) + limit = MOD["height"](built) - 1 + cut = MOD["framed"](built, limit) + self.assertLessEqual(MOD["height"](cut), limit) + self.assertLess(sum(len(one.rows) for one in cut.groups), 4) + def test_max_bytes_16001_prints_the_77_rows_the_byte_limit_alone_leaves_and_a_page_under_the_frame_is_the_same_with_and_without_it(self): busy(self.fixture).write() self.assertEqual(sum(len(lines) for _, lines in data_of(self.document("--max-bytes", "16001"))["G"]), 77) From df593847a9e8af8ef445b932c020b73dae9a4079 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 21:30:07 -0500 Subject: [PATCH 5/9] activity: narrow the change note to a goal row and a labeled page --- changes/pstack-t3%2Fd173.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/pstack-t3%2Fd173.md b/changes/pstack-t3%2Fd173.md index d3d5b69e..27ed5c73 100644 --- a/changes/pstack-t3%2Fd173.md +++ b/changes/pstack-t3%2Fd173.md @@ -1 +1 @@ -- The activity page groups each shown work item under its goal name and prints one count line per shown group, using the chip's state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. An item with no resolved goal is under Unassigned. A page with no such kind and no resolved goal stays the page the store printed before kinds and goals. +- A shown work item with a goal row is grouped under that goal's name, and each shown group has one count line of the chip's state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. An item with no resolved goal is under Unassigned when those shown items include a kind tag or a resolved goal. A page with no such kind and no resolved goal stays the page the store printed before kinds and goals. From f7dae7e1979d85b11c7eff745f044c2c83ef8dd5 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 22:05:31 -0500 Subject: [PATCH 6/9] activity: require a finished item's goal and kind in the text form --- tests/test_activity.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_activity.py b/tests/test_activity.py index d07cf27d..df24176a 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -3493,6 +3493,51 @@ def test_the_page_does_not_write_the_store_or_the_database(self): self.out("--text") self.assertEqual((listing(fixture.store), listing(fixture.database.parent)), before) + def test_a_finished_labeled_item_shows_its_goal_and_kind_in_text(self): + fixture = self.fixture + fixture.coordinator(turns=(("completed", 60, 50),)) + fixture.unit("D1", "merged", "Finished parser", thread=worker(1)) + fixture.thread(worker(1), title="D1 worker", turns=(("completed", 30, 20),)) + fixture.write() + write_goals(fixture, [("G1", "2026-10-10T11:00:00.000Z", "open", + "Ship the parser", "purpose-secret", "done-secret", "1", "")]) + widen_items(fixture, {"D1": ("feature", "G1")}) + lines = self.out("--text").splitlines() + self.assertEqual(lines[lines.index("Work items") + 1:], [ + " Ship the parser 1 merged", + " Ship the parser", + " feature D1 merged Finished parser 1 agent, 10m at work", + ]) + html = self.document() + self.assertIn("Finished parser", html) + self.assertNotIn("Ship the parser", html) + self.assertNotIn("F", data_of(html)) + + def test_text_groups_a_finished_item_when_the_strip_item_has_no_labels(self): + fixture = self.fixture + fixture.coordinator(turns=(("completed", 60, 50),)) + fixture.unit("D1", "merged", "Finished parser", thread=worker(1)) + fixture.unit("D2", "in-progress", "Open shelf") + fixture.thread(worker(1), title="D1 worker", turns=(("completed", 30, 20),)) + fixture.write() + write_goals(fixture, [("G1", "2026-10-10T11:00:00.000Z", "open", + "Ship the parser", "purpose-secret", "done-secret", "1", "")]) + widen_items(fixture, {"D1": ("feature", "G1")}) + lines = self.out("--text").splitlines() + self.assertEqual(lines[lines.index("Work items") + 1:], [ + " Ship the parser 1 merged", + " Ship the parser", + " feature D1 merged Finished parser 1 agent, 10m at work", + " Unassigned 1 working", + " Unassigned", + " D2 working Open shelf no activity in this window", + ]) + html = self.document() + self.assertIn("Open shelf", html) + self.assertNotIn("Ship the parser", html) + self.assertNotIn("Unassigned", html) + self.assertNotIn("F", data_of(html)) + if __name__ == "__main__": unittest.main() From f37bf7eef42722fb05b1e093d9f7a98aaafefaf5 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 22:05:37 -0500 Subject: [PATCH 7/9] activity: group the text form from the items it lists --- skills/brigade/scripts/activity.py | 2 +- t3/added/brigade/scripts/activity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/brigade/scripts/activity.py b/skills/brigade/scripts/activity.py index d60dedf5..60915fb5 100644 --- a/skills/brigade/scripts/activity.py +++ b/skills/brigade/scripts/activity.py @@ -2054,7 +2054,7 @@ def worked(rows): more = len(drawn) + sum(item not in drawn for item in page.items) - len(listed) loose = [line(UNGROUPED, f"{people(group.agents, group.subagents)}, {worked(group.rows)}") for group in page.groups if not group.item] flat = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] - text_flight = flight_of(listed, page.goals) if page.flight is not None else None + text_flight = flight_of(listed, page.goals) if text_flight is None: items = flat else: diff --git a/t3/added/brigade/scripts/activity.py b/t3/added/brigade/scripts/activity.py index d60dedf5..60915fb5 100644 --- a/t3/added/brigade/scripts/activity.py +++ b/t3/added/brigade/scripts/activity.py @@ -2054,7 +2054,7 @@ def worked(rows): more = len(drawn) + sum(item not in drawn for item in page.items) - len(listed) loose = [line(UNGROUPED, f"{people(group.agents, group.subagents)}, {worked(group.rows)}") for group in page.groups if not group.item] flat = [line(item.id, item.state, item.summary, drawn.get(item, "no activity in this window"), item.pr) for item in listed] - text_flight = flight_of(listed, page.goals) if page.flight is not None else None + text_flight = flight_of(listed, page.goals) if text_flight is None: items = flat else: From 8e6701e3dbd27e13101e16131598b37e04686700 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 22:05:37 -0500 Subject: [PATCH 8/9] activity: state how each form groups --- changes/pstack-t3%2Fd173.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/pstack-t3%2Fd173.md b/changes/pstack-t3%2Fd173.md index 27ed5c73..2011c8a7 100644 --- a/changes/pstack-t3%2Fd173.md +++ b/changes/pstack-t3%2Fd173.md @@ -1 +1 @@ -- A shown work item with a goal row is grouped under that goal's name, and each shown group has one count line of the chip's state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. An item with no resolved goal is under Unassigned when those shown items include a kind tag or a resolved goal. A page with no such kind and no resolved goal stays the page the store printed before kinds and goals. +- The HTML work-item strip groups its shown items under matching goal names when its items have a recognized kind or a resolved goal. Each group has one count line of the chips' state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. When the strip is grouped, items without a resolved goal share an Unassigned group, last. With no recognized kind and no resolved goal in the strip, the HTML page keeps its previous form. The text form groups the work items it lists when one of those listed items has a recognized kind or a resolved goal. Each text group has one count line of the state words of the items in that group. A listed item with kind feature, fix, polish, upkeep, or study shows that word as a tag. Any other kind cell has no tag. When the text form is grouped, a listed item without a resolved goal is in an Unassigned group, last. When none of the items the text form lists has a recognized kind or a resolved goal, the text form keeps its previous form. From c13b41608c5c4a65cbcdac83c57fc94c63ac457b Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 22:26:41 -0500 Subject: [PATCH 9/9] activity: say an item is under a goal name only when the goal resolves --- changes/pstack-t3%2Fd173.md | 2 +- tests/test_activity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changes/pstack-t3%2Fd173.md b/changes/pstack-t3%2Fd173.md index 2011c8a7..488647a4 100644 --- a/changes/pstack-t3%2Fd173.md +++ b/changes/pstack-t3%2Fd173.md @@ -1 +1 @@ -- The HTML work-item strip groups its shown items under matching goal names when its items have a recognized kind or a resolved goal. Each group has one count line of the chips' state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. When the strip is grouped, items without a resolved goal share an Unassigned group, last. With no recognized kind and no resolved goal in the strip, the HTML page keeps its previous form. The text form groups the work items it lists when one of those listed items has a recognized kind or a resolved goal. Each text group has one count line of the state words of the items in that group. A listed item with kind feature, fix, polish, upkeep, or study shows that word as a tag. Any other kind cell has no tag. When the text form is grouped, a listed item without a resolved goal is in an Unassigned group, last. When none of the items the text form lists has a recognized kind or a resolved goal, the text form keeps its previous form. +- The HTML work-item strip groups its shown items when one of them has a recognized kind or a resolved goal. An item with a resolved goal is under the heading the page shows for that goal. Each group has one count line of the chips' state words. A kind tag is feature, fix, polish, upkeep, or study. Any other kind cell has no tag. When the strip is grouped, items without a resolved goal share an Unassigned group, last. With no recognized kind and no resolved goal in the strip, the HTML page keeps its previous form. The text form groups the work items it lists when one of those listed items has a recognized kind or a resolved goal. A listed item with a resolved goal is under the heading the page shows for that goal. Each text group has one count line of the state words of the items in that group. A listed item with kind feature, fix, polish, upkeep, or study shows that word as a tag. Any other kind cell has no tag. When the text form is grouped, a listed item without a resolved goal is in an Unassigned group, last. When none of the items the text form lists has a recognized kind or a resolved goal, the text form keeps its previous form. diff --git a/tests/test_activity.py b/tests/test_activity.py index df24176a..e09f057e 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -3404,7 +3404,7 @@ def test_a_goal_name_loses_each_kind_of_private_text(self): self.assertIn(kept, html) self.assertIn(kept, text) - def test_each_shown_item_is_under_its_goal_once_and_the_counts_match(self): + def test_each_shown_item_is_in_one_group_and_the_counts_match(self): fixture = self.fixture fixture.meta["thread"] = "" for ident, state, summary in (