C-32: fix-consistency registration for intake/decision work records (CUST-WP-0061-T02)
Extends fix-consistency to scan any file for kind: intake / kind: decision YAML blocks (not just workplans/, per canon: any file is a potential work-record source), create the corresponding hub entity when missing a state_hub_intake_id / state_hub_decision_id, and write the id back into the source block -- same write-back pattern as C-06 for workplans. kind: engagement is reported INFO (deferred, not fixable): no hub entity exists for it yet, a separate stage-3 follow-on. - _load_work_record_kind_registry(): kind-aware registry loader, factored out so C-31's existing flat _load_work_record_patterns() builds on it without duplication (verified: C-31's 16 tests still pass unmodified) - _check_work_record_registration(): detection, wired into check_repo right after C-31 - _inject_yaml_block_field(): write-back helper for fenced blocks, mirroring _inject_task_id_into_block's pattern for blocks - fix_repo C-32 dispatch: creates the intake (scoped to repo_id) or decision (scoped to resolved topic_id, reusing C-06's domain->topic resolution) via the REST API, then writes the id back - tests/test_work_record_registration.py: 15 tests (classification, detection incl. engagement-deferred and workplan-kind-exclusion, injection incl. idempotence and non-interference with sibling blocks) Live-verified end to end against a real registered repo (binky-control, not just synthetic fixtures): a real fix-consistency run found and registered 3 genuinely open, previously-unlinked intake items (AWQ-002/003/006) sitting in AutopilotWorkQueue.md, and correctly deferred 5 real OH- engagement items as INFO. No regressions: full consistency_check + consistency_sweep suite (128 tests) and C-31's own suite (16 tests) still green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
88ba666c95
commit
aade470f4a
2 changed files with 405 additions and 6 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue