diff --git a/AGENTS.md b/AGENTS.md
index 10dacc7..13bf28d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -879,8 +879,51 @@ the existing apparatus applies unchanged: the CI chip, **◔ review PR**,
**Assignee** is where "who runs it" is written down — the same claim that
gates starting work. A replica renders the phase and advances nothing.
-Members run one at a time. Running independent members in parallel, and
-any UI beyond the API and the ticker, are separate cards.
+Members run one at a time. Running independent members in parallel is a
+separate card.
+
+### Watching one run, and watching it stop
+
+A phase card is a card, so the PR chip, CI, the review actions and the
+merge-and-clean-up sheet all reach it by inheritance. Three things do not,
+and they are the phase's own interface:
+
+- **▸ run phase**, on an `in-progress/` phase card, in the slot **▸ start
+ work** occupies on an ordinary one — arming and firing like every other
+ launch, and reading **▸ take over** on a card someone else holds. Moving
+ the card to `in-progress/` stays the commitment; this is the second half
+ of it. **‖ hold** replaces it while the phase runs and means what it
+ means everywhere else: the run stops and the member agent in flight
+ stops with it, while the phase branch, every card already merged into it
+ and every worktree are left exactly as they were. A hold is not an undo,
+ and a held member's card keeps its own work — so running the phase again
+ may halt on it, which is the honest reading of a run that ended without
+ reaching `review/`.
+- **A chip in the header while a phase runs**, beside the agents chip and
+ present on the same terms as the sync chip — only when there is
+ something to say. It breathes in `--accent` and names the phase, how far
+ it has got and the card in flight: `⟶ Ship the site · 2/5 · on #33`.
+ Progress is what has landed on the phase branch, which is a different
+ fact from which card is running, so both are there. Two phases could in
+ principle run on one board, and two phases get two chips — a chip that
+ silently showed one of them would be worse than none.
+- **The halt, in `--alarm`, holding.** `⟶ Ship the site · halted at #35 —
+ it is not ready` stays in the header until the phase is run again or
+ held, rather than scrolling away; a toast fires with it, because a halt
+ is rare and actionable; and the ticker keeps the line. The member card
+ wears its own failure independently — a phase you started and stopped
+ watching is only trustworthy if its halt is impossible to miss, so it is
+ told three times at three altitudes, exactly as a dead run is.
+
+Every advance is narrated in the ticker as it happens: the member that
+came up green, the merge into the phase branch, the member starting next.
+And the phase card's sheet lists its members in run order with each one's
+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.
+
+Filtering the board to a phase's cards, and a Focus view for a phase, are
+separate cards.
An optional **Assignee** line records who holds the card:
diff --git a/manager/core/board.html b/manager/core/board.html
index 2e1cab7..51c12a4 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -99,6 +99,18 @@
/* an author display beats the UA's [hidden] — say it here or the sync
chip is never hidden */
.livechip[hidden]{display:none}
+ /* A running phase is the same kind of fact as the agents and the sync
+ chips: what is happening across the board without you. One chip per
+ phase in flight — two phases could in principle run at once, and a
+ chip that showed one of them would be worse than none — so the row
+ gives way before the chips already here do, clipping the names it
+ cannot fit rather than shrinking its neighbours. */
+ .phasechips{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}
+ .phasechips[hidden]{display:none}
+ .phasechips .livechip{min-width:0}
+ .phasechips .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .phasechips .mono{white-space:nowrap}
+ .phasechips .halted{color:var(--alarm)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
/* the model chip: which brain did this, beside the name that did it.
@@ -451,6 +463,28 @@
#drawer .dhead{display:flex;align-items:center;gap:10px}
/* the dead run's excerpt, above the task itself: machine output, bounded */
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
+ /* the phase's list, above the card's own text: run order, one row per
+ card, machine facts (position, number, stage, the runner's reading)
+ in mono and the title in the reader's face */
+ #drawer .pmembers{display:flex;flex-direction:column;gap:1px}
+ #drawer .phead{
+ display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;
+ color:var(--muted);padding-bottom:5px;
+ }
+ #drawer .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
+ #drawer .prow{
+ display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;
+ padding:5px 8px;border:1px solid transparent;border-radius:7px;
+ background:var(--sunken);font-size:12.5px;color:var(--text);
+ }
+ #drawer .prow:hover{border-color:var(--accent)}
+ #drawer .prow .mono{font-family:var(--mono);font-size:11px;color:var(--dim)}
+ #drawer .prow .pn{width:14px;flex:none;text-align:right}
+ #drawer .prow .pref{flex:none}
+ #drawer .prow .ptitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ #drawer .prow .pstage{flex:none;color:var(--muted)}
+ #drawer .prow .pstate{flex:none}
+ #drawer .pempty{font-size:12px;color:var(--dim);padding:4px 8px}
#drawer .dbody{font-size:13px;line-height:1.6}
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
@@ -542,6 +576,7 @@
+
@@ -625,10 +660,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
- move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
+ move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', phase: '⟶', other: '·' };
const FILTERS = [
['all', 'All', null],
- ['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
+ ['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync', 'phase'])],
['edits', 'Edits', new Set(['edit'])],
['reads', 'Reads', new Set(['read', 'search'])],
['tests', 'Tests', new Set(['test', 'check'])],
@@ -890,6 +925,82 @@ function renderSync() {
`${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}`;
}
+/* ── a phase in flight ────────────────────────────────────────────────── */
+
+/* The phases the header has something to say about: one running, or one
+ halted and not yet settled. A phase nobody has started, and one whose
+ card has left in-progress/, are simply not here — with none of them the
+ header is exactly what it was before phases existed.
+
+ A halt leads, because it is the one a person must not scroll past. */
+function phasesInFlight() {
+ return Object.values(S.state?.phases || {})
+ .filter(p => p.running || p.halted)
+ .sort((a, b) => (b.halted ? 1 : 0) - (a.halted ? 1 : 0) ||
+ a.file.localeCompare(b.file));
+}
+
+/* Where a phase is up to, from the members the last pass read: how many
+ have landed on the phase branch, and which one is in flight — an agent
+ on it, or its checks still running. */
+function phaseProgress(p) {
+ const members = p.members || [];
+ return {
+ done: members.filter(m => m.state === 'merged').length,
+ total: members.length,
+ on: members.find(m => m.state === 'running') ||
+ members.find(m => m.state === 'waiting') || null,
+ };
+}
+
+function clip(text, n) {
+ return text.length > n ? text.slice(0, n - 1) + '…' : text;
+}
+
+/* What the chip says after the name: the halt and where it happened, or
+ the progress and the card in flight. The whole of it is on the title —
+ a header chip is not the place a reason gets to run long. */
+function phaseChipDetail(p) {
+ if (p.halted) {
+ return (p.haltedAt ? `halted at #${p.haltedAt} — ` : 'halted — ') +
+ clip(p.haltedWhy || p.halted, 46);
+ }
+ const at = phaseProgress(p);
+ const rest = at.on ? `on #${at.on.number || at.on.file}`
+ : (p.waitingOn || []).length ? `waiting on #${p.waitingOn[0]}` : '';
+ return `${at.done}/${at.total}` + (rest ? ` · ${rest}` : '');
+}
+
+function renderPhases() {
+ const el = $('#phasechips');
+ const live = phasesInFlight();
+ el.hidden = !live.length;
+ el.innerHTML = live.map(p => {
+ const at = phaseProgress(p);
+ const detail = phaseChipDetail(p);
+ // colour only means state: a run alive breathes in accent, a halt
+ // holds in alarm until the phase is resumed or held
+ const title = (p.halted
+ ? `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
+ `${p.haltedWhy || p.halted}. It stays here until the phase is run again or held.`
+ : `${p.file} is running on ${p.branch} — ${at.done} of ${at.total} cards merged`) +
+ ' · click to open the phase card';
+ const dot = p.halted
+ ? ''
+ : '';
+ return `
`;
+ }).join('');
+ el.querySelectorAll('[data-phasechip]').forEach(chip =>
+ chip.addEventListener('click', () => {
+ const task = findTask(chip.dataset.phasechip);
+ if (!task) return;
+ setView('board');
+ showDetail(task);
+ }));
+}
+
function setView(view) {
S.view = view;
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
@@ -932,6 +1043,7 @@ function render() {
if (!S.state) return;
renderTitle();
renderChip();
+ renderPhases();
renderSync();
if (S.view === 'board') renderBoard();
else if (S.view === 'flight') renderFlight();
@@ -1072,11 +1184,38 @@ function cardFor(task) {
run: () => askCopilot(task) });
}
} else {
- if (task.stage === 'in-progress') {
- // someone else's card is never started by accident: the action says
- // whose it is, and firing it is the deliberate takeover
- const held = task.assignee && S.state.me && task.assignee !== S.state.me
- ? task.assignee : null;
+ // someone else's card is never started by accident: the action says
+ // whose it is, and firing it is the deliberate takeover
+ const held = task.assignee && S.state.me && task.assignee !== S.state.me
+ ? task.assignee : null;
+ if (task.stage === 'in-progress' && task.isPhase) {
+ // the phase's own launch, in the slot ▸ start work has on every other
+ // card: moving it here was the commitment, this is the second half
+ const ph = (S.state.phases || {})[task.file];
+ const hold = { glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
+ title: 'Stop the phase — the phase branch, every card already merged '
+ + 'into it and every worktree stay exactly as they are',
+ run: () => holdPhase(task) };
+ const start = held
+ ? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`,
+ busy: 'starting…',
+ title: `${held} holds this phase — running it takes the card over and `
+ + 'this board becomes the one that advances it',
+ run: () => runPhase(task, { takeover: true }) }
+ : { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
+ title: ph && ph.halted
+ ? 'Run it again — the halt is cleared and the phase carries on from '
+ + 'where it stopped'
+ : 'A branch of its own, each card in the list run on it in turn, and '
+ + 'one PR into main at the end',
+ run: () => runPhase(task) };
+ // a halt holds until the phase is run again or held: both are here,
+ // because a person who has read it and does not want to carry on
+ // needs a way to say so that is not walking the card backwards
+ if (ph && ph.running) actions.push(hold);
+ else if (ph && ph.halted) actions.push(start, hold);
+ else actions.push(start);
+ } else if (task.stage === 'in-progress') {
actions.push(held
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
@@ -1099,7 +1238,9 @@ function cardFor(task) {
title: 'Push the branch and open its PR — the board does this when a card '
+ 'enters review, and this is how you ask for it afterwards',
run: () => openPR(task) });
- } else {
+ } else if (actions.length < 2) {
+ // two per state is the whole budget, and a halted phase has already
+ // spent it on running it again and holding it
actions.push(stillTrue);
}
}
@@ -1443,6 +1584,35 @@ async function askCopilot(task) {
return res.ok;
}
+/* Running a phase, and holding it. Both are ordinary POSTs on the one
+ action machine — armed, fired, and honest about failing — because a
+ phase is a card and its actions are card actions. What follows either
+ one is watched in the header chip and narrated in the ticker, so
+ neither says more here than what it just asked for. */
+async function runPhase(task, extra) {
+ const res = await fetch('/api/phase/run', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
+ });
+ const data = await res.json();
+ toast(res.ok ? `Phase ${task.file} running — the header chip follows it`
+ : (data.error || 'the phase did not start'), !res.ok);
+ await loadState();
+ return res.ok;
+}
+
+async function holdPhase(task) {
+ const res = await fetch('/api/phase/stop', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ file: task.file, stage: task.stage }),
+ });
+ const data = await res.json();
+ toast(res.ok ? 'Held — the phase branch and everything merged into it stay as they are'
+ : (data.error || 'could not hold it'), !res.ok);
+ await loadState();
+ return res.ok;
+}
+
async function move(file, from, to) {
// finishing a task with work attached is a decision, not just a drag
if (to === 'done') {
@@ -1517,6 +1687,52 @@ function showDetail(task) {
if (changed) $('#drawerbody').scrollTop = 0;
}
+/* The runner's word for a member, in the vocabulary the rest of the board
+ uses. `pending` is the phase simply not having reached it, which the
+ stage beside it already says — so it says nothing. */
+const MEMBER_STATE = { merged: 'merged in', running: 'working',
+ waiting: 'checking', ready: 'ready to merge', halt: 'stopped here' };
+
+/* A phase card answers "where is this up to" on its own face: its members
+ in run order, each with the stage it is in, so the question costs no
+ hunt across five columns. The runner's reading (merged in, working,
+ stopped here) is added only while a phase is in flight, because only
+ then is there one — and only it can tell a card that has run from one
+ the phase has not reached. Each row opens that card. */
+function phaseMembers(t) {
+ if (!t.isPhase) return '';
+ if (!(t.members || []).length) {
+ return `
Cards
` +
+ `
This phase lists no cards yet — a ## Cards ` +
+ `section, one task number per line, in the order they run.
`;
+ }
+ const snap = (S.state.phases || {})[t.file];
+ const read = {};
+ for (const m of ((snap && snap.members) || [])) read[m.file] = m;
+ const rows = t.members.map((m, i) => {
+ const seen = read[m.file];
+ const note = seen ? MEMBER_STATE[seen.state] || '' : '';
+ const tint = seen && seen.state === 'halt' ? 'var(--alarm)'
+ : seen && ['running', 'waiting'].includes(seen.state) ? 'var(--accent)'
+ : seen && seen.state === 'merged' ? 'var(--calm)' : '';
+ return ``;
+ }).join('');
+ const halt = snap && snap.halted
+ ? `
${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}
`;
panel.classList.add('open');
+ body.querySelectorAll('[data-member]').forEach(row =>
+ row.addEventListener('click', () => {
+ const member = findTask(row.dataset.member);
+ if (member) showDetail(member);
+ }));
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); });
}
diff --git a/manager/core/httpd.py b/manager/core/httpd.py
index 379d9e9..734e163 100644
--- a/manager/core/httpd.py
+++ b/manager/core/httpd.py
@@ -35,8 +35,10 @@ def state_payload() -> dict:
"sessions": sessions,
"agents": agents.list_public(),
"prs": github.public_state(),
- # what the last pass of the phase runner saw: per running phase
- # card, its branch and each member's state
+ # what the last pass of the phase runner saw: per phase card in
+ # in-progress/, its branch, whether a run is in force or halted (and
+ # why, and where), and each member's state — the header chip and the
+ # phase card's own list are both read from here
"phases": phases.public_state(),
"drive": drive.public(),
"hasDriver": config.driver_path() is not None,
@@ -201,6 +203,11 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"phase": phases.start_phase(
payload["file"], payload["stage"],
bool(payload.get("takeover")))})
+ elif path == "/api/phase/stop":
+ payload = self._read_body()
+ # ‖ hold on a phase card: the run stops, nothing is unwound
+ self._json(200, {"phase": phases.stop_phase(payload["file"],
+ payload["stage"])})
elif path == "/api/pr/open":
payload = self._read_body()
self._json(200, {"url": github.open_pr_now(payload["file"])})
diff --git a/manager/core/phases.py b/manager/core/phases.py
index 58213ea..8fb80ed 100644
--- a/manager/core/phases.py
+++ b/manager/core/phases.py
@@ -64,6 +64,7 @@ LOG_LINE_RE = re.compile(r"^-\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+·\s+(.+?)\s*$
STARTED_RE = re.compile(r"^(\d+) started\b")
HALTED_RE = re.compile(r"^halted(?: at (\d+))? — (.*)$")
RUN_RE = re.compile(r"^run started\b")
+STOPPED_RE = re.compile(r"^stopped — (.*)$")
# What the last computed pass saw, for the API to render without paying for
# a git walk on every request. A display cache, not a decision: nothing here
@@ -161,17 +162,37 @@ def _started(entries: list[str]) -> set[str]:
(STARTED_RE.match(e) for e in _this_run(entries)) if match}
-def _halt_reason(entries: list[str]) -> str | None:
- """The halt still in force, or None. A `run started` line after a halt
- clears it — that is what running the phase again means."""
+def run_state(entries: list[str]) -> dict:
+ """Where the phase stands, read off its own log — the whole of what a
+ restarted board, and the chip in the header, know about a run.
+
+ Four answers, and the last line that says one of them wins: `running`
+ (a run was started and nothing has ended it), `halted` (with the reason
+ and the member it happened at), `stopped` (a person held it) and `idle`
+ (a card whose phase has never been run). Running it again is what
+ clears a halt or a stop, because that line is the person's decision.
+ """
for entry in reversed(entries):
halted = HALTED_RE.match(entry)
if halted:
- return (f"{halted.group(1)}: {halted.group(2)}" if halted.group(1)
- else halted.group(2))
+ return {"state": "halted", "reason": halted.group(2),
+ "at": halted.group(1)}
+ stopped = STOPPED_RE.match(entry)
+ if stopped:
+ return {"state": "stopped", "reason": stopped.group(1), "at": None}
if RUN_RE.match(entry):
- return None
- return None
+ return {"state": "running", "reason": "", "at": None}
+ return {"state": "idle", "reason": "", "at": None}
+
+
+def _halt_reason(entries: list[str]) -> str | None:
+ """The halt still in force, as one line — the member it happened at and
+ why, which is what the log's reader and the ticker both want."""
+ where = run_state(entries)
+ if where["state"] != "halted":
+ return None
+ return (f"{where['at']}: {where['reason']}" if where["at"]
+ else where["reason"])
# ── reading the board ──────────────────────────────────────────────────
@@ -266,6 +287,7 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
"""The whole phase, recomputed. Nothing is remembered between passes."""
branch = agents.phase_branch(phase["file"])
entries = log_entries(phase["body"])
+ where = run_state(entries)
started = _started(entries)
members = []
for listed in phase["members"]:
@@ -280,8 +302,20 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
"title": task["title"], "stage": task["stage"],
"state": member_state, "why": why,
"dependsOn": task["dependsOn"]})
+ halted = where["state"] == "halted"
return {"file": phase["file"], "branch": branch, "stage": phase["stage"],
- "members": members, "halted": _halt_reason(entries),
+ "title": phase["title"], "number": phase["number"],
+ "members": members,
+ # the halt as one line for the log's reader, and in its parts for
+ # anything that renders it — the same reading, said twice
+ "halted": _halt_reason(entries),
+ "haltedAt": where["at"] if halted else None,
+ "haltedWhy": where["reason"] if halted else None,
+ # is a run in force? The log answers it, so a restarted board and
+ # the chip in the header read the same thing
+ "running": where["state"] == "running",
+ "stopped": where["state"] == "stopped",
+ "stoppedBy": where["reason"] if where["state"] == "stopped" else None,
# the branch is the whole of "this phase has been started"
"started": _branch_exists(branch)}
@@ -428,6 +462,11 @@ def _launch(phase: dict, member: dict) -> None:
def _merge_member(phase: dict, member: dict) -> None:
branch = f"task/{member['file'][:-3]}"
phase_branch = agents.phase_branch(phase["file"])
+ # every advance is narrated, and finishing is its own half of one: what
+ # the phase judged green is a different fact from what it then merged,
+ # and a phase nobody can reconstruct afterwards is a phase nobody trusts
+ _say(phase["file"], f"{phase['file']}: {member['number']} — "
+ f"{member['title']} is green")
if _branch_exists(branch):
_merge_into_phase(phase, branch, f"{member['number']}'s branch", member)
_push_phase(phase)
@@ -449,11 +488,17 @@ def _finish(phase: dict) -> None:
def _halt(phase: dict, member: dict | None, reason: str) -> None:
- """Stop, and say so once. The log holds the halt from here on, so the
- next pass reads it rather than saying the same thing again."""
+ """Stop, and say so once — at three altitudes, like every other outcome
+ a person must not miss. The log holds the halt from here on (so the next
+ pass reads it rather than saying the same thing again), the ticker keeps
+ the line, and a toast says it to whoever is looking: a halt is rare,
+ actionable, and the whole argument for starting a phase and walking away.
+ """
at = f" at {member['number']}" if member and member["number"] else ""
_write_log(phase, f"halted{at} — {reason}") # best effort: never re-raise
_say(phase["file"], f"{phase['file']} halted{at} — {reason}")
+ state.broadcast({"type": "toast", "error": True,
+ "message": f"phase {phase['file']} halted{at} — {reason}"})
def _do_pass(phase: dict, snapshot: dict, by_number: dict[str, dict]) -> list[str]:
@@ -490,7 +535,7 @@ def advance(phase: dict, by_file: dict[str, dict],
"""
snapshot = _snapshot(phase, by_file)
SNAPSHOTS[phase["file"]] = snapshot
- if snapshot["halted"] or not snapshot["started"] or not _mine(phase):
+ if not snapshot["running"] or not snapshot["started"] or not _mine(phase):
return snapshot
waiting_on: list[str] = []
try:
@@ -541,30 +586,33 @@ def beat() -> None:
pass
-# ── starting one ───────────────────────────────────────────────────────
+# ── starting one, and stopping it ──────────────────────────────────────
+
+
+def _phase_card(filename: str, stage: str) -> dict:
+ """The phase card an action names, or the reason it is not one."""
+ if Path(filename).name != filename or not filename.endswith(".md"):
+ raise ValueError("bad filename")
+ if stage != "in-progress":
+ raise ValueError("a phase runs from in-progress/ — move the card there first")
+ if not (config.TASKS / stage / filename).is_file():
+ raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
+ phase = _cards()[0].get(filename)
+ if phase is None or not phase["isPhase"]:
+ raise ValueError(f"{filename} is not a phase — a phase card is "
+ f"**Type:** Phase with a ## Cards section")
+ return phase
def start_phase(filename: str, stage: str, takeover: bool = False) -> dict:
- """Run a phase — the first time, or again after a halt.
+ """Run a phase — the first time, or again after a halt or a hold.
Cutting `phase/` from the newest main is the whole of "starting":
everything after it is the beat looking at what is there. Running a
halted phase again is the person's decision that cleared the halt, so
it appends the line that clears it and takes one pass immediately.
"""
- if Path(filename).name != filename or not filename.endswith(".md"):
- raise ValueError("bad filename")
- if stage != "in-progress":
- raise ValueError("a phase runs from in-progress/ — move the card there first")
- path = config.TASKS / stage / filename
- if not path.is_file():
- raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
-
- by_file, by_number = _cards()
- phase = by_file.get(filename)
- if phase is None or not phase["isPhase"]:
- raise ValueError(f"{filename} is not a phase — a phase card is "
- f"**Type:** Phase with a ## Cards section")
+ phase = _phase_card(filename, stage)
if phase["phaseDrift"]:
raise ValueError(f"{filename} does not resolve: {phase['phaseDrift'][0]} "
f"— fix the list before running it")
@@ -603,3 +651,56 @@ def _start(phase: dict, filename: str) -> dict:
by_file, by_number = _cards() # the log just rewrote the card
return advance(by_file.get(filename) or phase, by_file, by_number)
+
+
+def _agents_on(files: set[str]) -> list[dict]:
+ with state.LOCK:
+ return [dict(r) for r in state.AGENTS.values()
+ if r["task"] in files and r["status"] == "running"]
+
+
+def stop_phase(filename: str, stage: str) -> dict:
+ """Hold a phase: stop, without unwinding anything.
+
+ `‖ hold` means here what it means on any other card — the work stops
+ and nothing is lost. So the line goes into the log (which is what the
+ beat reads, so the next pass stands down), and the agent the phase has
+ in flight is held exactly as its own card's hold would hold it. The
+ phase branch, every member already merged into it, and every worktree
+ are left precisely as they were: a hold is not an undo, and the only
+ way back to `main` is still the PR at the end.
+
+ A halted phase can be held too, and that is the other half of the
+ halt's promise: it holds until the phase is run again *or* stopped,
+ and a person who has read the halt and does not want to carry on needs
+ a way to say so that is not walking the card backwards.
+
+ A held member's card keeps its own work and its own state, so running
+ the phase again may well halt on it — that is the honest reading of a
+ run that ended without reaching review/, and it is a person's to settle.
+ """
+ phase = _phase_card(filename, stage)
+ with _LOCK: # never alongside a pass of the beat
+ where = run_state(log_entries(phase["body"]))
+ if where["state"] not in ("running", "halted"):
+ raise ValueError(f"{filename} is not running — nothing to hold "
+ f"(its phase log says: {where['state']})")
+ who = taskfiles.actor_name() or "you"
+ if not _write_log(phase, f"stopped — held by {who}"):
+ raise ValueError(f"could not record the hold on {filename} — its "
+ f"phase log must be writable, or the next beat "
+ f"would carry on regardless")
+ held = []
+ for record in _agents_on({m["file"] for m in phase["members"]}):
+ try:
+ agents.stop_agent(record["id"])
+ except ValueError: # it ended between the read and the ask
+ continue
+ held.append(record["task"])
+ _say(filename, f"phase {filename} held by {who}"
+ + (f" — {', '.join(held)} stopped with it" if held else "")
+ + f" — {agents.phase_branch(filename)} is left as it is")
+ snapshot = _snapshot(_reread(phase) or phase, _cards()[0])
+ SNAPSHOTS[filename] = snapshot
+ state.broadcast({"type": "board"})
+ return snapshot
diff --git a/tests/test_boards_sync.py b/tests/test_boards_sync.py
index f26d43c..829646b 100644
--- a/tests/test_boards_sync.py
+++ b/tests/test_boards_sync.py
@@ -12,6 +12,7 @@ from __future__ import annotations
import importlib
import os
+import re
import shutil
import subprocess
import sys
@@ -709,7 +710,11 @@ class TheSyncChip(unittest.TestCase):
def test_sync_events_have_a_glyph_and_a_filter(self):
self.assertIn("sync: '⇅'", self.html)
- self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html)
+ # the board-level kinds share the Moves filter; what matters here is
+ # that sync is one of them, not which others have joined it since
+ moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
+ self.assertIsNotNone(moves, "board.html lost its Moves filter")
+ self.assertIn("'sync'", moves.group(1))
if __name__ == "__main__":
diff --git a/tests/test_phase_watch.py b/tests/test_phase_watch.py
new file mode 100644
index 0000000..645a073
--- /dev/null
+++ b/tests/test_phase_watch.py
@@ -0,0 +1,600 @@
+"""The board shows a phase running, and shows it stopping (task 50).
+
+Card 49 built the runner; this one is the interface it does not inherit
+from being a card — an action to start and stop it, a header chip while it
+runs, and the same chip in `--alarm` when it halts.
+
+Two halves, tested the way each can be: the runner's own half (a hold that
+records itself, a run-state read back off the phase log, the toast a halt
+fires) runs for real on a real repo through `tests.test_phase_runs`'
+harness; the page's half is source-level invariants over board.html, with
+its pure functions lifted out and run under node — board.html has no test
+runner, and a chip that quietly shows one of two running phases is exactly
+the kind of thing that would otherwise reach a person at 2am.
+
+ 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 phases # noqa: E402
+import state # noqa: E402
+
+from tests.test_phase_runs import (DIES, ONE, PHASE, PHASE_BRANCH, TWO, # noqa: E402
+ PhaseCase, card, wait_for)
+
+BOARD = REPO / "manager" / "core" / "board.html"
+NODE = shutil.which("node")
+
+# An agent that is still working when the hold arrives.
+SLEEPS = """#!/usr/bin/env python3
+import time
+time.sleep(60)
+"""
+
+
+class HoldingAPhase(PhaseCase):
+ """`‖ hold` means here what it means everywhere else: the work stops
+ and nothing is lost."""
+
+ def running_agents(self) -> list[dict]:
+ return [r for r in state.AGENTS.values() if r["status"] == "running"]
+
+ def test_the_hold_is_recorded_where_the_beat_reads_it(self):
+ self.start()
+
+ snapshot = phases.stop_phase(PHASE, "in-progress")
+
+ self.assertTrue(any(e.startswith("stopped — held by") for e in self.log()))
+ self.assertFalse(snapshot["running"])
+ self.assertTrue(snapshot["stopped"])
+ self.assertTrue(any("held by" in s for s in self.summaries()))
+
+ def test_the_beat_stands_down_afterwards(self):
+ self.start()
+ phases.stop_phase(PHASE, "in-progress")
+ state.BOARD_EVENTS.clear()
+
+ snapshot = self.advance()
+
+ self.assertEqual(self.summaries(), [], "a held phase is quiet")
+ self.assertEqual(self.merges_on(PHASE_BRANCH), [],
+ "and merges nothing more")
+ self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
+ "and launches nothing more")
+ self.assertTrue(snapshot["stopped"])
+
+ def test_the_phase_branch_and_what_landed_on_it_are_untouched(self):
+ self.start()
+ self.advance() # 31 merges, 32 runs
+ merges, tip = self.merges_on(PHASE_BRANCH), self.tip(PHASE_BRANCH)
+
+ phases.stop_phase(PHASE, "in-progress")
+
+ self.assertEqual(self.merges_on(PHASE_BRANCH), merges)
+ self.assertEqual(self.tip(PHASE_BRANCH), tip, "a hold is not an undo")
+ self.assertTrue(self.branch_exists(f"task/{ONE[:-3]}"))
+ self.assertTrue((config.WORKTREES / PHASE[:-3]).is_dir())
+
+ def test_the_member_in_flight_is_held_with_it(self):
+ """A phase that said it had stopped while the agent it launched
+ kept working would be lying about the one thing it was asked."""
+ self.adapter_is(SLEEPS)
+ phases.start_phase(PHASE, "in-progress")
+ self.assertTrue(wait_for(lambda: any(r["task"] == ONE for r in
+ self.running_agents())),
+ "the phase never launched its first member")
+
+ phases.stop_phase(PHASE, "in-progress")
+
+ self.assertTrue(wait_for(lambda: not self.running_agents()),
+ "the member's agent was left running")
+ self.assertTrue(any(ONE in s and "stopped with it" in s
+ for s in self.summaries()),
+ "and the ticker says which cards stopped with it")
+
+ def test_running_it_again_after_a_hold_carries_on(self):
+ self.start()
+ phases.stop_phase(PHASE, "in-progress")
+
+ phases.start_phase(PHASE, "in-progress")
+ self.settle()
+
+ self.assertIn(f"31 merged into {PHASE_BRANCH}", self.log())
+ self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
+ "the next member started")
+
+ def test_a_halted_phase_can_be_held_rather_than_run_again(self):
+ """The halt holds until the phase is resumed *or* stopped: someone
+ who has read it and does not want to carry on says so here."""
+ self.adapter_is(DIES)
+ self.start()
+ self.advance()
+ self.assertIsNotNone(self.advance()["halted"])
+
+ snapshot = phases.stop_phase(PHASE, "in-progress")
+
+ self.assertIsNone(snapshot["halted"], "the alarm is settled")
+ self.assertTrue(snapshot["stopped"])
+
+ def test_holding_a_phase_that_never_ran_refuses(self):
+ with self.assertRaises(ValueError) as caught:
+ phases.stop_phase(PHASE, "in-progress")
+
+ self.assertIn("not running", str(caught.exception))
+
+ def test_holding_it_twice_refuses(self):
+ self.start()
+ phases.stop_phase(PHASE, "in-progress")
+
+ with self.assertRaises(ValueError) as caught:
+ phases.stop_phase(PHASE, "in-progress")
+
+ self.assertIn("not running", str(caught.exception))
+
+ def test_an_ordinary_card_is_not_held_as_a_phase(self):
+ self.write(ONE, card("31 — Stand up site/", status="In Progress"),
+ "in-progress")
+ (self.tasks / "backlog" / ONE).unlink()
+
+ with self.assertRaises(ValueError) as caught:
+ phases.stop_phase(ONE, "in-progress")
+
+ self.assertIn("not a phase", str(caught.exception))
+
+
+class WhatTheChipIsToldFrom(PhaseCase):
+ """The snapshot the API carries has to answer the chip's questions —
+ which phase, how far, on which card, and what stopped it — without the
+ page inferring any of them."""
+
+ def test_a_running_phase_names_itself_and_where_it_is(self):
+ self.adapter_is(SLEEPS) # so the member is still in flight
+ phases.start_phase(PHASE, "in-progress")
+ self.addCleanup(phases.stop_phase, PHASE, "in-progress")
+
+ snapshot = phases.public_state()[PHASE]
+
+ self.assertTrue(snapshot["running"])
+ self.assertIsNone(snapshot["halted"])
+ self.assertEqual(snapshot["title"], "40 — Ship the site")
+ self.assertEqual(snapshot["number"], "40")
+ self.assertEqual(snapshot["branch"], PHASE_BRANCH)
+ self.assertEqual([m["number"] for m in snapshot["members"]], ["31", "32"])
+ self.assertEqual([m["state"] for m in snapshot["members"]],
+ ["running", "pending"])
+
+ def test_a_halt_arrives_in_its_parts(self):
+ self.adapter_is(DIES)
+ self.start()
+
+ snapshot = self.advance()
+
+ self.assertEqual(snapshot["haltedAt"], "31")
+ self.assertIn("without reaching review/", snapshot["haltedWhy"])
+ self.assertFalse(snapshot["running"])
+ # the one line the log's reader gets, still said the old way
+ self.assertTrue(snapshot["halted"].startswith("31: "))
+
+ def test_a_halt_is_a_toast_as_well_as_a_state_and_a_line(self):
+ """Rare and actionable: the person who is not watching the ticker
+ is told once, the way a dead run tells them."""
+ self.adapter_is(DIES)
+ self.start()
+ self.sent.clear()
+
+ phases.advance_all() # the pass halts; it launches nothing
+
+ toasts = [m for m in self.sent if m.get("type") == "toast"]
+ self.assertEqual(len(toasts), 1, "said once, not once a beat")
+ self.assertTrue(toasts[0]["error"])
+ self.assertIn(PHASE, toasts[0]["message"])
+ self.assertIn("halted at 31", toasts[0]["message"])
+
+ def test_a_second_beat_repeats_neither_the_toast_nor_the_line(self):
+ self.adapter_is(DIES)
+ self.start()
+ self.advance()
+ self.sent.clear()
+
+ phases.advance_all()
+
+ self.assertEqual([m for m in self.sent if m.get("type") == "toast"], [])
+
+ def test_two_phases_are_two_snapshots(self):
+ """Two phases could in principle run on one board — the API says so
+ rather than leaving the header to show one of them."""
+ self.write("41-second-phase.md",
+ card("41 — A second phase", status="In Progress", kind="Phase",
+ cards="- 77 — On its own\n"), "in-progress")
+ self.write("77-alone.md", card("77 — On its own"))
+ self.start()
+ phases.start_phase("41-second-phase.md", "in-progress")
+ self.settle()
+
+ live = phases.public_state()
+
+ self.assertEqual(sorted(live), [PHASE, "41-second-phase.md"])
+ self.assertTrue(all(p["running"] for p in live.values()))
+
+ def test_a_phase_out_of_in_progress_leaves_the_header(self):
+ self.start()
+ self.advance()
+ self.advance() # every member merged: the card moves to review/
+ self.advance() # the next beat, with nothing in in-progress/
+
+ self.assertEqual(self.stage_of(PHASE), "review")
+ self.assertEqual(phases.public_state(), {},
+ "nothing is in flight, so there is nothing to show")
+
+
+class EveryAdvanceIsNarrated(PhaseCase):
+ """A phase that advances silently is a phase nobody can debug
+ afterwards: what finished, what merged, what started."""
+
+ def test_the_ticker_names_all_three(self):
+ self.start()
+ state.BOARD_EVENTS.clear()
+
+ self.advance()
+
+ said = " | ".join(self.summaries())
+ self.assertIn("31 — 31 — Stand up site/ is green", said)
+ self.assertIn(f"merged 31 into {PHASE_BRANCH}", said)
+ self.assertIn("started 32", said)
+
+ def test_the_events_are_phase_events(self):
+ self.start()
+
+ mine = [e for e in state.BOARD_EVENTS if e["file"] == PHASE]
+
+ self.assertTrue(mine, "the phase narrated nothing at all")
+ self.assertEqual({e["kind"] for e in mine}, {"phase"},
+ "the runner's narration is its own kind of event")
+
+
+class TheLogSaysWhereAPhaseIs(unittest.TestCase):
+ """`run_state` is the whole of a restarted board's memory of a run, and
+ now of the header's too. The last line that says something wins."""
+
+ def entries(self, *lines: str) -> list[str]:
+ body = "\n".join(f"- 2026-08-01 09:0{i} · {line}"
+ for i, line in enumerate(lines))
+ return phases.log_entries(f"# 40\n\n## Phase log\n\n{body}\n")
+
+ def state_of(self, *lines: str) -> str:
+ return phases.run_state(self.entries(*lines))["state"]
+
+ def test_a_card_that_has_never_run_is_idle(self):
+ self.assertEqual(phases.run_state([])["state"], "idle")
+
+ def test_a_started_run_is_running(self):
+ self.assertEqual(self.state_of("run started on phase/40-x", "31 started"),
+ "running")
+
+ def test_a_halt_stops_it(self):
+ where = phases.run_state(self.entries("run started on phase/40-x",
+ "halted at 31 — its CI is red"))
+ self.assertEqual(where["state"], "halted")
+ self.assertEqual(where["at"], "31")
+ self.assertEqual(where["reason"], "its CI is red")
+
+ def test_a_hold_stops_it(self):
+ where = phases.run_state(self.entries("run started on phase/40-x",
+ "stopped — held by ronald"))
+ self.assertEqual(where["state"], "stopped")
+ self.assertEqual(where["reason"], "held by ronald")
+
+ def test_a_hold_after_a_halt_settles_it(self):
+ self.assertEqual(self.state_of("halted at 31 — its CI is red",
+ "stopped — held by ronald"), "stopped")
+ self.assertIsNone(phases._halt_reason(
+ self.entries("halted at 31 — its CI is red", "stopped — held by ronald")))
+
+ def test_running_it_again_clears_either(self):
+ self.assertEqual(self.state_of("stopped — held by ronald",
+ "run started on phase/40-x"), "running")
+ self.assertEqual(self.state_of("halted at 31 — its CI is red",
+ "run started on phase/40-x"), "running")
+
+
+class TheHeaderChip(unittest.TestCase):
+ """board.html has no test runner, so these are source-level invariants:
+ the chip exists, it is absent when there is nothing to say, it wears
+ the design system's colours and it is one chip per phase."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.html = BOARD.read_text(encoding="utf-8")
+
+ def func(self, name: str) -> str:
+ match = re.search(rf"^function {name}\(.*?\n\}}\n", self.html,
+ re.M | re.S)
+ self.assertIsNotNone(match, f"board.html lost its {name}()")
+ return match.group(0)
+
+ def test_the_chip_has_a_home_beside_the_agents_chip(self):
+ header = re.search(r".*?", self.html, re.S).group(0)
+ self.assertIn('id="phasechips"', header)
+ self.assertLess(header.index('id="phasechips"'), header.index('id="livechip"'),
+ "the phase chip sits with the header's other live chips")
+
+ def test_it_is_rendered_on_every_frame(self):
+ self.assertIn("renderPhases();", self.func("render"))
+
+ def test_nothing_running_is_no_chip_at_all(self):
+ body = self.func("renderPhases")
+ self.assertIn("el.hidden = !live.length", body,
+ "an empty chip or a placeholder is not the answer")
+
+ def test_only_a_run_or_a_halt_is_in_flight(self):
+ self.assertIn("p.running || p.halted", self.func("phasesInFlight"))
+
+ def test_a_halt_leads(self):
+ self.assertIn("sort(", self.func("phasesInFlight"))
+ self.assertIn("b.halted", self.func("phasesInFlight"))
+
+ def test_every_phase_in_flight_gets_its_own_chip(self):
+ body = self.func("renderPhases")
+ self.assertIn("live.map(", body,
+ "one chip per phase — never one of two, silently")
+
+ def test_the_colours_mean_what_they_mean(self):
+ body = self.func("renderPhases")
+ self.assertIn("dot live", body, "a run alive breathes in --accent")
+ self.assertIn("var(--alarm)", body, "a halt holds in --alarm")
+
+ def test_the_chip_opens_the_phase_card(self):
+ body = self.func("renderPhases")
+ self.assertIn("data-phasechip", body)
+ self.assertIn("showDetail(task)", body)
+
+ def test_the_page_still_parses(self):
+ scripts = re.findall(r"", self.html, re.S)
+ self.assertTrue(scripts, "board.html has no inline script")
+ if not NODE:
+ self.skipTest("node is needed to parse the page")
+ 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)
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own functions")
+class WhatTheChipSays(unittest.TestCase):
+ """`phaseChipDetail` is a pure function of the snapshot, so — as with
+ `md()` and `phaseLabel()` — it is lifted out of the page and run."""
+
+ @classmethod
+ def setUpClass(cls):
+ html = BOARD.read_text(encoding="utf-8")
+ source = ""
+ for name in ("phaseProgress", "clip", "phaseChipDetail", "phasesInFlight"):
+ match = re.search(rf"^function {name}\(.*?\n\}}\n", html, re.M | re.S)
+ assert match, f"board.html lost its {name}()"
+ source += match.group(0)
+ cls._dir = tempfile.TemporaryDirectory()
+ cls.js = Path(cls._dir.name) / "chip.js"
+ cls.js.write_text(
+ "const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));\n"
+ "const S = { state: { phases: input.phases || {} } };\n" + source +
+ "process.stdout.write(JSON.stringify(input.phase "
+ "? phaseChipDetail(input.phase) "
+ ": phasesInFlight().map(p => p.file)));\n", encoding="utf-8")
+
+ @classmethod
+ def tearDownClass(cls):
+ cls._dir.cleanup()
+
+ def run_js(self, payload: dict):
+ out = subprocess.run([NODE, str(self.js)], input=json.dumps(payload),
+ capture_output=True, text=True)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ return json.loads(out.stdout)
+
+ def detail(self, **phase) -> str:
+ return self.run_js({"phase": phase})
+
+ def members(self, *states) -> list[dict]:
+ return [{"number": str(30 + i), "file": f"{30 + i}-c.md", "state": s}
+ for i, s in enumerate(states, start=1)]
+
+ def test_a_run_says_how_far_and_which_card(self):
+ """Progress is what has landed on the phase branch, not where the
+ pointer is — `2/5 · on #33` says two different things, which is the
+ whole reason both are on the chip."""
+ self.assertEqual(
+ self.detail(running=True,
+ members=self.members("merged", "merged", "running",
+ "pending", "pending")),
+ "2/5 · on #33")
+
+ def test_a_member_waiting_on_its_checks_is_still_the_card_in_flight(self):
+ self.assertEqual(
+ self.detail(running=True, members=self.members("merged", "waiting")),
+ "1/2 · on #32")
+
+ def test_a_dependency_holding_the_phase_is_named(self):
+ self.assertEqual(
+ self.detail(running=True, waitingOn=["99"],
+ members=self.members("merged", "pending")),
+ "1/2 · waiting on #99")
+
+ def test_between_passes_the_progress_stands_alone(self):
+ self.assertEqual(
+ self.detail(running=True, members=self.members("merged", "pending")),
+ "1/2")
+
+ def test_a_halt_names_the_card_and_the_reason(self):
+ self.assertEqual(
+ self.detail(halted="35: it is not ready", haltedAt="35",
+ haltedWhy="it is not ready", members=self.members("merged")),
+ "halted at #35 — it is not ready")
+
+ def test_a_long_reason_is_clipped_for_the_header(self):
+ detail = self.detail(halted="x", haltedAt="35", members=self.members(),
+ haltedWhy="its run ended without reaching review/ — "
+ "API Error: 500 overloaded, the model said")
+ self.assertTrue(detail.endswith("…"), detail)
+ self.assertLessEqual(len(detail), 64, "a header chip is not the place "
+ "a reason gets to run long")
+
+ def test_a_halt_with_no_member_still_says_what_stopped(self):
+ self.assertEqual(
+ self.detail(halted="the run could not continue: no", members=[],
+ haltedWhy="the run could not continue: no"),
+ "halted — the run could not continue: no")
+
+ def test_a_quiet_board_has_nothing_in_flight(self):
+ self.assertEqual(self.run_js({"phases": {
+ "40-a.md": {"file": "40-a.md", "running": False, "halted": None},
+ }}), [])
+
+ def test_a_halted_phase_leads_the_running_one(self):
+ self.assertEqual(self.run_js({"phases": {
+ "40-a.md": {"file": "40-a.md", "running": True, "halted": None},
+ "41-b.md": {"file": "41-b.md", "running": False, "halted": "31: red"},
+ }}), ["41-b.md", "40-a.md"])
+
+
+class TheCardsActions(unittest.TestCase):
+ """▸ run phase sits in the slot ▸ start work has on every other card,
+ and ‖ hold replaces it while the phase runs — both on the one action
+ machine, so both arm before they fire."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.html = BOARD.read_text(encoding="utf-8")
+ block = re.search(r"if \(task\.stage === 'in-progress' && task\.isPhase\)"
+ r" \{.*?\n \} else if \(task\.stage === 'in-progress'\)",
+ cls.html, re.S)
+ assert block, "board.html lost the phase card's actions"
+ cls.block = block.group(0)
+
+ def test_the_action_only_exists_on_an_in_progress_phase_card(self):
+ self.assertIn("task.stage === 'in-progress' && task.isPhase", self.html)
+
+ def test_running_a_phase_arms_like_every_other_launch(self):
+ self.assertIn("label: 'run phase'", self.block)
+ self.assertIn("confirm: 'run it?'", self.block)
+ self.assertIn("busy: 'starting…'", self.block)
+
+ def test_a_phase_someone_else_holds_is_taken_over_deliberately(self):
+ self.assertIn("label: 'take over'", self.block)
+ self.assertIn("takeover: true", self.block)
+
+ def test_hold_is_the_word_while_it_runs(self):
+ self.assertIn("ph.running) actions.push(hold)", self.block)
+ self.assertIn("label: 'hold'", self.block)
+ self.assertIn("confirm: 'hold it?'", self.block)
+
+ def test_a_halt_offers_both_ways_out(self):
+ self.assertIn("ph.halted) actions.push(start, hold)", self.block)
+
+ def test_the_slot_never_holds_three(self):
+ self.assertIn("else if (actions.length < 2) {", self.html,
+ "the relevance check gives way rather than stacking")
+
+ def test_both_go_through_the_api(self):
+ run = re.search(r"async function runPhase.*?\n\}", self.html, re.S).group(0)
+ hold = re.search(r"async function holdPhase.*?\n\}", self.html, re.S).group(0)
+ self.assertIn("'/api/phase/run'", run)
+ self.assertIn("'/api/phase/stop'", hold)
+ for body in (run, hold):
+ self.assertIn("return res.ok", body,
+ "fireAction can only unlock on error if it is told")
+ self.assertIn("toast(", body)
+
+
+class ThePhaseCardsSheet(unittest.TestCase):
+ """Opening a phase card answers "where is this up to" without a hunt
+ across five columns."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.html = BOARD.read_text(encoding="utf-8")
+ cls.block = re.search(r"function phaseMembers\(t\) \{.*?\n\}\n",
+ cls.html, re.S).group(0)
+
+ def test_the_sheet_lists_the_members(self):
+ self.assertIn("phaseMembers(t) +", self.html,
+ "the list belongs in the drawer, above the card's text")
+
+ def test_only_a_phase_card_gets_one(self):
+ self.assertIn("if (!t.isPhase) return '';", self.block)
+
+ def test_run_order_is_the_order_it_lists(self):
+ self.assertIn("t.members.map((m, i)", self.block)
+ self.assertIn("${i + 1}", self.block)
+
+ def test_each_member_carries_its_stage(self):
+ self.assertIn("m.stage", self.block)
+
+ def test_the_runners_reading_is_shown_when_there_is_one(self):
+ self.assertIn("S.state.phases || {}", self.block)
+ self.assertIn("MEMBER_STATE", self.block)
+ for word in ("merged in", "working", "stopped here"):
+ self.assertIn(word, self.html)
+
+ def test_a_row_opens_that_card(self):
+ self.assertIn("data-member", self.block)
+ self.assertIn("findTask(row.dataset.member)", self.html)
+
+
+class TheTickerReadsPhaseEvents(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.html = BOARD.read_text(encoding="utf-8")
+
+ def test_a_phase_event_has_a_glyph_of_its_own(self):
+ glyphs = re.search(r"const GLYPHS = \{.*?\};", self.html, re.S).group(0)
+ self.assertIn("phase: '⟶'", glyphs)
+
+ def test_phase_events_are_in_the_moves_filter(self):
+ moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
+ self.assertIsNotNone(moves, "board.html lost its Moves filter")
+ self.assertIn("'phase'", moves.group(1),
+ "a phase's narration is board-level, like a move or a sync")
+
+
+class TheApiHasAStop(unittest.TestCase):
+ def test_the_route_exists(self):
+ source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
+ self.assertIn('"/api/phase/stop"', source)
+ self.assertIn("phases.stop_phase", source)
+
+
+class TheDocumentedPromise(unittest.TestCase):
+ """What the board does is documented in AGENTS.md; an interface that is
+ not written down is one the next reader has to find by clicking."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
+
+ def test_the_actions_are_named(self):
+ self.assertIn("▸ run phase", self.doc)
+
+ def test_the_chip_is_described(self):
+ self.assertIn("halted", self.doc)
+ self.assertIn("header", self.doc)
+
+
+if __name__ == "__main__":
+ unittest.main()