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 `
` + + dot + `⟶ ${esc(phaseLabel(p))}` + + `${esc(detail)}
`; + }).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 + ? `
halted` + + `${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''} — ` + + `${esc(snap.haltedWhy || snap.halted)}
` : ''; + return `
Cards` + + `${t.members.length} in run order
` + + rows + halt + `
`; +} + function renderDrawer() { const panel = $('#drawer'); const body = $('#drawerbody'); @@ -1557,9 +1773,15 @@ function renderDrawer() { `` + `` + failBlock + + phaseMembers(t) + `
${md(t.body)}
` + `
${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()