diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 3bc4ace..3818007 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -19,7 +19,7 @@ Checks: C-12 orphan-db-task WARN Yes DB task in workstream has no file backing unless terminal in a closed workstream C-13 workstream-auto-complete WARN Yes All DB tasks done but workstream still active C-14 ghost-duplicate WARN No Active topic workstream with no repo_id matches a file-backed title — probable ghost from premature create_workstream() call - C-15 task-db-ahead WARN Yes DB task status is ahead of file — regression prevented; writeback syncs file + C-15 task-db-ahead WARN No DB task is terminal and file is not — report only; file wins (ADR-001) C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB @@ -281,7 +281,10 @@ RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = ( "Workplan status is proposed, ready, or backlog while a linked " "task is progress or wait." ), - repair="Patch the DB workstream and workplan frontmatter to status=active.", + repair=( + "ready/backlog: patch DB and file to active. " + "proposed: report only (ADR-007 decision 3)." + ), test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization", ), ) @@ -1302,18 +1305,27 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N task_statuses=[*file_task_statuses, *db_task_statuses], ) if active_task_requires_activation: + # ADR-007 decision 3 / RMGR-WP-0005-T06: automation may report + # that a proposed workplan has started work, but must not promote + # proposed → active. proposed is a human review gate. + proposed_gate = file_status == "proposed" report.add( severity="WARN", check_id="C-23", message=( f"Lifecycle drift in '{ws.get('slug')}': workplan status " - f"{file_status!r} has an active task — repair to 'active'" + f"{file_status!r} has an active task" + + ( + " — report only (ADR-007: do not auto-promote proposed)" + if proposed_gate + else " — repair to 'active'" + ) ), file_path=fname, db_id=ws_id, file_value=file_status, db_value=ws.get("status", ""), - fixable=True, + fixable=not proposed_gate, _fix_context={ "ws_id": ws_id, "wp_file": str(wp_file), @@ -1544,48 +1556,45 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N fixable=False, ) continue - # C-10 / C-15: task status drift + # C-10 / C-15: task status drift. ADR-001: the file originates + # work. Same-rank wait/progress is not "DB ahead" — writing + # wait over progress/todo is the CFED/RMGR-WP-0004-T02 bug. + # C-15 remains a report-only review signal when the DB is + # terminal and the file is not. db_t_status = normalise_task_status(db_task.get("status", "todo")) if t_status and db_t_status and t_status != db_t_status: db_rank = STATUS_ORDER.get(db_t_status, 0) file_rank = STATUS_ORDER.get(t_status, 0) - if db_rank >= file_rank: - # C-15: DB is already at the same rank or ahead — prevent - # regression. Writeback syncs the file to match DB. + db_terminal = db_t_status in {"done", "cancel"} + file_terminal = t_status in {"done", "cancel"} + if db_terminal and not file_terminal: report.add( severity="WARN", check_id="C-15", message=( - f"DB task '{t_id}' is ahead of file " + f"DB task '{t_id}' is terminal " f"(db={db_t_status!r}, file={t_status!r}) " - f"— regression prevented; writeback will sync file" + f"— report only; file wins (ADR-001 / RMGR-WP-0005-T06)" ), file_path=f"{fname}#{t_id}", db_id=t_sh_id, file_value=t_status, db_value=db_t_status, - fixable=True, - _fix_context={ - "task_id": t_sh_id, - "wp_file": str(wp_file), - "task_block_id": t_id, - "db_status": db_t_status, - }, - ) - else: - # C-10: file is ahead — apply file→DB sync (normal drift) - report.add( - severity="WARN", check_id="C-10", - message=( - f"Task status drift '{t_id}': " - f"file={t_status!r} db={db_t_status!r} (file wins)" - ), - file_path=f"{fname}#{t_id}", - db_id=t_sh_id, - file_value=t_status, - db_value=db_t_status, - fixable=True, - _fix_context={"task_id": t_sh_id, "status": t_status}, + fixable=False, ) + # File always wins, including same-rank wait ↔ progress. + report.add( + severity="WARN", check_id="C-10", + message=( + f"Task status drift '{t_id}': " + f"file={t_status!r} db={db_t_status!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=True, + _fix_context={"task_id": t_sh_id, "status": t_status}, + ) file_description = task.get("description") if isinstance(file_description, str): file_description = file_description.strip() or None diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 6a8cccd..6916a58 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -993,10 +993,10 @@ class TestLifecycleRenormalization: assert "C-23" in check_ids assert "C-04" not in check_ids issue = next(issue for issue in report.issues if issue.check_id == "C-23") - assert issue.fixable is True + assert issue.fixable is False assert issue.file_value == "proposed" - def test_fix_repo_repairs_planning_workplan_with_active_task(self, tmp_path, monkeypatch): + def test_fix_repo_does_not_promote_proposed(self, tmp_path, monkeypatch): repo = self._make_repo(tmp_path) wp = repo / "workplans" / "STATE-WP-0001-demo.md" patches = [] @@ -1015,6 +1015,29 @@ class TestLifecycleRenormalization: report = fix_repo("http://unused", "state-hub") + assert ("/workplans/ws-1", {"status": "active"}) not in patches + assert "status: proposed" in wp.read_text(encoding="utf-8") + assert not any("C-23 fixed" in fix for fix in report.fixes_applied) + + def test_fix_repo_repairs_planning_workplan_with_active_task(self, tmp_path, monkeypatch): + repo = self._make_repo(tmp_path, status="ready") + wp = repo / "workplans" / "STATE-WP-0001-demo.md" + patches = [] + + def fake_patch(_api_base, path, body): + patches.append((path, body)) + return {"ok": True} + + monkeypatch.setattr("consistency_check._api_get", self._api_get_for_repo(repo)) + monkeypatch.setattr("consistency_check._api_patch", fake_patch) + monkeypatch.setattr("consistency_check._detect_behind_remote", lambda _repo_path: False) + monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0) + monkeypatch.setattr("consistency_check._git_commit_writeback", lambda *args, **kwargs: True) + monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False) + monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed")) + + report = fix_repo("http://unused", "state-hub") + assert ("/workplans/ws-1", {"status": "active"}) in patches assert "status: active" in wp.read_text(encoding="utf-8") assert any("C-23 fixed" in fix for fix in report.fixes_applied)