diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 9ad670f..b549464 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -34,6 +34,7 @@ Checks: C-29 inbox-work-unpromoted WARN No Stale unread message looks like a multi-step work request without a workplan C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060) + C-32 work-record-not-indexed WARN Yes kind: intake/decision YAML block has no hub id — not indexed in DB (registration, CUST-WP-0061-T02) Usage: python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] @@ -779,11 +780,14 @@ _WORK_RECORD_ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+$") _WORK_RECORD_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history"}) -def _load_work_record_patterns() -> list[re.Pattern] | None: - """Load id patterns from the canon work-record type registry. +def _load_work_record_kind_registry() -> list[dict] | None: + """Load [{kind, patterns}] from the canon work-record type registry. Location: $WORK_RECORD_REGISTRY, else ~/the-custodian/canon/standards/ - work-record-types.yaml. Returns None (check skipped) when unavailable. + work-record-types.yaml. Returns None (checks skipped) when unavailable. + Kind-aware: shared by C-31 (flat sidetrack detection) and C-32 + (kind-aware registration — needs to know intake vs. decision vs. + engagement to pick the right hub entity). """ if not _HAS_YAML: return None @@ -798,17 +802,28 @@ def _load_work_record_patterns() -> list[re.Pattern] | None: continue try: reg = _yaml.safe_load(path.read_text()) - patterns = [] + kinds = [] for kind in reg.get("kinds", []): - patterns += [re.compile(p) for p in kind.get("id_patterns", [])] + patterns = [re.compile(p) for p in kind.get("id_patterns", [])] patterns += [re.compile(lp["pattern"]) for lp in kind.get("legacy_patterns", [])] - return patterns + kinds.append({"kind": kind["kind"], "patterns": patterns}) + return kinds except Exception: return None return None +def _load_work_record_patterns() -> list[re.Pattern] | None: + """Flat id-pattern list across all kinds — used by C-31's sidetrack + detector, which only needs to know "is this id registered at all", + not which kind it belongs to.""" + kinds = _load_work_record_kind_registry() + if kinds is None: + return None + return [p for k in kinds for p in k["patterns"]] + + def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport") -> None: """C-31: warn on YAML-block ids matching no registered work-record kind. @@ -849,6 +864,115 @@ def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport" ) +_YAML_ONLY_FENCE_RE = re.compile(r"```yaml\n(.*?)\n```", re.S) +_WORK_RECORD_ID_FIELD_BY_KIND = { + "intake": "state_hub_intake_id", + "decision": "state_hub_decision_id", + "engagement": "state_hub_engagement_id", +} +_WORK_RECORD_CREATE_ENDPOINT_BY_KIND = { + "intake": "/intakes", + "decision": "/decisions", +} + + +def _classify_work_record_kind(record_id: str, kind_registry: list[dict]) -> str | None: + for k in kind_registry: + if any(p.match(record_id) for p in k["patterns"]): + return k["kind"] + return None + + +def _inject_yaml_block_field( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject a field into the ```yaml``` block whose id == match_id. + Mirrors _inject_task_id_into_block, adapted for fenced yaml blocks + (intake/decision/engagement records live as ```yaml, not ```task).""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + meta = _parse_yaml_block(block_content.strip()) + if not isinstance(meta, dict) or str(meta.get("id", "")) != match_id: + return m.group(0) + existing_val = meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```yaml\n{new_content}\n```" + + new_text = _YAML_ONLY_FENCE_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _check_work_record_registration(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-32: register kind: intake / kind: decision YAML blocks found + anywhere in the repo against the hub (CUST-WP-0061-T02, work-record + stage 3) — writes state_hub_intake_id / state_hub_decision_id back into + the source block, same write-back pattern as C-06 for workplans. + + kind: engagement has no hub entity yet (deferred; reported INFO, not + fixable) — building one is a separate stage-3 follow-on, not this task. + """ + kind_registry = _load_work_record_kind_registry() + if kind_registry is None: + return + 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 + 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() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in _WORK_RECORD_ID_FIELD_BY_KIND: + continue + + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + existing = str(meta.get(id_field, "")).strip().strip('"') + if existing and existing not in ("~", "null", "None", "none"): + continue + + rel = md.relative_to(repo_dir) + if kind not in _WORK_RECORD_CREATE_ENDPOINT_BY_KIND: + report.add( + severity="INFO", check_id="C-32", + message=( + f"[{rel}] engagement '{rid}' has no hub entity yet — " + f"registration deferred (CUST-WP-0061 stage-3 seed)" + ), + fixable=False, + ) + continue + + report.add( + severity="WARN", check_id="C-32", + message=f"[{rel}] {kind} '{rid}' has no {id_field} — not indexed in DB", + file_path=str(rel), + file_value=rid, + fixable=True, + _fix_context={"md_path": md, "kind": kind, "rid": rid, "meta": meta}, + ) + + def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport: """Run all consistency checks for a registered repo.""" repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) @@ -915,6 +1039,9 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N # C-31: work-record sidetrack detector (canon work-record-types registry) _check_unregistered_work_records(repo_dir, report) + # C-32: intake/decision work-record registration (CUST-WP-0061-T02) + _check_work_record_registration(repo_dir, report) + # C-01: workplans/ directory missing if not workplans_dir.is_dir(): report.add( @@ -2588,6 +2715,77 @@ def fix_repo( f" ! task {t_id} not created: {t_data.get('_error', t_data)}" ) + elif issue.check_id == "C-32": + md_path = ctx["md_path"] + kind = ctx["kind"] + rid = ctx["rid"] + meta = ctx["meta"] + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + title = str(meta.get("title") or rid) + + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if not repo_record: + report.fixes_applied.append( + f"C-32 SKIP {rid}: could not look up repo '{repo_slug}'" + ) + continue + created = None + if kind == "intake": + payload = { + "title": title, + "repo_id": repo_record.get("id"), + "description": meta.get("description"), + "lane": meta.get("lane", "green"), + "origin": meta.get("origin"), + "origin_ref": meta.get("origin_ref"), + "source_repo_path": str(md_path.relative_to(Path(report.repo_path))), + } + created = _api_post(api_base, "/intakes", payload) + elif kind == "decision": + repo_market_domain = str(repo_record.get("domain_slug") or "").strip() + topic_domain = resolve_topic_domain_slug( + repo_market_domain, repo_market_domain=repo_market_domain or None + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-32 SKIP {rid}: no topic found for domain '{topic_domain}'" + ) + continue + payload = { + "title": title, + "topic_id": topic_id, + "decision_type": meta.get("decision_type", "pending"), + "description": meta.get("description"), + "rationale": meta.get("agent_recommendation") or meta.get("rationale"), + } + created = _api_post(api_base, "/decisions", payload) + + if not created or "_error" in created: + report.fixes_applied.append( + f"C-32 FAIL {rid}: could not create {kind} in DB: " + f"{(created or {}).get('_error', 'no response')}" + ) + continue + + new_id = created["id"] + if _inject_yaml_block_field(md_path, id_field, new_id, rid): + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid}, " + f"wrote {id_field} to {md_path.name}" + ) + else: + report.fixes_applied.append( + f"C-32 WARN {rid}: created {kind} {new_id[:8]}… but " + f"failed to write {id_field} back to {md_path.name}" + ) + elif issue.check_id == "C-09": ws_id = ctx["ws_id"] correct_repo_id = ctx["correct_repo_id"] diff --git a/tests/test_work_record_registration.py b/tests/test_work_record_registration.py new file mode 100644 index 0000000..5a03186 --- /dev/null +++ b/tests/test_work_record_registration.py @@ -0,0 +1,201 @@ +"""Unit tests for C-32 (work-record registration: intake/decision kinds, +CUST-WP-0061-T02) — detection and file write-back only. The live +create-in-DB path is covered by the fix_repo integration path, proven +manually against a real repo/API (see CUST-WP-0061 progress notes); these +tests stay offline and use a synthetic registry fixture, matching the +conventions in test_work_record_check.py. +""" +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from consistency_check import ( # noqa: E402 + ConsistencyReport, + _check_work_record_registration, + _classify_work_record_kind, + _inject_yaml_block_field, + _load_work_record_kind_registry, +) + +REGISTRY_YAML = textwrap.dedent( + """ + version: "0.1" + kinds: + - kind: intake + id_patterns: ['^[A-Z]+-IN-[0-9]{4}$'] + legacy_patterns: + - pattern: '^AWQ-[0-9]{3}$' + source: binky AutopilotWorkQueue + - kind: decision + id_patterns: ['^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$'] + legacy_patterns: + - pattern: '^DEC-[0-9]{4}-[0-9]{3}$' + source: binky DecisionQueue + - kind: engagement + id_patterns: ['^[A-Z]+-ENG-[0-9]{4}-[0-9]{3}$'] + legacy_patterns: + - pattern: '^OH-[0-9]{4}-[0-9]{3}$' + source: binky OfficeHourQueue + - kind: workplan + id_patterns: ['^[A-Z]+-WP-[0-9]{4}$'] + """ +) + + +@pytest.fixture +def registry_file(tmp_path, monkeypatch): + reg = tmp_path / "work-record-types.yaml" + reg.write_text(REGISTRY_YAML, encoding="utf-8") + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(reg)) + return reg + + +class TestClassifyWorkRecordKind: + def test_classifies_intake(self, registry_file): + kinds = _load_work_record_kind_registry() + assert _classify_work_record_kind("BINKY-IN-0001", kinds) == "intake" + + def test_classifies_legacy_awq(self, registry_file): + kinds = _load_work_record_kind_registry() + assert _classify_work_record_kind("AWQ-010", kinds) == "intake" + + def test_classifies_decision(self, registry_file): + kinds = _load_work_record_kind_registry() + assert _classify_work_record_kind("DEC-2026-004", kinds) == "decision" + + def test_unregistered_returns_none(self, registry_file): + kinds = _load_work_record_kind_registry() + assert _classify_work_record_kind("FOO-QX-001", kinds) is None + + +class TestCheckWorkRecordRegistration: + def _report(self, repo_dir: Path) -> ConsistencyReport: + report = ConsistencyReport(repo_slug="test", repo_path=str(repo_dir)) + _check_work_record_registration(repo_dir, report) + return report + + def test_intake_without_hub_id_is_flagged_fixable(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text( + "```yaml\nid: BINKY-IN-0001\ntitle: needs registration\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert len(report.issues) == 1 + issue = report.issues[0] + assert issue.check_id == "C-32" + assert issue.fixable is True + assert issue._fix_context["kind"] == "intake" + assert issue._fix_context["rid"] == "BINKY-IN-0001" + + def test_intake_with_hub_id_is_skipped(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text( + '```yaml\nid: BINKY-IN-0001\ntitle: already linked\n' + 'state_hub_intake_id: "019f8000-0000-7000-8000-000000000000"\n```\n', + encoding="utf-8", + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_decision_without_hub_id_is_flagged_fixable(self, tmp_path, registry_file): + (tmp_path / "DecisionQueue.md").write_text( + "```yaml\nid: DEC-2026-005\ntitle: a new decision\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert len(report.issues) == 1 + assert report.issues[0]._fix_context["kind"] == "decision" + + def test_engagement_without_hub_id_is_info_not_fixable(self, tmp_path, registry_file): + (tmp_path / "OfficeHourQueue.md").write_text( + "```yaml\nid: OH-2026-001\ntitle: bank call\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert len(report.issues) == 1 + assert report.issues[0].severity == "INFO" + assert report.issues[0].fixable is False + + def test_workplan_kind_is_untouched_by_c32(self, tmp_path, registry_file): + """Workplans keep their own C-06 registration path; C-32 must not + double-register them.""" + (tmp_path / "workplans" / "CUST-WP-0099-x.md").parent.mkdir(parents=True) + (tmp_path / "workplans" / "CUST-WP-0099-x.md").write_text( + "---\nid: CUST-WP-0099\n---\n```yaml\nid: CUST-WP-0099\ntitle: x\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_template_placeholder_ignored(self, tmp_path, registry_file): + (tmp_path / "AGENTS.md").write_text( + "```yaml\nid: BINKY-IN-NNNN\ntitle: template\n```\n", encoding="utf-8" + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_no_op_when_registry_unavailable(self, tmp_path, monkeypatch): + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(tmp_path / "missing.yaml")) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome") + (tmp_path / "queue.md").write_text( + "```yaml\nid: BINKY-IN-0001\ntitle: x\n```\n", encoding="utf-8" + ) + report = self._report(tmp_path) + assert report.issues == [] + + +class TestInjectYamlBlockField: + def test_injects_field_into_matching_block(self, tmp_path): + md = tmp_path / "queue.md" + md.write_text( + "```yaml\nid: BINKY-IN-0001\ntitle: needs registration\n```\n", + encoding="utf-8", + ) + changed = _inject_yaml_block_field( + md, "state_hub_intake_id", "019f8000-0000-7000-8000-000000000000", "BINKY-IN-0001" + ) + assert changed is True + text = md.read_text() + assert 'state_hub_intake_id: "019f8000-0000-7000-8000-000000000000"' in text + # round-trips as valid yaml + block = text.split("```yaml\n", 1)[1].rsplit("```", 1)[0] + parsed = yaml.safe_load(block) + assert parsed["state_hub_intake_id"] == "019f8000-0000-7000-8000-000000000000" + + def test_does_not_touch_other_blocks(self, tmp_path): + md = tmp_path / "queue.md" + md.write_text( + "```yaml\nid: BINKY-IN-0001\ntitle: first\n```\n" + "```yaml\nid: BINKY-IN-0002\ntitle: second\n```\n", + encoding="utf-8", + ) + _inject_yaml_block_field(md, "state_hub_intake_id", "abc-123", "BINKY-IN-0001") + text = md.read_text() + assert "abc-123" in text + second_block = text.split("BINKY-IN-0002")[1] + assert "state_hub_intake_id" not in second_block + + def test_no_op_when_id_not_found(self, tmp_path): + md = tmp_path / "queue.md" + original = "```yaml\nid: BINKY-IN-0001\ntitle: x\n```\n" + md.write_text(original, encoding="utf-8") + changed = _inject_yaml_block_field(md, "state_hub_intake_id", "abc-123", "BINKY-IN-9999") + assert changed is False + assert md.read_text() == original + + def test_idempotent_when_field_already_set(self, tmp_path): + md = tmp_path / "queue.md" + md.write_text( + '```yaml\nid: BINKY-IN-0001\nstate_hub_intake_id: "already-there"\n```\n', + encoding="utf-8", + ) + changed = _inject_yaml_block_field(md, "state_hub_intake_id", "new-value", "BINKY-IN-0001") + assert changed is False + assert "already-there" in md.read_text() + assert "new-value" not in md.read_text()