From 00c6e7a6ae2caaa8dbd5aeef03748a15ffc6be54 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 28 Sep 2026 22:39:18 +0200 Subject: [PATCH] CUST-WP-0074-T02: C-39/C-40/C-41 and task-block depends_on in C-20 consistency_check.py: - C-20 now indexes each ```task block's depends_on (workplan ids and task ids) as workplan_dependencies rows with from_task_id set, description " depends_on ". Targets may live in other repos: resolved by file mapping first, ADR-007 derived uuid second; unresolvable targets stay a non-fixable C-20 as before. Frontmatter edges are unchanged (keyed with from_task_id None). - C-39 task-wait-unqualified (WARN, not fixable): wait task with neither task-block depends_on nor needs_human: true. Skipped in closed workplans. - C-40 task-wait-blocker-satisfied (WARN, not fixable): every depends_on target terminal (tasks done/cancel, workplans finished/archived), or decision_id resolved in the hub. A target or decision the hub cannot resolve is skipped and withholds the warning; decision_id resolves via a uuid, the repo's state_hub_decision_id writeback, or the derived id. - C-41 task-wait-qualifier-drift (WARN, fixable): needs_human, blocking_reason, decision_id file -> hub via PATCH /tasks/{id}, only for blocks that carry at least one of the keys so hub-set flags on silent blocks are left alone. The same fields ride along on the C-10 status PATCH for wait tasks and on the C-06/C-11 task creates. When needs_human is set and the block has no intervention_note, the blocking_reason stands in (TaskCreate/Update require the note). Co-Authored-By: Claude Fable 5.1 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 237582@bnt-lap001 Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826 --- scripts/consistency_check.py | 262 +++++++++++++++++++++++++- tests/test_consistency_check.py | 324 ++++++++++++++++++++++++++++++++ 2 files changed, 580 insertions(+), 6 deletions(-) diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index a2078c1..11dc4e1 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -42,6 +42,10 @@ Checks: C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins) + C-39 task-wait-unqualified WARN No wait task has neither task-block depends_on nor needs_human: true (CUST-WP-0074 rule 1) + C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, or whose decision_id is resolved (CUST-WP-0074 rules 3/4) + C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id differ between file and DB (file wins) + (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) Usage: @@ -549,6 +553,70 @@ def _frontmatter_depends_on_tasks(meta: dict) -> list[str]: return _dedupe_preserve(from_alias + from_depends_on) +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"true", "yes", "1", "on"} + + +def _task_wait_fields(task: dict) -> dict[str, Any]: + """File → hub fields that qualify a wait (CUST-WP-0074): needs_human, + blocking_reason, decision_id. TaskCreate/TaskUpdate require an + intervention_note whenever needs_human is set; the blocking reason is by + rule 2 what the human must do, so it stands in when the block has none.""" + needs_human = _as_bool(task.get("needs_human")) + blocking_reason = str(task.get("blocking_reason") or "").strip() or None + decision_id = str(task.get("decision_id") or "").strip().strip('"') or None + fields: dict[str, Any] = { + "needs_human": needs_human, + "blocking_reason": blocking_reason, + "decision_id": decision_id, + } + if needs_human: + fields["intervention_note"] = ( + str(task.get("intervention_note") or "").strip() or blocking_reason + ) + return fields + + +_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id") + + +def _dependency_target_satisfied(record_id: str, status: str) -> bool: + """Rule 3: task targets are satisfied at done/cancel, workplan targets at + finished/archived.""" + if _TASK_RECORD_ID_RE.search(record_id): + return normalise_task_status(status, default="") in {"done", "cancel"} + return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES + + +def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]: + """Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote + back into their YAML block.""" + out: dict[str, str] = {} + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + if "state_hub_decision_id" not in text: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"') + if rid and hub_id and hub_id not in ("~", "null", "None", "none"): + out[rid] = hub_id + return out + + def _as_int_or_none(value: Any) -> int | None: if value in (None, "", "~", "null", "None", "none"): return None @@ -1421,6 +1489,46 @@ def check_repo( snapshot_dependencies_by_workplan.setdefault( str(row["from_workplan_id"]), [] ).append(row) + snapshot_tasks_by_id: dict[str, dict] = { + str(row["id"]): row + for rows in snapshot_tasks_by_workplan.values() + for row in rows + if row.get("id") + } + + # Task-block depends_on targets (CUST-WP-0074) may live in other repos, so + # resolve them by the file mapping first and the ADR-007 derived uuid second. + dependency_target_cache: dict[str, tuple[str | None, str | None]] = {} + + def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]: + """Return (hub uuid, hub status) for a workplan/task record id, or (None, None).""" + if record_id in dependency_target_cache: + return dependency_target_cache[record_id] + is_task = bool(_TASK_RECORD_ID_RE.search(record_id)) + mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id) + hub_id = mapped or _derived_work_record_uuid(record_id) + row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id) + if row is None: + row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}") + result: tuple[str | None, str | None] = ( + (hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None) + ) + dependency_target_cache[record_id] = result + return result + + decision_hub_ids: dict[str, str] | None = None + + def _resolve_decision(decision_id: str) -> dict | None: + nonlocal decision_hub_ids + if decision_hub_ids is None: + decision_hub_ids = _repo_decision_hub_ids(repo_dir) + hub_id = ( + decision_id + if _UUID_RE.match(decision_id) + else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id) + ) + row = _api_get(api_base, f"/decisions/{hub_id}") + return row if isinstance(row, dict) else None # Per-workplan checks for wp_file, meta, body in workplan_infos: @@ -1802,11 +1910,13 @@ def check_repo( if from_id != ws_id: continue rel = dep.get("relationship_type") or "blocks" + # Frontmatter edges carry no from_task_id; task-block edges do. + from_task_id = dep.get("from_task_id") or None to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id") if to_workplan_id: - existing_dep_keys.add(("workstream", to_workplan_id, rel)) + existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel)) if dep.get("to_task_id"): - existing_dep_keys.add(("task", dep["to_task_id"], rel)) + existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel)) for target_wp_id in _frontmatter_depends_on_workplans(meta): target_ws_id = workplan_id_to_ws_id.get(target_wp_id) @@ -1820,7 +1930,7 @@ def check_repo( fixable=False, ) continue - dep_key = ("workstream", target_ws_id, "blocks") + dep_key = (None, "workstream", target_ws_id, "blocks") if dep_key not in existing_dep_keys: report.add( severity="WARN", @@ -1849,7 +1959,7 @@ def check_repo( fixable=False, ) continue - dep_key = ("task", target_sh_id, "starts_after") + dep_key = (None, "task", target_sh_id, "starts_after") if dep_key not in existing_dep_keys: report.add( severity="WARN", @@ -1878,6 +1988,99 @@ def check_repo( t_sh_id = "" t_status = normalise_task_status(task.get("status", "todo")) + # CUST-WP-0074: task-block depends_on and wait qualifiers. + task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task) + wait_fields = _task_wait_fields(task) + if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + if not task_dep_targets and not wait_fields["needs_human"]: + report.add( + severity="WARN", check_id="C-39", + message=( + f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has " + f"neither depends_on nor needs_human: true — unqualified wait" + ), + file_path=f"{fname}#{t_id}", + file_value=t_status, + fixable=False, + ) + # C-40: rule 3 — every depends_on target terminal. A target the + # hub cannot resolve is skipped, which also withholds the warning. + resolved_targets = [ + (target, _resolve_dependency_target(target)) for target in task_dep_targets + ] + if resolved_targets and all( + hub_status is not None + and _dependency_target_satisfied(target, hub_status) + for target, (_hub_id, hub_status) in resolved_targets + ): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — every depends_on target is terminal " + f"({', '.join(task_dep_targets)})" + ), + file_path=f"{fname}#{t_id}", + file_value=", ".join(task_dep_targets), + fixable=False, + ) + # C-40: rule 4 — the tracked decision has been resolved. + if wait_fields["decision_id"]: + decision = _resolve_decision(wait_fields["decision_id"]) + if decision is not None and str(decision.get("status") or "") == "resolved": + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — decision " + f"{wait_fields['decision_id']} is resolved" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_fields["decision_id"], + fixable=False, + ) + + if t_sh_id and task_dep_targets: + # C-20 for task blocks: the waiting task is the from side. + for target in task_dep_targets: + target_hub_id, _target_status = _resolve_dependency_target(target) + is_task_target = bool(_TASK_RECORD_ID_RE.search(target)) + if not target_hub_id: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"{'Task' if is_task_target else 'Workplan'} dependency target " + f"'{target}' of task '{t_id}' is not linked to State Hub" + ), + file_path=f"{fname}#{t_id}", + file_value=target, + fixable=False, + ) + continue + rel = "starts_after" if is_task_target else "blocks" + dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel) + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"Missing DB dependency edge: task '{t_id}' " + f"{'starts after task' if is_task_target else 'depends on workplan'} {target}" + ), + file_path=f"{fname}#{t_id}", + db_id=ws_id, + file_value=target, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "from_task_id": t_sh_id, + "to_task_id" if is_task_target else "to_workstream_id": target_hub_id, + "relationship_type": rel, + "description": f"{t_id} depends_on {target}", + }, + ) + if t_sh_id: file_task_sh_ids.add(t_sh_id) db_task = db_tasks_by_id.get(t_sh_id) @@ -1946,7 +2149,12 @@ def check_repo( file_value=t_status, db_value=db_t_status, fixable=True, - _fix_context={"task_id": t_sh_id, "status": t_status}, + _fix_context={ + "task_id": t_sh_id, + "status": t_status, + # A wait needs its qualifiers in the same PATCH. + "wait_fields": wait_fields if t_status == "wait" else {}, + }, ) file_description = task.get("description") if isinstance(file_description, str): @@ -1999,6 +2207,29 @@ def check_repo( fixable=True, _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, ) + # C-41: wait qualifiers — only where the file speaks, so hub-set + # flags on blocks that never carried the keys are left alone. + if any(key in task for key in _TASK_WAIT_FIELD_KEYS): + db_wait_fields = { + "needs_human": bool(db_task.get("needs_human")), + "blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None, + "decision_id": str(db_task.get("decision_id") or "").strip() or None, + } + file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS} + if file_wait_fields != db_wait_fields: + report.add( + severity="WARN", check_id="C-41", + message=( + f"Task wait-qualifier drift '{t_id}': " + f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=json.dumps(file_wait_fields, sort_keys=True), + db_value=json.dumps(db_wait_fields, sort_keys=True), + fixable=True, + _fix_context={"task_id": t_sh_id, "wait_fields": wait_fields}, + ) elif t_id: # C-11: task exists in file but not linked to DB ws_status = ws.get("status", "") @@ -3290,6 +3521,7 @@ def fix_repo( "priority": t_priority, "assignee": task.get("assignee") or None, "flavor": task_flavor, + **_task_wait_fields(task), }) if t_data and "_error" not in t_data: t_db_id = t_data["id"] @@ -3419,6 +3651,9 @@ def fix_repo( "to_task_id": ctx.get("to_task_id"), "relationship_type": ctx["relationship_type"], } + if ctx.get("from_task_id"): + body["from_task_id"] = ctx["from_task_id"] + body["description"] = ctx.get("description") result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) if result is not None and "_error" not in result: target = ctx.get("to_workstream_id") or ctx.get("to_task_id") @@ -3435,7 +3670,8 @@ def fix_repo( task_id = ctx["task_id"] status = ctx["status"] result = _api_patch(api_base, f"/tasks/{task_id}", - {"status": status, "suppress_token_event": True}) + {"status": status, "suppress_token_event": True, + **ctx.get("wait_fields", {})}) if result is not None and "_error" not in result: report.fixes_applied.append( f"C-10 fixed: task {task_id[:8]}… status → {status!r}" @@ -3475,6 +3711,7 @@ def fix_repo( "status": t_status, "priority": t_priority, "assignee": task.get("assignee") or None, + **_task_wait_fields(task), }) if t_data: t_db_id = t_data["id"] @@ -3528,6 +3765,19 @@ def fix_repo( f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" ) + elif issue.check_id == "C-41": + task_id = ctx["task_id"] + wait_fields = ctx["wait_fields"] + result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced" + ) + elif result is not None: + report.fixes_applied.append( + f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}" + ) + elif issue.check_id == "C-15": # T03 — writeback: DB is ahead of file — patch file to match DB. if no_writeback: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 7eeb439..46f990b 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -2111,3 +2111,327 @@ class TestCoordinationHygieneHelpers: result = _api_get("http://example", "/workstreams/ws-1") assert result == {"id": "ws-1"} assert attempts["count"] == 2 + + +class TestTaskWaitQualifiers: + """CUST-WP-0074-T02: C-39 / C-40 / C-41 plus task-block depends_on in C-20.""" + + WORKPLAN_HEAD = ( + "---\n" + "id: STATE-WP-0001\n" + "title: Waiting\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: blocked\n" + "state_hub_workstream_id: \"wait-ws\"\n" + "---\n\n" + "## Wait on something\n\n" + ) + + def _make_repo(self, tmp_path, task_block: str, extra_files: dict[str, str] | None = None): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + (workplans / "STATE-WP-0001-waiting.md").write_text( + self.WORKPLAN_HEAD + "```task\n" + task_block + "\n```\n\nBody text.\n", + encoding="utf-8", + ) + for name, text in (extra_files or {}).items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return repo + + def _fake_get(self, repo, *, db_task=None, targets=None, deps=None): + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + import socket + + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {socket.gethostname(): str(repo)}, + "domain_slug": "financials", + } + if path == "/workplans/wait-ws": + return { + "id": "wait-ws", "repo_id": "repo-1", "slug": "state-wp-0001", + "title": "Waiting", "status": "blocked", + } + if path == "/tasks" and params and params.get("workplan_id") == "wait-ws": + return [db_task] if db_task else [] + if path == "/workplans/wait-ws/dependencies": + return deps or [] + if targets and path in targets: + return targets[path] + return [] + + return fake_get + + def _install(self, monkeypatch, fake_get): + monkeypatch.setattr("consistency_check.load_classification_file", lambda _repo_dir: ({}, [], [])) + monkeypatch.setattr("consistency_check._api_get", fake_get) + + @staticmethod + def _issues(report, check_id): + return [issue for issue in report.issues if issue.check_id == check_id] + + def test_wait_without_qualifier_warns_c39(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nblocking_reason: \"someone else first\"", + ) + self._install(monkeypatch, self._fake_get(repo, db_task={"id": "task-1", "status": "wait"})) + + report = check_repo("http://unused", "demo-repo") + + c39 = self._issues(report, "C-39") + assert len(c39) == 1 + assert c39[0].severity == "WARN" + assert c39[0].fixable is False + assert "STATE-WP-0001-T01" in c39[0].message + assert "STATE-WP-0001" in c39[0].message + assert self._issues(report, "C-40") == [] + + def test_needs_human_or_depends_on_qualifies_the_wait(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nneeds_human: true\n" + "blocking_reason: \"operator must provision creds\"", + ) + self._install(monkeypatch, self._fake_get(repo)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-39") == [] + + repo = self._make_repo( + tmp_path / "second", + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\ndepends_on: [FLEX-WP-0020]\n" + "blocking_reason: \"access-engine rename must land first\"", + ) + self._install(monkeypatch, self._fake_get(repo)) + report = check_repo("http://unused", "demo-repo") + assert self._issues(report, "C-39") == [] + # Unresolvable target: C-20 reports it unlinked, C-40 stays silent. + assert self._issues(report, "C-40") == [] + c20 = self._issues(report, "C-20") + assert len(c20) == 1 and c20[0].fixable is False + assert "FLEX-WP-0020" in c20[0].message + + def test_task_block_depends_on_indexes_into_c20_with_from_task(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\n" + "depends_on:\n - FLEX-WP-0020\n - KEY-WP-0035-T02\n" + "blocking_reason: \"access-engine rename must land first\"", + ) + flex_id = _derived_work_record_uuid("FLEX-WP-0020") + key_task_id = _derived_work_record_uuid("KEY-WP-0035-T02") + targets = { + f"/workplans/{flex_id}": {"id": flex_id, "status": "active"}, + f"/tasks/{key_task_id}": {"id": key_task_id, "status": "progress"}, + } + self._install(monkeypatch, self._fake_get(repo, targets=targets)) + + report = check_repo("http://unused", "demo-repo") + + c20 = self._issues(report, "C-20") + assert len(c20) == 2 + assert all(issue.fixable for issue in c20) + contexts = [issue._fix_context for issue in c20] + assert { + "from_workstream_id": "wait-ws", + "from_task_id": "task-1", + "to_workstream_id": flex_id, + "relationship_type": "blocks", + "description": "STATE-WP-0001-T01 depends_on FLEX-WP-0020", + } in contexts + assert { + "from_workstream_id": "wait-ws", + "from_task_id": "task-1", + "to_task_id": key_task_id, + "relationship_type": "starts_after", + "description": "STATE-WP-0001-T01 depends_on KEY-WP-0035-T02", + } in contexts + assert self._issues(report, "C-40") == [] + + # Existing rows with the task as the from side satisfy the check; a + # frontmatter row (no from_task_id) for the same target does not. + deps = [ + {"id": "d1", "from_workplan_id": "wait-ws", "from_task_id": "task-1", + "to_workplan_id": flex_id, "to_task_id": None, "relationship_type": "blocks"}, + {"id": "d2", "from_workplan_id": "wait-ws", "from_task_id": None, + "to_workplan_id": None, "to_task_id": key_task_id, "relationship_type": "starts_after"}, + ] + self._install(monkeypatch, self._fake_get(repo, targets=targets, deps=deps)) + report = check_repo("http://unused", "demo-repo") + c20 = self._issues(report, "C-20") + assert len(c20) == 1 + assert c20[0]._fix_context["to_task_id"] == key_task_id + + def test_all_terminal_targets_warn_c40(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\n" + "depends_on: [FLEX-WP-0020, KEY-WP-0035-T02]\n" + "blocking_reason: \"access-engine rename must land first\"", + ) + flex_id = _derived_work_record_uuid("FLEX-WP-0020") + key_task_id = _derived_work_record_uuid("KEY-WP-0035-T02") + targets = { + f"/workplans/{flex_id}": {"id": flex_id, "status": "finished"}, + f"/tasks/{key_task_id}": {"id": key_task_id, "status": "done"}, + } + self._install(monkeypatch, self._fake_get(repo, targets=targets)) + + report = check_repo("http://unused", "demo-repo") + + c40 = self._issues(report, "C-40") + assert len(c40) == 1 + assert c40[0].severity == "WARN" and c40[0].fixable is False + assert c40[0].message.startswith("blocker satisfied, task still wait") + assert "STATE-WP-0001-T01" in c40[0].message + + # One target still open → no C-40. + targets[f"/tasks/{key_task_id}"] = {"id": key_task_id, "status": "progress"} + self._install(monkeypatch, self._fake_get(repo, targets=targets)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == [] + + # One target the hub cannot resolve → skipped, no C-40 either. + del targets[f"/tasks/{key_task_id}"] + self._install(monkeypatch, self._fake_get(repo, targets=targets)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == [] + + def test_resolved_decision_warns_c40(self, tmp_path, monkeypatch): + decision_doc = ( + "# Decisions\n\n```yaml\nid: CCR-2026-0004\nkind: decision\n" + "state_hub_decision_id: \"dec-1\"\n```\n" + ) + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nneeds_human: true\n" + "decision_id: CCR-2026-0004\n" + "blocking_reason: \"operator must provision creds\"", + extra_files={"docs/decisions.md": decision_doc}, + ) + self._install( + monkeypatch, + self._fake_get(repo, targets={"/decisions/dec-1": {"id": "dec-1", "status": "resolved"}}), + ) + + report = check_repo("http://unused", "demo-repo") + + c40 = self._issues(report, "C-40") + assert len(c40) == 1 and c40[0].fixable is False + assert "CCR-2026-0004" in c40[0].message + + self._install( + monkeypatch, + self._fake_get(repo, targets={"/decisions/dec-1": {"id": "dec-1", "status": "open"}}), + ) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == [] + + # Decision unknown to the hub → skipped silently. + self._install(monkeypatch, self._fake_get(repo)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == [] + + def test_wait_qualifier_drift_c41_is_fixable(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nneeds_human: true\n" + "decision_id: CCR-2026-0004\n" + "blocking_reason: \"operator must provision creds\"", + ) + db_task = { + "id": "task-1", "status": "wait", "needs_human": False, + "blocking_reason": None, "decision_id": None, + } + self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) + + report = check_repo("http://unused", "demo-repo") + + c41 = self._issues(report, "C-41") + assert len(c41) == 1 and c41[0].fixable is True + assert c41[0]._fix_context == { + "task_id": "task-1", + "wait_fields": { + "needs_human": True, + "blocking_reason": "operator must provision creds", + "decision_id": "CCR-2026-0004", + "intervention_note": "operator must provision creds", + }, + } + + db_task.update(needs_human=True, blocking_reason="operator must provision creds", + decision_id="CCR-2026-0004") + self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == [] + + def test_c41_ignores_blocks_that_do_not_carry_the_keys(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: todo\npriority: high\nstate_hub_task_id: \"task-1\"", + ) + db_task = {"id": "task-1", "status": "todo", "needs_human": True, + "blocking_reason": "set via API", "decision_id": None} + self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == [] + + def test_fix_repo_writes_from_task_edge_and_wait_fields(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\ndepends_on: [FLEX-WP-0020]\n" + "blocking_reason: \"access-engine rename must land first\"", + ) + flex_id = _derived_work_record_uuid("FLEX-WP-0020") + db_task = {"id": "task-1", "status": "todo", "needs_human": False, + "blocking_reason": None, "decision_id": None} + self._install( + monkeypatch, + self._fake_get(repo, db_task=db_task, + targets={f"/workplans/{flex_id}": {"id": flex_id, "status": "active"}}), + ) + posts, patches = [], [] + monkeypatch.setattr( + "consistency_check._api_post", + lambda _api_base, path, body: posts.append((path, body)) or {"id": "dep-new"}, + ) + monkeypatch.setattr( + "consistency_check._api_patch", + lambda _api_base, path, body: patches.append((path, body)) or {"ok": True}, + ) + 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", "demo-repo") + + assert ( + "/workplans/wait-ws/dependencies", + { + "to_workstream_id": flex_id, + "to_task_id": None, + "relationship_type": "blocks", + "from_task_id": "task-1", + "description": "STATE-WP-0001-T01 depends_on FLEX-WP-0020", + }, + ) in posts + # C-10 carries the wait qualifiers in the same PATCH; C-41 syncs them too. + wait_fields = { + "needs_human": False, + "blocking_reason": "access-engine rename must land first", + "decision_id": None, + } + assert ("/tasks/task-1", {"status": "wait", "suppress_token_event": True, **wait_fields}) in patches + assert ("/tasks/task-1", wait_fields) in patches + assert any("C-20 fixed" in fix for fix in report.fixes_applied) + assert any("C-41 fixed" in fix for fix in report.fixes_applied)