From 542945518b0f779c37346bc6da6ff5caf1e2d359 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 10 Oct 2026 18:36:05 -0500 Subject: [PATCH 1/3] fix: drop superseded review cycles from the dashboard attention list `compute_attention()` added a "failed" row for each `.project/review/*.md` file with a negative verdict. The runtime keeps earlier review cycles on disk, so a wave that blocked at cycle 1 and passed at cycle 2 kept a permanent "failed" row and a red project until the milestone was archived. For wave review files (`wave-N.cycleC.md` and the deep-review lens files `wave-N.cycleC..md`), only the highest cycle of each wave and lens now drives attention. The Reviews table still lists every cycle. Final, gap and other review files are unchanged. Item 1 of the issue (runtime validation on the pinned-runtime layout) was fixed by 84b97f4a. Fixes #356 Co-Authored-By: Claude Opus 5.5 --- daemon/gsd_daemon/probe.py | 17 +++++++++++++++- tests/test_daemon_attention.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/daemon/gsd_daemon/probe.py b/daemon/gsd_daemon/probe.py index 23c9ba34b..9033dab17 100644 --- a/daemon/gsd_daemon/probe.py +++ b/daemon/gsd_daemon/probe.py @@ -37,7 +37,7 @@ re.IGNORECASE | re.MULTILINE) REVIEW_CYCLE_RE = re.compile(r"^Cycle:\s*(\d+)", re.MULTILINE) REVIEW_DEPTH_RE = re.compile(r"^Depth:\s*([A-Za-z-]+)", re.MULTILINE) -WAVE_REVIEW_FILE_RE = re.compile(r"^wave-\d+\.cycle\d+(?:\.[a-z]+)?\.md$") +WAVE_REVIEW_FILE_RE = re.compile(r"^wave-(\d+)\.cycle(\d+)(\.[a-z]+)?\.md$") GAP_REVIEW_FILE_RE = re.compile(r"^final-gap-\d+\.md$") CRITERION_HEADING_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) CRITERION_ID_RE = re.compile(r"^(SC\d+)\s*—\s*(.*)$") @@ -677,6 +677,18 @@ def _human_age(seconds: float) -> str: return f"{max(0, int(seconds // 3600))}h" +def _superseded_reviews(reviews: List[dict]) -> set: + """Wave review files that a later cycle of the same wave and lens replaces.""" + cycles: Dict[tuple, Dict[int, object]] = {} + for review in reviews: + match = WAVE_REVIEW_FILE_RE.match(str(review.get("file"))) + if match: + wave, cycle, lens = match.groups() + cycles.setdefault((int(wave), lens), {})[int(cycle)] = review.get("file") + return {name for found in cycles.values() + for cycle, name in found.items() if cycle < max(found)} + + def compute_attention(status: ProjectStatus, now: Optional[datetime] = None) -> List[dict]: items: List[dict] = [] @@ -690,7 +702,10 @@ def compute_attention(status: ProjectStatus, if task.status and "blocked" in task.status.lower(): label = f"{task.id} — {task.title}" if task.title else str(task.id) items.append({"kind": "blocked", "label": label, "ref": task.id}) + superseded = _superseded_reviews(status.reviews) for review in status.reviews: + if review.get("file") in superseded: + continue verdict = review.get("verdict") if isinstance(verdict, str) and verdict.lower() in NEGATIVE_REVIEW_VERDICTS: name = review.get("file") or "review" diff --git a/tests/test_daemon_attention.py b/tests/test_daemon_attention.py index 9077c3ff8..83040eb5b 100644 --- a/tests/test_daemon_attention.py +++ b/tests/test_daemon_attention.py @@ -234,6 +234,43 @@ def test_passing_reviews_are_not_attention(self) -> None: self.assertEqual(self.kinds(status), ["unverified"]) self.assertEqual(status.health, "amber") + def write_wave_reviews(self, *reviews: tuple) -> None: + review_dir = self.root / ".project" / "review" + review_dir.mkdir() + for wave, cycle, lens, verdict in reviews: + (review_dir / f"wave-{wave}.cycle{cycle}{lens}.md").write_bytes( + f"# Review — wave {wave}, cycle {cycle}\n\n" + f"Wave verdict: {verdict}\nCycle: {cycle}\n".encode("utf-8"), + ) + + def failed_refs(self) -> list: + status = probe.probe_project(self.root) + return [item["ref"] for item in status.attention if item["kind"] == "failed"] + + def test_blocked_cycle_superseded_by_a_passed_cycle_is_not_attention(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", "pass")) + self.assertEqual(self.failed_refs(), []) + # The earlier cycle stays in the review history. + files = [review["file"] for review in probe.probe_project(self.root).reviews] + self.assertEqual(files, ["wave-1.cycle1.md", "wave-1.cycle2.md"]) + + def test_blocked_newest_cycle_is_one_failed_row_per_wave(self) -> None: + make_project(self.root) + self.write_wave_reviews( + (1, 1, "", "blocked"), (1, 2, "", "blocked"), + (2, 1, "", "blocked"), (10, 1, "", "pass"), + ) + self.assertEqual(self.failed_refs(), ["wave-1.cycle2.md", "wave-2.cycle1.md"]) + + def test_deep_review_cycles_are_superseded_per_lens(self) -> None: + make_project(self.root) + self.write_wave_reviews( + (1, 1, ".contract", "blocked"), (1, 1, ".adversarial", "blocked"), + (1, 2, ".adversarial", "pass"), + ) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.contract.md"]) + def test_stale_active_project_amber(self) -> None: make_project(self.root) age_tree(self.root / ".project", seconds=3 * 86400) From a222036b4e83ae69d41f9cc5f12583585d82f0e1 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 10 Oct 2026 20:38:45 -0500 Subject: [PATCH 2/3] fix(daemon): keep a blocked review cycle until a newer cycle has a verdict A newer wave review file with no verdict line (a review that is still being written) replaced an older blocked cycle of the same wave and lens. The failed row disappeared and health could turn green. Only a review with a verdict now replaces an older cycle. A cycle with no verdict replaces nothing and adds no row. Tests: a newer cycle with no verdict keeps the blocked row and red health; cycle 10 is newer than cycle 9. Refs #356 Co-Authored-By: Claude Opus 5.5 --- daemon/gsd_daemon/probe.py | 7 +++++-- tests/test_daemon_attention.py | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/daemon/gsd_daemon/probe.py b/daemon/gsd_daemon/probe.py index 9033dab17..46cde6b24 100644 --- a/daemon/gsd_daemon/probe.py +++ b/daemon/gsd_daemon/probe.py @@ -678,11 +678,14 @@ def _human_age(seconds: float) -> str: def _superseded_reviews(reviews: List[dict]) -> set: - """Wave review files that a later cycle of the same wave and lens replaces.""" + """Wave review files that a later cycle of the same wave and lens replaces. + + A cycle with no verdict (a review still being written) replaces nothing. + """ cycles: Dict[tuple, Dict[int, object]] = {} for review in reviews: match = WAVE_REVIEW_FILE_RE.match(str(review.get("file"))) - if match: + if match and review.get("verdict"): wave, cycle, lens = match.groups() cycles.setdefault((int(wave), lens), {})[int(cycle)] = review.get("file") return {name for found in cycles.values() diff --git a/tests/test_daemon_attention.py b/tests/test_daemon_attention.py index 83040eb5b..10429d09d 100644 --- a/tests/test_daemon_attention.py +++ b/tests/test_daemon_attention.py @@ -239,8 +239,9 @@ def write_wave_reviews(self, *reviews: tuple) -> None: review_dir.mkdir() for wave, cycle, lens, verdict in reviews: (review_dir / f"wave-{wave}.cycle{cycle}{lens}.md").write_bytes( - f"# Review — wave {wave}, cycle {cycle}\n\n" - f"Wave verdict: {verdict}\nCycle: {cycle}\n".encode("utf-8"), + (f"# Review — wave {wave}, cycle {cycle}\n\n" + + (f"Wave verdict: {verdict}\n" if verdict else "") + + f"Cycle: {cycle}\n").encode("utf-8"), ) def failed_refs(self) -> list: @@ -271,6 +272,17 @@ def test_deep_review_cycles_are_superseded_per_lens(self) -> None: ) self.assertEqual(self.failed_refs(), ["wave-1.cycle1.contract.md"]) + def test_newer_cycle_without_a_verdict_keeps_the_blocked_cycle(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", None)) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.md"]) + self.assertEqual(probe.probe_project(self.root).health, "red") + + def test_review_cycles_compare_as_numbers(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 9, "", "blocked"), (1, 10, "", "pass")) + self.assertEqual(self.failed_refs(), []) + def test_stale_active_project_amber(self) -> None: make_project(self.root) age_tree(self.root / ".project", seconds=3 * 86400) From 0e75e8acd2d6b4c8479a8dd636a4a45aa2632b7e Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 10 Oct 2026 21:38:20 -0500 Subject: [PATCH 3/3] fix(daemon): a review cycle replaces an older one only with a valid wave verdict A review that is still being written can have `Wave verdict:` with no value. The verdict pattern then reads the first word of the next line (`Cycle`), so the unfinished cycle counted as a verdict, replaced the blocked cycle before it, and the failed row disappeared. A cycle now replaces an older one only when its verdict is `pass` or `blocked`, the two values the runtime accepts for a wave review. Refs #356 Co-Authored-By: Claude Opus 5.5 --- daemon/gsd_daemon/probe.py | 6 ++++-- tests/test_daemon_attention.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/daemon/gsd_daemon/probe.py b/daemon/gsd_daemon/probe.py index 46cde6b24..ef1d76ab0 100644 --- a/daemon/gsd_daemon/probe.py +++ b/daemon/gsd_daemon/probe.py @@ -50,6 +50,7 @@ STALE_AFTER_S = 12 * 3600 NEGATIVE_REVIEW_VERDICTS = {"blocked", "fail", "failed", "not-met", "unverifiable"} +WAVE_VERDICTS = {"pass", "blocked"} # the values the runtime accepts for a wave review NEGATIVE_CRITERION_VERDICTS = {"not-met", "unverifiable"} STATE_KEYS = ( @@ -680,12 +681,13 @@ def _human_age(seconds: float) -> str: def _superseded_reviews(reviews: List[dict]) -> set: """Wave review files that a later cycle of the same wave and lens replaces. - A cycle with no verdict (a review still being written) replaces nothing. + Only a cycle with a valid wave verdict replaces an older one: a review that + is still being written, with no verdict or an empty verdict line, does not. """ cycles: Dict[tuple, Dict[int, object]] = {} for review in reviews: match = WAVE_REVIEW_FILE_RE.match(str(review.get("file"))) - if match and review.get("verdict"): + if match and str(review.get("verdict")).lower() in WAVE_VERDICTS: wave, cycle, lens = match.groups() cycles.setdefault((int(wave), lens), {})[int(cycle)] = review.get("file") return {name for found in cycles.values() diff --git a/tests/test_daemon_attention.py b/tests/test_daemon_attention.py index 10429d09d..943fe9d74 100644 --- a/tests/test_daemon_attention.py +++ b/tests/test_daemon_attention.py @@ -278,6 +278,12 @@ def test_newer_cycle_without_a_verdict_keeps_the_blocked_cycle(self) -> None: self.assertEqual(self.failed_refs(), ["wave-1.cycle1.md"]) self.assertEqual(probe.probe_project(self.root).health, "red") + def test_newer_cycle_with_an_empty_verdict_line_keeps_the_blocked_cycle(self) -> None: + make_project(self.root) + # "Wave verdict:" with no value: the verdict pattern reads the next line's first word. + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", " ")) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.md"]) + def test_review_cycles_compare_as_numbers(self) -> None: make_project(self.root) self.write_wave_reviews((1, 9, "", "blocked"), (1, 10, "", "pass"))