diff --git a/AGENTS.md b/AGENTS.md
index 26b9523..9f5f7f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -295,7 +295,10 @@ top of `manager/board.html`.
The board has three views (header switcher):
-- **Board** — the kanban, live. Active Claude Code sessions appear as chips in
+- **Board** — the kanban, live, and only what you are holding yourself: a
+ phase's members are drawn elsewhere, with the phase card standing for
+ them and each column saying how many it is not showing (see "A phase's
+ members leave the Board view"). Active Claude Code sessions appear as chips in
the header; a card an agent is working on carries a live activity line; the
bottom ticker narrates the latest events and every move is attributed
(`you` / `agent` / `disk`, or the teammate's git name when a sync brought
@@ -949,6 +952,34 @@ stage — and, while a phase is in flight, the runner's own reading of each
(merged in, working, checking, stopped here) — so the card answers "where
is this up to" without a hunt across five columns.
+### A phase's members leave the Board view
+
+The Board is the work you are personally holding, in five columns that
+fit, so a phase's members are **not drawn there**. Nothing is deleted,
+moved or marked: a member keeps its stage, its file, its agent and its
+actions, and the Board simply stops listing it. A column's number is
+therefore the number of cards you can see in it — true by construction
+rather than true-with-a-footnote — with `+2 in phases` beside it saying
+where the rest went, and only where members are actually hidden. A board
+with no phase card on it renders exactly what it always did.
+
+Membership is the only thing that hides a card, so *removing* membership
+is the un-hiding, with no sweep and no second rule: archive the phase
+card, drop a number from its `## Cards` list, or let the phase reach
+`done/` — it holds nothing once it is over — and its former members are
+back in the columns they are genuinely in. A membership that did not
+resolve hides nothing either: a card wearing `phase drift` stays on the
+board, because an authoring mistake must not make work vanish.
+
+That leaves the phase card standing for all of it, so it carries the
+summary it owes: a `⟶ 1 of 2 merged` chip in the footer row that opens
+its own sheet (the member list, in run order, with each one's stage), the
+member in flight on its activity line, and a run that halted worn in
+`--alarm` like any other stopped work. Only the Board hides. Sessions and
+Focus are about runs, not stages, and a phase member's agent is an agent
+like any other — it is counted by the header's live chip and by the tab
+title wherever it is working.
+
Filtering the board to a phase's cards, and a Focus view for a phase, are
separate cards.
diff --git a/manager/core/board.html b/manager/core/board.html
index d7e81a3..22109a9 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -173,6 +173,9 @@
/* the last run on this card died: the same terracotta, worn until the
next launch replaces it or the card moves stage */
.card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
+ /* a phase halted: the same terracotta, on the card that now stands for
+ every member this view no longer draws */
+ .card.halted{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* merge & clean up is running on this card: the working vocabulary,
because that is what is happening — the board is disassembling its
branch. No new colour, no new animation, and nothing to grab: the card
@@ -1112,6 +1115,52 @@ function render() {
/* ── board ────────────────────────────────────────────────────────────── */
+/* A phase member is not drawn on the Board view. It keeps its stage, its
+ file, its agent and its actions — this view simply stops listing it,
+ because the phase card stands for it here. The Board goes back to
+ meaning one thing: the work you are personally holding, in five columns
+ that fit.
+
+ Membership is the only thing that hides a card, so *removing* membership
+ is the un-hiding — no sweep, no migration, no second rule. A phase card
+ archived, or a number dropped from its `## Cards` list, stops deriving
+ the membership at all (the server weaves it fresh on every read), and a
+ phase that has reached done/ is over: it holds nothing and hands its
+ cards straight back into the columns they are genuinely in.
+
+ A membership that did not resolve hides nothing either: an authoring
+ mistake must not make work vanish, so a card wearing `phase drift` stays
+ where it can be seen. */
+function heldByPhase(task) {
+ if (!task.phase || (task.phaseDrift || []).length) return false;
+ const card = findTask(task.phase.file);
+ return !!card && card.stage !== 'done';
+}
+
+/* What a column draws, and what its header says about what it does not.
+ The count is the number of cards you can see — true by construction
+ rather than true-with-a-footnote — and `+2 in phases` beside it is a
+ signpost to where the rest went, in the register the existing note uses.
+ It appears only where members are actually hidden, so a board with no
+ phases on it reads exactly as it did before they existed.
+
+ The agents note counts the agents on the cards this column draws;
+ counting every agent anywhere is the header's live chip's job, and that
+ chip still sees an agent working on a hidden member. */
+function columnCards(stage) {
+ return stage.tasks.filter(t => !heldByPhase(t));
+}
+
+function columnNote(stage, shown) {
+ const agentsHere = shown.filter(t => agentOnTask(t.file)).length;
+ const hidden = stage.tasks.length - shown.length;
+ const notes = [];
+ if (agentsHere) notes.push(`${agentsHere} agent${agentsHere > 1 ? 's' : ''}`);
+ else if (STAGE_NOTE[stage.slug]) notes.push(STAGE_NOTE[stage.slug]);
+ if (hidden) notes.push(`+${hidden} in phases`);
+ return notes.join(' · ');
+}
+
function renderBoard() {
const board = $('#board');
// where you were looking, taken before the wipe throws the nodes away:
@@ -1124,17 +1173,19 @@ function renderBoard() {
for (const stage of S.state.board.stages) {
const col = document.createElement('section');
col.className = 'kcol';
- const agentsHere = stage.tasks.filter(t => agentOnTask(t.file)).length;
- const note = agentsHere ? `${agentsHere} agent${agentsHere > 1 ? 's' : ''}` : (STAGE_NOTE[stage.slug] || '');
+ const shown = columnCards(stage);
col.innerHTML =
`
` +
- `${stage.label}${stage.tasks.length}` +
- `${esc(note)}
`;
+ `${stage.label}${shown.length}` +
+ `${esc(columnNote(stage, shown))}`;
const drop = document.createElement('div');
drop.className = 'drop';
drop.dataset.stage = stage.slug;
- if (!stage.tasks.length) drop.innerHTML = 'Nothing here. Good.
';
- for (const task of stage.tasks) drop.appendChild(cardFor(task));
+ // a column emptied by a phase says so rather than congratulating you
+ if (!shown.length) drop.innerHTML = '' +
+ (stage.tasks.length ? 'Everything here is in a phase.' : 'Nothing here. Good.') +
+ '
';
+ for (const task of shown) drop.appendChild(cardFor(task));
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => {
@@ -1164,6 +1215,40 @@ function phaseLabel(phase) {
return name.length > 22 ? name.slice(0, 21) + '…' : name;
}
+/* The phase card is now the only thing standing for its members on this
+ view, so it carries the summary it owes them: how far the run has got.
+ Progress is what has landed on the phase branch, which only a run knows
+ — before there is one the card says how much it holds, which is the
+ honest reading of a phase nobody has started. */
+function phaseSummary(task) {
+ const snap = (S.state.phases || {})[task.file];
+ if (snap) {
+ const at = phaseProgress(snap);
+ return `${at.done} of ${at.total} merged`;
+ }
+ const total = (task.members || []).length;
+ return `${total} card${total === 1 ? '' : 's'}`;
+}
+
+/* …and what it is doing right now: the member in flight while a run is on,
+ the halt while one is held. Null when the phase has no story yet — a
+ phase waiting in to-do/ is just a card. */
+function phaseFlight(task) {
+ const snap = (S.state.phases || {})[task.file];
+ if (!snap) return null;
+ if (snap.halted) {
+ return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') +
+ ' — ' + (snap.haltedWhy || snap.halted) };
+ }
+ if (!snap.running) return null;
+ const at = phaseProgress(snap);
+ if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file} — ${at.on.title}` };
+ if ((snap.waitingOn || []).length) {
+ return { bad: false, line: `waiting on #${snap.waitingOn[0]}` };
+ }
+ return { bad: false, line: 'running' };
+}
+
/* Which phases this card could join, which is also whether the action is
there at all: the phase cards waiting in to-do/, and none whatsoever for
a card already in a phase, a phase card itself (they do not nest), or a
@@ -1188,7 +1273,10 @@ function canArchive(task) {
function cardFor(task) {
const el = document.createElement('article');
const agent = agentOnTask(task.file);
- const working = agent && agent.mode !== 'review';
+ // a running phase has an agent alive on a card this view no longer draws,
+ // so the phase card wears that state on its members' behalf
+ const flight = task.isPhase ? phaseFlight(task) : null;
+ const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad);
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
// the server holds this, not the tab that clicked: while merge & clean up
@@ -1199,6 +1287,7 @@ function cardFor(task) {
+ (working ? ' running' : '')
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
+ (failure ? ' run-failed' : '')
+ + (flight && flight.bad ? ' halted' : '')
+ (completing ? ' completing' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = !completing;
@@ -1221,6 +1310,13 @@ function cardFor(task) {
title: failure.excerpt };
tint = 'var(--alarm)';
}
+ if (flight && flight.bad) {
+ // a halt is held, not scrolled past — and on this view the phase card
+ // is where a person meets it, the member that stopped being hidden
+ pill = { text: 'halted', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16),
+ title: flight.line };
+ tint = 'var(--alarm)';
+ }
if (completing) {
// live work outranks every settled reading: the branch behind an
// 'approved' pill is being merged away as you look at it
@@ -1388,6 +1484,12 @@ function cardFor(task) {
liveLine = `` +
`·rc=${esc(failure.rc)} · ` +
`${esc(whyFailed(failure))}
`;
+ } else if (flight) {
+ // the member in flight, on the card that stands for it — the same line
+ // the header chip carries, at the altitude a person is already reading
+ liveLine = `·` +
+ `${esc(flight.line)}` +
+ `${flight.bad ? '' : '▌'}
`;
}
// tool chips: destinations, not statuses — they live in the card's footer
@@ -1395,6 +1497,15 @@ function cardFor(task) {
const detail = prState && prState.detail ? prState.detail : 'open the PR';
const hasBranch = (S.state.branches || []).includes(task.file.replace(/\.md$/, ''));
const chips = [];
+ // what the phase card now stands for: its progress, and the way through
+ // to the cards themselves — its own sheet lists them in run order with
+ // the stage each is in, which is where they are read until the Phases
+ // view draws them
+ if (task.isPhase) {
+ chips.push({ label: phaseSummary(task), pre: '⟶', cls: '', phase: task.file,
+ title: 'The cards in this phase, in run order — they are not drawn on '
+ + 'the board while it holds them' });
+ }
// where this card sits in its phase — derived from the phase card's own
// list, and a destination like the rest of the row: it opens that card
if (task.phase) {
diff --git a/tests/test_phase_members_hidden.py b/tests/test_phase_members_hidden.py
new file mode 100644
index 0000000..960c0ad
--- /dev/null
+++ b/tests/test_phase_members_hidden.py
@@ -0,0 +1,512 @@
+"""Phase members leave the main board (task 56).
+
+The Board view stops drawing a phase's members: the phase card stands for
+them there, and the columns go back to counting what you can actually see.
+Nothing is deleted, moved or marked — membership, which the server already
+derives on every read, is the only thing that hides a card, so removing it
+is what brings the card back.
+
+Two halves, as elsewhere for board.html. The behaviour is exercised for
+real: `taskfiles.collect()` reads a throwaway tasks/ directory exactly as
+the board does, and the page's own rules are lifted out of board.html and
+run in node over that reading. The wiring — that renderBoard() draws and
+counts the same list, and that no other view calls the hiding rule — is a
+source-level invariant, board.html being a single file with inline JS and
+no frontend test runner.
+
+ python3 -m unittest discover -s tests -v
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO / "manager" / "core"))
+
+import config # noqa: E402
+import taskfiles # noqa: E402
+
+BOARD = REPO / "manager" / "core" / "board.html"
+NODE = shutil.which("node")
+
+# The rules the Board view draws by, lifted from the page as written.
+PARTS = (
+ r"const STAGE_NOTE = \{[^\n]*\};",
+ r"function agentOnTask\(file\) \{.*?\n\}",
+ r"function runningAgents\(\) \{.*?\n\}",
+ r"function allTasks\(\) \{[^\n]*\}",
+ r"function findTask\(file\) \{[^\n]*\}",
+ r"function phaseProgress\(p\) \{.*?\n\}",
+ r"function heldByPhase\(task\) \{.*?\n\}",
+ r"function columnCards\(stage\) \{.*?\n\}",
+ r"function columnNote\(stage, shown\) \{.*?\n\}",
+ r"function phaseSummary\(task\) \{.*?\n\}",
+ r"function phaseFlight\(task\) \{.*?\n\}",
+)
+
+
+def harness() -> str:
+ html = BOARD.read_text(encoding="utf-8")
+ out = []
+ for pattern in PARTS:
+ match = re.search(pattern, html, re.S)
+ if match is None:
+ raise AssertionError(f"board.html no longer defines {pattern!r}")
+ out.append(match.group(0))
+ return "\n".join(out)
+
+
+def card(title: str, *, kind: str | None = None, cards: str | None = None,
+ status: str = "Backlog") -> str:
+ """A task file as a person would write it."""
+ text = f"# {title}\n\n**Status:** {status}\n**Priority:** Medium\n"
+ if kind:
+ text += f"**Type:** {kind}\n"
+ text += "\nWhat this card is for.\n"
+ if cards is not None:
+ text += f"\n## Cards\n{cards}\n"
+ return text
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own rules")
+class BoardViewCase(unittest.TestCase):
+ """One tasks/ directory per test, read as the board reads it, then
+ rendered by the page's own functions."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.src = harness()
+
+ def setUp(self):
+ tmp = Path(tempfile.mkdtemp(prefix="bench-hide-")).resolve()
+ self.addCleanup(shutil.rmtree, tmp, True)
+ self.tasks = tmp / "tasks"
+ for slug in (*config.STAGE_DIRS, "archive"):
+ (self.tasks / slug).mkdir(parents=True)
+ self.addCleanup(setattr, config, "TASKS", config.TASKS)
+ config.TASKS = self.tasks
+ self.addCleanup(setattr, config, "TM_ROOT", config.TM_ROOT)
+ config.TM_ROOT = tmp
+
+ def write(self, filename: str, text: str, stage: str = "backlog") -> None:
+ (self.tasks / stage / filename).write_text(text, encoding="utf-8")
+
+ def relocate(self, filename: str, source: str, target: str) -> None:
+ (self.tasks / source / filename).rename(self.tasks / target / filename)
+
+ def state(self, **extra) -> dict:
+ return {"board": taskfiles.collect(), "agents": [], "phases": {}, **extra}
+
+ def run_js(self, expression: str, **extra) -> object:
+ script = (self.src + "\nvar S = { state: " + json.dumps(self.state(**extra))
+ + " };\nconsole.log(JSON.stringify(" + expression + "));\n")
+ out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ return json.loads(out.stdout)
+
+ def columns(self, **extra) -> dict[str, dict]:
+ """What each column draws and says — renderBoard()'s own reading."""
+ drawn = self.run_js(
+ "S.state.board.stages.map(s => { const shown = columnCards(s);"
+ " return { slug: s.slug, count: shown.length,"
+ " note: columnNote(s, shown), files: shown.map(t => t.file) }; })",
+ **extra)
+ return {column["slug"]: column for column in drawn}
+
+ # the shape used by most of these: a phase card and the two cards it
+ # lists, spread over the stages they are genuinely in
+ def a_phase_of_two(self, phase_stage: str = "in-progress",
+ listed: str = "- 31 — Stand up site/\n- 32 — Serve it\n") -> None:
+ self.write("31-stand-up-site.md", card("31 — Stand up site/",
+ status="Review"), "review")
+ self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do")
+ self.write("40-the-site.md",
+ card("40 — Ship the site", kind="Phase", cards=listed,
+ status=config.STAGE_LABELS[phase_stage]), phase_stage)
+
+
+class MembersLeaveTheBoard(BoardViewCase):
+ def test_the_members_are_absent_and_the_phase_card_is_present(self):
+ self.a_phase_of_two()
+
+ columns = self.columns()
+
+ drawn = [f for column in columns.values() for f in column["files"]]
+ self.assertEqual(drawn, ["40-the-site.md"],
+ "a phase's members are not drawn on the Board view")
+
+ def test_the_column_that_held_a_member_counts_one_fewer_and_says_where(self):
+ self.a_phase_of_two()
+
+ columns = self.columns()
+
+ self.assertEqual(columns["review"]["count"], 0)
+ self.assertIn("+1 in phases", columns["review"]["note"])
+ self.assertEqual(columns["to-do"]["count"], 0)
+ self.assertIn("+1 in phases", columns["to-do"]["note"])
+
+ def test_the_note_is_a_signpost_beside_the_stages_own_word(self):
+ """`+1 in phases` says where the work went; it does not correct the
+ number or replace what the column has always called itself. "your
+ move" is now a claim about the cards you can see — see the card's
+ own note on that."""
+ self.a_phase_of_two()
+
+ self.assertEqual(self.columns()["review"]["note"], "your move · +1 in phases")
+
+ def test_two_hidden_in_one_column_are_counted_together(self):
+ self.write("31-stand-up-site.md", card("31 — Stand up site/"))
+ self.write("32-serve-it.md", card("32 — Serve it"))
+ self.write("33-landing.md", card("33 — The landing page"))
+ self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
+ cards="- 31 — Stand up site/\n- 32 — Serve it\n"))
+
+ columns = self.columns()
+
+ self.assertEqual(columns["backlog"]["files"],
+ ["33-landing.md", "40-the-site.md"])
+ self.assertEqual(columns["backlog"]["count"], 2)
+ self.assertEqual(columns["backlog"]["note"], "+2 in phases")
+
+ def test_a_column_with_no_hidden_members_shows_no_such_note(self):
+ self.a_phase_of_two()
+
+ columns = self.columns()
+
+ for slug in ("backlog", "in-progress", "done"):
+ with self.subTest(stage=slug):
+ self.assertNotIn("in phases", columns[slug]["note"])
+ self.assertEqual(columns["in-progress"]["note"], "")
+ self.assertEqual(columns["done"]["note"], "")
+
+ def test_the_count_is_what_you_can_see_in_every_column(self):
+ """The rule that keeps the header honest, stated as a whole-board
+ invariant rather than one column's arithmetic."""
+ self.a_phase_of_two()
+
+ for column in self.columns().values():
+ with self.subTest(stage=column["slug"]):
+ self.assertEqual(column["count"], len(column["files"]))
+
+
+class RemovingMembershipUnhides(BoardViewCase):
+ """Membership is the only thing that hides a card, so there is nothing
+ to sweep or migrate: each of these simply stops deriving it."""
+
+ def test_a_phase_that_reached_done_hands_its_cards_back(self):
+ self.a_phase_of_two(phase_stage="done")
+
+ columns = self.columns()
+
+ self.assertEqual(columns["review"]["files"], ["31-stand-up-site.md"])
+ self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"])
+ self.assertEqual(columns["done"]["files"], ["40-the-site.md"])
+ for slug in ("review", "to-do"):
+ self.assertNotIn("in phases", columns[slug]["note"])
+
+ def test_a_phase_moved_to_done_while_you_watch_hands_them_back(self):
+ """The same board, redrawn: nothing else changes and no other rule
+ runs — the members reappear in the stages they were always in."""
+ self.a_phase_of_two(phase_stage="review")
+ before = self.columns()
+ self.assertEqual(before["review"]["files"], ["40-the-site.md"])
+
+ self.relocate("40-the-site.md", "review", "done")
+
+ after = self.columns()
+ self.assertEqual(after["review"]["files"], ["31-stand-up-site.md"])
+ self.assertEqual(after["to-do"]["files"], ["32-serve-it.md"])
+
+ def test_an_archived_phase_hands_its_cards_back(self):
+ self.a_phase_of_two(phase_stage="to-do")
+
+ self.relocate("40-the-site.md", "to-do", "archive")
+
+ columns = self.columns()
+ self.assertEqual(columns["review"]["files"], ["31-stand-up-site.md"])
+ self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"])
+ self.assertNotIn("in phases", columns["to-do"]["note"])
+
+ def test_a_number_removed_from_the_list_brings_that_card_back(self):
+ self.a_phase_of_two(listed="- 31 — Stand up site/\n- 32 — Serve it\n")
+ self.assertEqual(self.columns()["to-do"]["files"], [])
+
+ self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
+ cards="- 31 — Stand up site/\n",
+ status="In Progress"), "in-progress")
+
+ columns = self.columns()
+ self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"],
+ "the card the list no longer names is on the board again")
+ self.assertNotIn("in phases", columns["to-do"]["note"])
+ self.assertEqual(columns["review"]["files"], [])
+ self.assertIn("+1 in phases", columns["review"]["note"])
+
+
+class AuthoringMistakesHideNothing(BoardViewCase):
+ """A card that vanishes is worse than a card that is flagged."""
+
+ def test_a_card_two_phases_claim_is_still_drawn(self):
+ self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do")
+ self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
+ cards="- 32 — Serve it\n"))
+ self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
+ cards="- 32 — Serve it\n"))
+
+ columns = self.columns()
+
+ self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"],
+ "a card wearing phase drift must not disappear")
+ self.assertEqual(columns["to-do"]["note"], "next up")
+
+ def test_a_number_no_card_has_hides_nothing(self):
+ self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do")
+ self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
+ cards="- 99 — a card that is not here\n"))
+
+ columns = self.columns()
+
+ self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"])
+ self.assertEqual(columns["backlog"]["files"], ["40-the-site.md"])
+
+ def test_a_phase_listing_a_phase_hides_neither(self):
+ self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
+ cards="- 33 — The landing page\n"))
+ self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
+ cards="- 41 — Ship the docs\n"))
+
+ columns = self.columns()
+
+ self.assertEqual(columns["backlog"]["files"],
+ ["40-the-site.md", "41-the-docs.md"])
+
+
+class HidingCardsDoesNotHideActivity(BoardViewCase):
+ def test_the_live_agents_chip_still_counts_an_agent_on_a_hidden_member(self):
+ self.a_phase_of_two()
+ agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}]
+
+ self.assertEqual(self.run_js("runningAgents().length", agents=agents), 1,
+ "the header counts agents, not cards this view draws")
+ # …and the card is still hidden while its agent works
+ self.assertEqual(self.columns(agents=agents)["to-do"]["files"], [])
+
+ def test_the_column_note_counts_the_agents_you_can_see(self):
+ """The count and its note describe the same visible cards, so a
+ column drawing no agent never claims one; the header chip above is
+ what says an agent is alive somewhere."""
+ self.a_phase_of_two()
+ agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}]
+
+ note = self.columns(agents=agents)["to-do"]["note"]
+ self.assertNotIn("agent", note)
+ self.assertIn("+1 in phases", note)
+
+ def test_a_visible_agent_still_reads_as_it_did(self):
+ self.write("32-serve-it.md", card("32 — Serve it", status="In Progress"),
+ "in-progress")
+ agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}]
+
+ self.assertEqual(self.columns(agents=agents)["in-progress"]["note"],
+ "1 agent")
+
+
+class WithNoPhasesNothingChanged(BoardViewCase):
+ """The edge case that keeps this card from being a redesign: a board
+ with no phase card on it renders exactly what it rendered before."""
+
+ def board_as_before(self) -> dict[str, dict]:
+ """The rule board.html applied until this card: every task drawn,
+ the count is the directory's length, the note is the agents here or
+ the stage's own word."""
+ notes = {"to-do": "next up", "review": "your move"}
+ columns = {}
+ for stage in taskfiles.collect()["stages"]:
+ columns[stage["slug"]] = {
+ "slug": stage["slug"], "count": len(stage["tasks"]),
+ "note": notes.get(stage["slug"], ""),
+ "files": [task["file"] for task in stage["tasks"]]}
+ return columns
+
+ def test_every_column_reads_as_it_did(self):
+ self.write("31-stand-up-site.md", card("31 — Stand up site/"))
+ self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do")
+ self.write("33-landing.md", card("33 — The landing page", status="Review"),
+ "review")
+ self.write("34-done.md", card("34 — Something finished", status="Done"), "done")
+
+ self.assertEqual(self.columns(), self.board_as_before())
+
+ def test_an_empty_board_reads_as_it_did(self):
+ self.assertEqual(self.columns(), self.board_as_before())
+
+
+class ThePhaseCardsSummary(BoardViewCase):
+ """It is the only thing standing for that work on this view, so it says
+ how far the run has got and what it is doing."""
+
+ def snapshot(self, **extra) -> dict:
+ """What the runner's last pass would have published for the phase."""
+ members = [{"number": "31", "file": "31-stand-up-site.md",
+ "title": "31 — Stand up site/", "stage": "review",
+ "state": "merged", "why": ""},
+ {"number": "32", "file": "32-serve-it.md",
+ "title": "32 — Serve it", "stage": "in-progress",
+ "state": "running", "why": ""}]
+ return {"40-the-site.md": {"file": "40-the-site.md", "members": members,
+ "running": True, "halted": "", "haltedAt": None,
+ "haltedWhy": None, **extra}}
+
+ def summary(self, **extra) -> str:
+ return self.run_js("phaseSummary(findTask('40-the-site.md'))", **extra)
+
+ def flight(self, **extra):
+ return self.run_js("phaseFlight(findTask('40-the-site.md'))", **extra)
+
+ def test_progress_is_what_has_landed_on_the_phase_branch(self):
+ self.a_phase_of_two()
+
+ self.assertEqual(self.summary(phases=self.snapshot()), "1 of 2 merged")
+
+ def test_a_phase_nobody_has_started_says_what_it_holds(self):
+ """No run, no progress to report — so it says how many cards it has
+ rather than reporting a merge count that means nothing yet."""
+ self.a_phase_of_two(phase_stage="to-do")
+
+ self.assertEqual(self.summary(), "2 cards")
+
+ def test_one_card_is_not_pluralised(self):
+ self.a_phase_of_two(listed="- 31 — Stand up site/\n", phase_stage="to-do")
+
+ self.assertEqual(self.summary(), "1 card")
+
+ def test_the_member_in_flight_is_named(self):
+ self.a_phase_of_two()
+
+ self.assertEqual(self.flight(phases=self.snapshot()),
+ {"bad": False, "line": "on #32 — 32 — Serve it"})
+
+ def test_a_dependency_holding_the_phase_is_named(self):
+ snapshot = self.snapshot()
+ snapshot["40-the-site.md"]["members"][1]["state"] = "pending"
+ snapshot["40-the-site.md"]["waitingOn"] = ["7"]
+ self.a_phase_of_two()
+
+ self.assertEqual(self.flight(phases=snapshot),
+ {"bad": False, "line": "waiting on #7"})
+
+ def test_a_halt_reaches_the_card_that_stands_for_the_member(self):
+ self.a_phase_of_two()
+ halted = self.snapshot(running=False, halted="halted at 32 — it is not ready",
+ haltedAt="32", haltedWhy="it is not ready")
+
+ self.assertEqual(self.flight(phases=halted),
+ {"bad": True, "line": "halted at #32 — it is not ready"})
+
+ def test_a_phase_with_no_run_has_nothing_to_say(self):
+ self.a_phase_of_two(phase_stage="to-do")
+
+ self.assertIsNone(self.flight())
+
+ def test_a_phase_whose_run_ended_has_nothing_to_say(self):
+ self.a_phase_of_two()
+
+ self.assertIsNone(self.flight(phases=self.snapshot(running=False)))
+
+
+class Wiring(unittest.TestCase):
+ """board.html's own half: what keeps the count and the cards from
+ drifting apart, and the hiding from leaking into the other views."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.html = BOARD.read_text(encoding="utf-8")
+
+ def body(self, pattern: str) -> str:
+ match = re.search(pattern, self.html, re.S)
+ self.assertIsNotNone(match, f"board.html no longer defines {pattern!r}")
+ return match.group(0)
+
+ def test_the_column_draws_and_counts_one_list(self):
+ """The count is the number of cards drawn because it is the length
+ of the list drawn — not a second reading that could disagree."""
+ render = self.body(r"function renderBoard\(\) \{.*?\n\}")
+ self.assertIn("const shown = columnCards(stage);", render)
+ self.assertIn('${shown.length}', render)
+ self.assertIn("for (const task of shown)", render)
+ self.assertNotIn("stage.tasks.length}", render,
+ "the header must not count what the column does not draw")
+ self.assertNotIn("for (const task of stage.tasks)", render)
+
+ def test_the_note_is_built_where_the_column_is(self):
+ render = self.body(r"function renderBoard\(\) \{.*?\n\}")
+ self.assertIn("columnNote(stage, shown)", render)
+ note = self.body(r"function columnNote\(stage, shown\) \{.*?\n\}")
+ self.assertIn("in phases", note)
+ self.assertIn("agentOnTask", note)
+
+ def test_only_the_board_view_hides(self):
+ """Sessions and Focus are about runs and sessions, not stages, and a
+ phase member's agent is an agent like any other."""
+ self.assertEqual(len(re.findall(r"heldByPhase\(", self.html)), 2,
+ "heldByPhase() is defined once and asked once, by columnCards()")
+ self.assertEqual(len(re.findall(r"columnCards\(", self.html)), 2,
+ "columnCards() is defined once and used once, by renderBoard()")
+ for view in (r"function renderFlight\(\) \{.*?\n\}",
+ r"function renderFocus\(\) \{.*?\n\}"):
+ body = self.body(view)
+ self.assertNotIn("heldByPhase", body)
+ self.assertNotIn("columnCards", body)
+
+ def test_drift_is_the_guard_inside_the_hiding_rule(self):
+ held = self.body(r"function heldByPhase\(task\) \{.*?\n\}")
+ self.assertIn("phaseDrift", held,
+ "an unresolved membership must not hide a card")
+ self.assertIn("'done'", held,
+ "a phase that has reached done/ holds nothing")
+
+ def test_the_summary_chip_sits_in_the_footer_row_and_opens_the_card(self):
+ block = self.html[self.html.index("if (task.isPhase) {"):][:400]
+ self.assertIn("chips.push", block)
+ self.assertIn("phaseSummary(task)", block)
+ self.assertIn("phase: task.file", block,
+ "the chip has to name the card it opens — its own")
+ self.assertIn("⟶", block)
+
+ def test_the_phase_card_wears_a_running_phase_and_a_halted_one(self):
+ card_fn = self.body(r"function cardFor\(task\) \{.*?\n\}")
+ self.assertIn("const flight = task.isPhase ? phaseFlight(task) : null;", card_fn)
+ self.assertIn("flight && !flight.bad", card_fn,
+ "a phase in flight breathes like any other live work")
+ self.assertIn("' halted'", card_fn)
+ self.assertIn(".card.halted{", self.html,
+ "the halted card needs the alarm border it wears")
+
+ def test_an_emptied_column_says_why_it_is_empty(self):
+ render = self.body(r"function renderBoard\(\) \{.*?\n\}")
+ self.assertIn("Everything here is in a phase.", render)
+ self.assertIn("Nothing here. Good.", render,
+ "a genuinely empty column keeps the line it always had")
+
+
+@unittest.skipUnless(NODE, "node is needed to parse the page")
+class ThePageStillParses(unittest.TestCase):
+ def test_the_inline_script_parses(self):
+ html = BOARD.read_text(encoding="utf-8")
+ scripts = re.findall(r"", html, re.S)
+ self.assertTrue(scripts, "board.html has no inline script")
+ with tempfile.TemporaryDirectory() as tmp:
+ for index, script in enumerate(scripts):
+ source = Path(tmp) / f"page-{index}.js"
+ source.write_text(script, encoding="utf-8")
+ out = subprocess.run([NODE, "--check", str(source)],
+ capture_output=True, text=True)
+ self.assertEqual(out.returncode, 0, out.stderr)