diff --git a/AGENTS.md b/AGENTS.md index 2a02516..d52a02c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -378,6 +378,21 @@ there, and firing it is the deliberate reassignment. An unclaimed card claims itself on launch. One agent per task at a time, and a work agent's worktree must not already exist when it starts. +It also refuses a **phase card**, and the refusal names **▸ run phase** — +a phase card's body is a list of other cards, so a work agent handed one +implements the table of contents. The board's own page offers one action +or the other and never both, but a UI layer can be stale or bypassed, so +the rule lives where every other launch refusal lives: with the stage +check, ahead of the claim and well ahead of the worktree, costing nothing +and leaving nothing to clean up. Which headless kinds a phase card may +host is decided rather than left to omission: **▸ start work** and **↻ act +on PR** refuse it (both are work agents, and a phase's work belongs to its +members' cards), while the read-only pair — **◔ still true?** and **◔ +review PR**, the latter told the phase's own branch — are allowed. The +guard is about *starting*: a card that gains `**Type:** Phase` while an +ordinary run is in flight is left alone, and that run ends as it would +have. + 1. The board creates a git worktree at `.worktrees//` on a new branch `task/` from the newest main it can see: with an `origin` remote it fetches `origin/main` first (bounded by @@ -951,6 +966,17 @@ and they are the phase's own interface: 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. +**And the card itself says which state it is in.** The header chip only +appears while there is something to say, so it cannot tell "the phase has +not been started" from "there is no phase here at all" — which left an +`in-progress/` phase card nobody had run looking exactly like one mid-run. +So the card carries the distinction quietly, in the pill and the line +under it: `not started` (`▸ run phase` cuts its branch and starts the +first card), `held`, the accent and the member in flight while it runs, +and `halted` in `--alarm`. Only a run wears the working vocabulary — the +breathing mark, the accent border, the caret — because only a run is work +happening. + **And the card does not move while its work runs.** A phase card stands for cards the Board no longer draws, so dragging it to another stage — or onto the archive tray, which is a move like any other — while a member has diff --git a/manager/core/agents.py b/manager/core/agents.py index 0c41729..7b1ffb4 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -148,13 +148,65 @@ def working_on(files: set[str]) -> list[dict]: return [record for record in records if _alive(record)] -def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None: +# ── what a phase card may host ───────────────────────────────────────── +# +# A phase card is a list of other cards. Handed to a work agent as a brief +# it reads as a table of contents, and the agent does what it is told — +# which is how one run once implemented two cards at once in a worktree +# nobody was watching. `▸ run phase` already guards its own door (a card +# that is not a phase is refused there); this is the other half of that +# gate, and it is decided kind by kind rather than left to omission: +# +# - **▸ start work** (`start_agent`) refuses. A phase is run with ▸ run +# phase, which works its list into a branch of its own; its members are +# worked on their own cards. +# - **↻ act on PR** (`start_pr_fix`) refuses. It is the same work agent +# with a push, and the phase's PR carries its members' commits — review +# feedback on it belongs on the member's own card, or on the phase +# branch by hand. +# - **◔ still true?** (`start_review`) is allowed. Read-only, no worktree: +# asking whether a phase is still worth running is a fair question, and +# the report is appended to the card like any other. +# - **◔ review PR** (`start_pr_review`) is allowed. Read-only, and the PR +# into `main` is the one thing the whole run exists to produce — the +# launch just has to name the phase's own branch rather than a +# `task/` that was never cut. +# +# It is about *starting*: a card that gains `**Type:** Phase` while an +# ordinary run is in flight is left alone, and the run ends as it would +# have. +PHASE_RUNS_WITH = ("a list of other cards, not a brief. Run it with ▸ run " + "phase, which cuts a branch of its own and works the list " + "into it; its members are worked on their own cards") + + +def is_phase_card(filename: str, stage: str) -> bool: + """Is this card a phase card? `**Type:** Phase` is the whole of it — + the same reading `phases._phase_card` refuses a non-phase by, so the + two gates cannot disagree. A phase card whose list is empty or unwritten + is still a coordinator, and still no brief for a work agent.""" + try: + return bool(read_task(config.TASKS / stage / filename, stage)["isPhase"]) + except OSError: + return False + + +def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None, + *, phase: str | None = None) -> None: + """The refusals every launch shares, before anything exists to clean up. + + `phase` is what a phase card should do instead — pass it and this kind + refuses one, naming that; omit it and the kind is one a phase card may + host. See the note above for which is which and why. + """ if Path(filename).name != filename or not filename.endswith(".md"): raise ValueError("bad filename") if stage not in allowed: raise ValueError(why or f"agents cannot start from {stage}/") if not (config.TASKS / stage / filename).is_file(): raise ValueError(f"{filename} is not in {stage}/ — refresh the board") + if phase and is_phase_card(filename, stage): + raise ValueError(f"{filename} is a phase card — {phase}") _assert_no_running_agent(filename) @@ -304,9 +356,12 @@ def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None: def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: - # Moving a card to in-progress is the commitment; only then does work start. + # Moving a card to in-progress is the commitment; only then does work + # start — and a phase card is refused here, in the same breath and ahead + # of the claim, so the refusal costs nothing and leaves nothing behind. _validate(filename, stage, {"in-progress"}, - "work starts from in-progress/ — move the card there first") + "work starts from in-progress/ — move the card there first", + phase=PHASE_RUNS_WITH) claim_for_launch(filename, stage, takeover) stem = filename[:-3] @@ -386,7 +441,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: def start_review(filename: str, stage: str) -> dict: - """Fire a read-only agent that checks the task against the codebase.""" + """Fire a read-only agent that checks the task against the codebase. + + Every stage, and a phase card too: no `phase=` here is the deliberate + answer, not an omission. Nothing is written but the report. + """ _validate(filename, stage, config.STAGE_DIRS) task = read_task(config.TASKS / stage / filename, stage) @@ -593,14 +652,21 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None: def start_pr_review(filename: str, stage: str) -> dict: """Fire a read-only agent that reviews the task's PR and posts the - verdict to GitHub as well as back to the board.""" + verdict to GitHub as well as back to the board. + + A phase card is allowed here, deliberately: the PR into `main` is what + the whole run exists to produce, and reading it writes nothing. + """ _validate(filename, stage, {"review"}, "PR reviews run on cards in review/") task = read_task(config.TASKS / stage / filename, stage) if not task.get("pr"): raise ValueError(f"{filename} has no PR yet — nothing to review") - branch = f"task/{filename[:-3]}" + # …but it must be told the branch its PR is actually from: the prompt + # asks GitHub for the diff by branch, and a phase's is its own. + branch = (phase_branch(filename) if task["isPhase"] + else f"task/{filename[:-3]}") name = _pick_name(filename) agent_id = f"review-pr-{filename[:-3]}-{time.strftime('%H%M%S')}" log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log" @@ -632,7 +698,10 @@ def start_pr_fix(filename: str, stage: str) -> dict: working in the task's existing worktree (recreated from the branch if it was cleaned up), committing and pushing to update the PR.""" _validate(filename, stage, {"review"}, - "acting on a PR happens from review/") + "acting on a PR happens from review/", + phase="↻ act on PR is a work agent, and a phase's PR carries " + "its members' commits — address the review on the member's " + "own card, or on the phase branch by hand") task = read_task(config.TASKS / stage / filename, stage) if not task.get("pr"): raise ValueError(f"{filename} has no PR to act on") diff --git a/manager/core/board.html b/manager/core/board.html index 2bc83bd..b1ef4ea 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -1347,8 +1347,14 @@ function phaseSummary(task) { } /* …and what it is doing right now: the member in flight while a run is on, - the halt while one is held. Null when the phase has no story yet — a - phase waiting in to-do/ is just a card. */ + the halt while one is held — and the quiet case this exists for, that + nobody has started it. A phase card sitting in in-progress/ that has not + been run used to look exactly like one that is running: the header chip + is the only other place that difference is written, and it is absent both + when a phase has not started and when there is no phase at all. So the + card says it itself. `idle` is not work — it takes no accent, no + breathing mark and no caret, because nothing is happening. Null when the + phase has no story to tell here: one waiting in to-do/ is just a card. */ function phaseFlight(task) { const snap = (S.state.phases || {})[task.file]; if (!snap) return null; @@ -1356,7 +1362,14 @@ function phaseFlight(task) { return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') + ' — ' + (snap.haltedWhy || snap.halted) }; } - if (!snap.running) return null; + if (!snap.running) { + if (task.stage !== 'in-progress') return null; + return snap.stopped + ? { idle: true, pill: 'held', + line: 'held — ▸ run phase carries on from where it stopped' } + : { idle: true, pill: 'not started', + line: 'not started — ▸ run phase cuts its branch and starts the first card' }; + } const at = phaseProgress(snap); if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file} — ${at.on.title}` }; if ((snap.waitingOn || []).length) { @@ -1392,7 +1405,8 @@ function cardFor(task) { // a running phase has an agent alive on a card this view no longer draws, // so the phase card wears that state on its members' behalf const flight = task.isPhase ? phaseFlight(task) : null; - const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad); + const working = !!(agent && agent.mode !== 'review') + || !!(flight && !flight.bad && !flight.idle); const verdict = task.stage === 'review' ? prVerdict(task) : null; const failure = failedRun(task); // the server holds this, not the tab that clicked: while merge & clean up @@ -1419,6 +1433,15 @@ function cardFor(task) { pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) }; tint = 'var(--alarm)'; } + if (flight && flight.idle) { + // the state the card could not tell from a run in flight: a phase in + // in-progress/ that nobody has started, or one held. It is the absence + // of work, so it wears the settled register — never the accent, which + // means an agent is alive. + pill = { text: flight.pill, tint: 'var(--idle)', bg: 'var(--sunken)', + title: flight.line }; + tint = 'var(--idle)'; + } if (failure) { // the newest thing that happened here, and the only actionable one: // it outranks a PR verdict from before the run died @@ -1610,7 +1633,7 @@ function cardFor(task) { // the header chip carries, at the altitude a person is already reading liveLine = `
·` + `${esc(flight.line)}` + - `${flight.bad ? '' : ''}
`; + `${flight.bad || flight.idle ? '' : ''}`; } // tool chips: destinations, not statuses — they live in the card's footer @@ -2503,7 +2526,7 @@ function laneFor(task) { // a run in flight, in the same well the cards use for the same fact const well = !halted && flight ? `
·${esc(flight.line)}` + - `
` + `${flight.idle ? '' : ''}` : ''; el.innerHTML = head + halt + well + diff --git a/tests/test_phase_card_refuses_work.py b/tests/test_phase_card_refuses_work.py new file mode 100644 index 0000000..c7edbf2 --- /dev/null +++ b/tests/test_phase_card_refuses_work.py @@ -0,0 +1,385 @@ +"""A work agent must refuse a phase card (task 54). + +`▸ run phase` guards its own door — a card that is not a phase is refused +there. This is the other half of that gate, on the neighbour's door: a +phase card's body is a list of other cards, so a work agent handed one +implements the table of contents, which is exactly what happened the first +time a phase reached the board. The refusal is a server rule because the +page that offers one action or the other can be stale or bypassed. + +Which headless kinds a phase card may host is *decided* here rather than +left to omission, so each of the four has a case saying which it is. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import agents # noqa: E402 +import config # noqa: E402 +import phases # noqa: E402 +import state # noqa: E402 + +from tests.test_phase_runs import (ONE, PHASE, PHASE_BRANCH, PhaseCase, # noqa: E402 + card, git, wait_for) + +BOARD = REPO / "manager" / "core" / "board.html" +NODE = shutil.which("node") + +ALONE = "77-alone.md" +PR_URL = "https://github.com/acme/widget/pull/7" + +# Reads, says something, writes nothing — what a read-only kind does. +REPORTS = """#!/usr/bin/env python3 +print("RELEVANCE REVIEW: Still relevant") +""" + +# Commits, then waits for a file beside the worktrees directory before +# exiting — a run held open for as long as a test needs it in flight. +BLOCKS = """#!/usr/bin/env python3 +import os, subprocess, time +cwd = os.environ["AGENT_CWD"] +open(os.path.join(cwd, "work.txt"), "w").write("work\\n") +subprocess.run(["git", "-C", cwd, "add", "-A"], check=True) +subprocess.run(["git", "-C", cwd, "-c", "user.email=a@b", "-c", "user.name=stub", + "commit", "-q", "-m", "work"], check=True) +go = os.path.join(os.path.dirname(os.path.dirname(cwd)), "worktrees", "go") +for _ in range(1200): + if os.path.exists(go): + break + time.sleep(0.05) +print("WORK REPORT: the work is committed") +""" + + +class LaunchCase(PhaseCase): + """PhaseCase's world — a real repo, a real adapter, a real phase card in + in-progress/ — with the launches aimed at the card rather than the run.""" + + def only_in(self, filename: str, text: str, stage: str) -> None: + """One card, one stage — a rewrite that moves it rather than + leaving the board reading the same number twice.""" + for slug in config.STAGE_DIRS: + path = self.tasks / slug / filename + if path.is_file(): + path.unlink() + self.write(filename, text, stage) + + def ordinary(self, stage: str = "in-progress", **kw) -> None: + """A card that is not a phase, in the stage a launch wants it in.""" + self.only_in(ALONE, card("77 — On its own", + status=config.STAGE_LABELS[stage], **kw), stage) + + def as_phase(self, filename: str, stage: str, listed: str | None = None) -> None: + """The same card, now typed Phase — what a person writes when they + decide a card coordinates rather than builds.""" + self.only_in(filename, card("77 — On its own", kind="Phase", + status=config.STAGE_LABELS[stage], + cards=listed), stage) + + def with_pr(self, filename: str, title: str, stage: str, **kw) -> None: + text = card(title, status=config.STAGE_LABELS[stage], **kw) + self.only_in(filename, text.replace( + "**Priority:** High\n", + f"**Priority:** High\n**PR:** {PR_URL}\n"), stage) + + def running(self) -> list[dict]: + return [r for r in state.AGENTS.values() if r["status"] == "running"] + + def worktrees(self) -> list[str]: + return [line.split(" ", 1)[1] for line in + git(self.repo, "worktree", "list", "--porcelain").stdout.splitlines() + if line.startswith("worktree ")] + + +class TheWorkAgentRefusesAPhaseCard(LaunchCase): + def test_a_phase_card_is_refused_and_told_what_to_run_instead(self): + with self.assertRaises(ValueError) as caught: + agents.start_agent(PHASE, "in-progress") + + self.assertIn("▸ run phase", str(caught.exception)) + self.assertIn("phase card", str(caught.exception)) + + def test_the_refusal_leaves_nothing_behind(self): + """Refused with `_validate`, so it costs nothing: no branch, no + worktree, no process, nothing for anyone to clean up.""" + with self.assertRaises(ValueError): + agents.start_agent(PHASE, "in-progress") + + self.assertFalse(self.branch_exists(f"task/{PHASE[:-3]}")) + self.assertFalse((config.WORKTREES / PHASE[:-3]).exists()) + self.assertEqual(self.worktrees(), [str(self.repo)]) + self.assertEqual(state.AGENTS, {}, "no run was ever recorded") + + def test_it_refuses_ahead_of_the_claim(self): + """The order the shape depends on: a refusal must not write the card + it refused. In team mode a launch claims an unheld card, so an + assignee appearing here would mean the guard ran too late.""" + self.patch(COMMIT_MOVES=True) + + with self.assertRaises(ValueError): + agents.start_agent(PHASE, "in-progress") + + self.assertNotIn("**Assignee:**", self.text(PHASE)) + + def test_a_takeover_is_refused_just_the_same(self): + """The deliberate second click reassigns a card; it does not make a + table of contents into a brief.""" + self.patch(COMMIT_MOVES=True) + + with self.assertRaises(ValueError) as caught: + agents.start_agent(PHASE, "in-progress", takeover=True) + + self.assertIn("▸ run phase", str(caught.exception)) + self.assertNotIn("**Assignee:**", self.text(PHASE)) + + def test_a_phase_card_with_no_list_yet_is_still_a_phase_card(self): + """`**Type:** Phase` is the whole of the reading, the same one + `phases._phase_card` refuses a non-phase by. A list not written yet + is an authoring mistake to fix, not an invitation to build it.""" + self.as_phase(ALONE, "in-progress") + + with self.assertRaises(ValueError) as caught: + agents.start_agent(ALONE, "in-progress") + + self.assertIn("▸ run phase", str(caught.exception)) + + def test_an_ordinary_card_starts_work_exactly_as_it_did(self): + self.ordinary() + + agent = agents.start_agent(ALONE, "in-progress") + self.settle() + + self.assertEqual(agent["branch"], f"task/{ALONE[:-3]}") + self.assertTrue(self.branch_exists(f"task/{ALONE[:-3]}")) + self.assertEqual(self.stage_of(ALONE), "review", + "the ordinary path is untouched: commits, then review/") + + def test_the_two_gates_are_mirror_images(self): + """The bug was an asymmetry, so the symmetry is the test: each door + refuses the card the other one is for, and says so.""" + self.ordinary() + + with self.assertRaises(ValueError) as work: + agents.start_agent(PHASE, "in-progress") + with self.assertRaises(ValueError) as run: + phases.start_phase(ALONE, "in-progress") + + self.assertIn("phase card", str(work.exception)) + self.assertIn("not a phase", str(run.exception)) + self.assertEqual(state.AGENTS, {}) + self.assertFalse(self.branch_exists(f"phase/{ALONE[:-3]}")) + + +class ARunInFlightIsLeftAlone(LaunchCase): + """The guard is about starting. A card retyped under a running agent is + a person's edit, not a reason to break the run underneath it.""" + + def test_a_card_that_becomes_a_phase_mid_run_still_lands(self): + self.ordinary() + self.adapter_is(BLOCKS) + go = config.WORKTREES / "go" + + agents.start_agent(ALONE, "in-progress") + self.assertTrue( + wait_for(lambda: (config.WORKTREES / ALONE[:-3] / "work.txt").is_file()), + "the agent never got as far as its commit") + # the edit, while the process is alive and the reaper has not run + self.as_phase(ALONE, "in-progress") + self.assertTrue(self.running(), "the run ended before the edit landed") + go.write_text("done\n", encoding="utf-8") + self.settle() + + record = next(iter(state.AGENTS.values())) + self.assertEqual(record["rc"], 0) + self.assertEqual(record["status"], "done", "the run ended as it would have") + self.assertEqual(self.stage_of(ALONE), "review", + "and the card moved on, phase line or not") + + def tearDown(self): + # never leave a blocked adapter behind if an assertion jumped the wire + (config.WORKTREES / "go").parent.mkdir(parents=True, exist_ok=True) + (config.WORKTREES / "go").write_text("done\n", encoding="utf-8") + wait_for(lambda: not self.running(), timeout=10) + + +class WhichKindsAPhaseCardMayHost(LaunchCase): + """Four kinds, four deliberate answers — the two that would work on the + card refuse it, the two that only read it are allowed.""" + + def test_still_true_is_allowed_on_a_phase_card(self): + self.adapter_is(REPORTS) + + agent = agents.start_review(PHASE, "in-progress") + self.assertTrue(wait_for(lambda: not self.running())) + + self.assertEqual(agent["mode"], "review") + self.assertIsNone(agent["worktree"], "read-only: nothing is cut for it") + self.assertIn("Relevance review", self.text(PHASE)) + + def test_reviewing_the_phase_pr_is_allowed_and_names_the_phase_branch(self): + """The prompt asks GitHub for the diff by branch, and a phase's PR + is from its own branch — `task/` was never cut.""" + self.adapter_is(REPORTS) + self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase", + cards="- 31 — Stand up site/\n") + + agent = agents.start_pr_review(PHASE, "review") + self.assertTrue(wait_for(lambda: not self.running())) + + self.assertEqual(agent["branch"], PHASE_BRANCH) + + def test_an_ordinary_cards_pr_review_still_names_its_task_branch(self): + self.adapter_is(REPORTS) + self.with_pr(ONE, "31 — Stand up site/", "review") + + agent = agents.start_pr_review(ONE, "review") + self.assertTrue(wait_for(lambda: not self.running())) + + self.assertEqual(agent["branch"], f"task/{ONE[:-3]}") + + def test_acting_on_a_phase_pr_is_refused_and_says_where_it_belongs(self): + """↻ act on PR is the same work agent with a push, and a phase's PR + carries its members' commits.""" + self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase", + cards="- 31 — Stand up site/\n") + + with self.assertRaises(ValueError) as caught: + agents.start_pr_fix(PHASE, "review") + + self.assertIn("phase card", str(caught.exception)) + self.assertIn("member", str(caught.exception)) + self.assertFalse((config.WORKTREES / PHASE[:-3]).exists()) + self.assertEqual(state.AGENTS, {}) + + +@unittest.skipUnless(NODE, "node is needed to run the page's own rules") +class TheCardSaysWhichStateItIsIn(unittest.TestCase): + """A phase card in in-progress/ that nobody has started used to look + exactly like one mid-run. These run the page's own function over the + snapshots the server sends.""" + + PARTS = (r"function phaseProgress\(p\) \{.*?\n\}", + r"function phaseFlight\(task\) \{.*?\n\}") + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + parts = [] + for pattern in cls.PARTS: + match = re.search(pattern, cls.html, re.S) + if match is None: + raise AssertionError(f"board.html no longer defines {pattern!r}") + parts.append(match.group(0)) + cls.src = "\n".join(parts) + cls.card_fn = re.search(r"function cardFor\(task\) \{.*?\n\}", + cls.html, re.S).group(0) + + def flight(self, snapshot: dict | None, stage: str = "in-progress") -> object: + task = {"file": PHASE, "stage": stage, "isPhase": True} + script = (self.src + "\nvar S = { state: { phases: " + + json.dumps({PHASE: snapshot} if snapshot else {}) + + " } };\nconsole.log(JSON.stringify(phaseFlight(" + + json.dumps(task) + ")));\n") + out = subprocess.run([NODE, "-e", script], capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def snapshot(self, **extra) -> dict: + return {"file": PHASE, "members": [], "halted": None, "haltedAt": None, + "haltedWhy": None, "running": False, "stopped": False, + "started": False, **extra} + + def test_a_phase_nobody_has_started_says_so(self): + flight = self.flight(self.snapshot()) + + self.assertTrue(flight["idle"], "nothing is happening, so nothing breathes") + self.assertEqual(flight["pill"], "not started") + self.assertIn("▸ run phase", flight["line"]) + + def test_a_held_phase_is_its_own_state(self): + flight = self.flight(self.snapshot(started=True, stopped=True)) + + self.assertTrue(flight["idle"]) + self.assertEqual(flight["pill"], "held") + + def test_a_running_phase_is_unchanged(self): + flight = self.flight(self.snapshot( + started=True, running=True, + members=[{"number": "31", "file": ONE, "title": "Stand up site/", + "state": "running"}])) + + self.assertFalse(flight.get("idle"), "a run is work: it keeps the accent") + self.assertIn("on #31", flight["line"]) + + def test_a_halted_phase_is_unchanged(self): + flight = self.flight(self.snapshot(started=True, halted="31: its CI is red", + haltedAt="31", haltedWhy="its CI is red")) + + self.assertTrue(flight["bad"]) + self.assertFalse(flight.get("idle")) + + def test_a_phase_the_runner_has_not_read_says_nothing(self): + """Before the first beat there is no snapshot, and a card that + guessed would be worse than one that waits.""" + self.assertIsNone(self.flight(None)) + + def test_a_settled_phase_card_is_not_told_it_never_started(self): + """review/ and done/ are past the question — the card there is + waiting on a person, which its stage already says.""" + self.assertIsNone(self.flight(self.snapshot(started=True), stage="review")) + self.assertIsNone(self.flight(self.snapshot(started=True), stage="done")) + + def test_idle_wears_the_settled_register_and_never_the_accent(self): + block = re.search(r"if \(flight && flight\.idle\) \{.*?\n \}", + self.card_fn, re.S).group(0) + self.assertIn("var(--idle)", block) + for colour in ("--accent", "--alarm", "--calm"): + self.assertNotIn(colour, block, + "not started is neither work, an alarm nor a verdict") + self.assertIn("!flight.bad && !flight.idle", self.card_fn, + "an unstarted phase must not read as an agent working") + self.assertIn("flight.bad || flight.idle ? '' :", self.card_fn, + "…and nothing is still arriving, so there is no caret") + + def test_the_halt_and_the_failure_still_outrank_it(self): + idle = self.card_fn.index("if (flight && flight.idle)") + self.assertLess(idle, self.card_fn.index("if (failure)")) + self.assertLess(idle, self.card_fn.index("if (flight && flight.bad)")) + + +class TheDocumentedGuard(unittest.TestCase): + """The doctrine this card restores: the file-carried gates exist because + a UI layer can be stale, so AGENTS.md has to say this one is there.""" + + @classmethod + def setUpClass(cls): + cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8") + # the doc is hard-wrapped, so read it as the sentence it is + cls.flat = re.sub(r"\s+", " ", cls.doc.replace("**", "")) + + def test_the_refusal_is_written_down_with_the_action_it_names(self): + self.assertIn("It also refuses a phase card", self.flat) + self.assertIn("the refusal names ▸ run phase", self.flat) + + def test_the_kinds_a_phase_card_may_host_are_named(self): + self.assertIn("▸ start work and ↻ act on PR refuse it", self.flat) + self.assertIn("◔ still true? and ◔ review PR", self.flat) + + def test_the_unstarted_card_is_described(self): + self.assertIn("`not started`", self.doc) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase_members_hidden.py b/tests/test_phase_members_hidden.py index 960c0ad..2b66848 100644 --- a/tests/test_phase_members_hidden.py +++ b/tests/test_phase_members_hidden.py @@ -416,10 +416,24 @@ class ThePhaseCardsSummary(BoardViewCase): self.assertIsNone(self.flight()) - def test_a_phase_whose_run_ended_has_nothing_to_say(self): + def test_a_phase_in_progress_with_no_run_says_it_has_not_started(self): + """It said nothing here once, which made a phase nobody had started + look exactly like one mid-run — the header chip is absent in both + cases. Task 54 gave the card the distinction; it is quiet, and it is + not work, so nothing about it breathes.""" self.a_phase_of_two() - self.assertIsNone(self.flight(phases=self.snapshot(running=False))) + flight = self.flight(phases=self.snapshot(running=False)) + + self.assertTrue(flight["idle"]) + self.assertEqual(flight["pill"], "not started") + + def test_a_phase_that_was_held_says_that_instead(self): + self.a_phase_of_two() + + flight = self.flight(phases=self.snapshot(running=False, stopped=True)) + + self.assertEqual(flight["pill"], "held") class Wiring(unittest.TestCase):