C-33: generated per-repo work-record index (CUST-WP-0061-T04)
First cut of the transclusion-style index named in the stage-3 seed: a WORK-RECORDS.md at repo root listing kind/id/status/lane/source for every workplan, task, intake, decision, and engagement found in the repo -- derived purely from files (no hub query), matching the ADR-001 rebuild principle. Full markitect transclusion rendering is a follow-on, not this first cut. - _generate_work_record_index(): reuses iter_workplan_files/ parse_frontmatter/get_tasks_from_workplan for workplan+task rows, and the repo-wide yaml-block scan already proven by C-31/C-32 for intake/decision/engagement rows. Archived workplans excluded (index is for current orientation, not history); closed decisions/intakes/ engagements stay listed. Returns None (no file written) for repos with zero work records, to avoid clutter. - _check_work_record_index_freshness(): C-33, WARN+fixable when WORK-RECORDS.md is missing or its content differs from a fresh regeneration. - fix_repo C-33 dispatch: overwrites the file with the regenerated content. 13 new tests (generation across all kinds, sort order, archived exclusion, self-scan exclusion, freshness detection incl. idempotence). No regressions: full repo suite green (561 tests). Live-verified at two scales: binky-control (5 workplans, 31 tasks, 3 intake, 4 decisions, 5 engagements) and the-custodian (52 workplans, 332 tasks) -- both generated correctly on first run, confirmed idempotent (identical second run produces no C-33 fix), and the larger repo's scan added no material overhead to the existing ~1.5min fix-consistency run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3dbbc753bc
commit
b564ac7046
2 changed files with 307 additions and 0 deletions
|
|
@ -35,6 +35,7 @@ Checks:
|
|||
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)
|
||||
C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04)
|
||||
|
||||
Usage:
|
||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
||||
|
|
@ -973,6 +974,116 @@ def _check_work_record_registration(repo_dir: Path, report: "ConsistencyReport")
|
|||
)
|
||||
|
||||
|
||||
_WORK_RECORD_INDEX_NAME = "WORK-RECORDS.md"
|
||||
_WORK_RECORD_INDEX_KIND_ORDER = {
|
||||
"workplan": 0, "task": 1, "intake": 2, "decision": 3, "engagement": 4,
|
||||
}
|
||||
|
||||
|
||||
def _generate_work_record_index(repo_dir: Path, repo_slug: str) -> str | None:
|
||||
"""CUST-WP-0061-T04: the generated per-repo work-record index — every
|
||||
registered work record (workplan, task, intake, decision, engagement)
|
||||
with kind/id/status/lane/source, derivable purely from repo files (no
|
||||
hub query — matches the ADR-001 rebuild principle). Full transclusion
|
||||
rendering (markitect integration) is a follow-on; this is the flat
|
||||
first cut. Returns None (no file written) when the repo has no work
|
||||
records at all, to avoid clutter in small repos.
|
||||
"""
|
||||
rows: list[tuple[str, str, str, str, str]] = [] # kind, id, status, lane, source
|
||||
|
||||
workplans_dir = repo_dir / "workplans"
|
||||
if workplans_dir.is_dir():
|
||||
for wp_file in iter_workplan_files(workplans_dir, include_archived=False):
|
||||
try:
|
||||
text = wp_file.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
meta, body = parse_frontmatter(text)
|
||||
wp_id = str(meta.get("id", "")).strip()
|
||||
if not wp_id:
|
||||
continue
|
||||
source = str(wp_file.relative_to(repo_dir))
|
||||
rows.append(("workplan", wp_id, str(meta.get("status", "")).strip() or "—", "—", source))
|
||||
for task in get_tasks_from_workplan(meta, body):
|
||||
if task.get("_parse_error"):
|
||||
continue
|
||||
t_id = str(task.get("id", "")).strip()
|
||||
if not t_id:
|
||||
continue
|
||||
rows.append(("task", t_id, str(task.get("status", "")).strip() or "—", "—", source))
|
||||
|
||||
kind_registry = _load_work_record_kind_registry()
|
||||
if kind_registry:
|
||||
for md in sorted(repo_dir.rglob("*.md")):
|
||||
if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts):
|
||||
continue
|
||||
if md.name == _WORK_RECORD_INDEX_NAME:
|
||||
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 ("intake", "decision", "engagement"):
|
||||
continue
|
||||
status = str(meta.get("status", "")).strip() or "—"
|
||||
lane = str(meta.get("lane", "")).strip() or "—"
|
||||
source = str(md.relative_to(repo_dir))
|
||||
rows.append((kind, rid, status, lane, source))
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
rows.sort(key=lambda r: (_WORK_RECORD_INDEX_KIND_ORDER.get(r[0], 99), r[4], r[1]))
|
||||
|
||||
lines = [
|
||||
f"# Work Records — {repo_slug}",
|
||||
"",
|
||||
"> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record",
|
||||
"> stage 3). Do not edit by hand — edit the source file/block listed for",
|
||||
"> each record and re-run fix-consistency to refresh this index. Archived",
|
||||
"> workplans are omitted; closed decisions/intakes/engagements stay listed",
|
||||
"> so recently-resolved work is still visible. [auto]",
|
||||
"",
|
||||
"| Kind | ID | Status | Lane | Source |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
lines.extend(f"| {kind} | {rid} | {status} | {lane} | {source} |" for kind, rid, status, lane, source in rows)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _check_work_record_index_freshness(
|
||||
repo_dir: Path, repo_slug: str, report: "ConsistencyReport"
|
||||
) -> None:
|
||||
"""C-33: WORK-RECORDS.md missing or stale relative to the current
|
||||
file-derived work-record set."""
|
||||
content = _generate_work_record_index(repo_dir, repo_slug)
|
||||
if content is None:
|
||||
return
|
||||
index_file = repo_dir / _WORK_RECORD_INDEX_NAME
|
||||
existing = index_file.read_text(encoding="utf-8") if index_file.is_file() else None
|
||||
if existing == content:
|
||||
return
|
||||
report.add(
|
||||
severity="WARN", check_id="C-33",
|
||||
message=(
|
||||
f"{_WORK_RECORD_INDEX_NAME} is missing or stale — regenerate via "
|
||||
f"fix-consistency (CUST-WP-0061-T04 generated work-record index)"
|
||||
),
|
||||
file_path=_WORK_RECORD_INDEX_NAME,
|
||||
fixable=True,
|
||||
_fix_context={"index_file": index_file, "content": content},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -1042,6 +1153,9 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||
# C-32: intake/decision work-record registration (CUST-WP-0061-T02)
|
||||
_check_work_record_registration(repo_dir, report)
|
||||
|
||||
# C-33: generated per-repo work-record index (CUST-WP-0061-T04)
|
||||
_check_work_record_index_freshness(repo_dir, repo_slug, report)
|
||||
|
||||
# C-01: workplans/ directory missing
|
||||
if not workplans_dir.is_dir():
|
||||
report.add(
|
||||
|
|
@ -2786,6 +2900,12 @@ def fix_repo(
|
|||
f"failed to write {id_field} back to {md_path.name}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-33":
|
||||
ctx["index_file"].write_text(ctx["content"], encoding="utf-8")
|
||||
report.fixes_applied.append(
|
||||
f"C-33 fixed: regenerated {ctx['index_file'].name}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-09":
|
||||
ws_id = ctx["ws_id"]
|
||||
correct_repo_id = ctx["correct_repo_id"]
|
||||
|
|
|
|||
187
tests/test_work_record_index.py
Normal file
187
tests/test_work_record_index.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Tests for C-33 (generated per-repo work-record index, CUST-WP-0061-T04):
|
||||
_generate_work_record_index() and _check_work_record_index_freshness().
|
||||
Offline, synthetic registry fixture — same conventions as
|
||||
test_work_record_check.py / test_work_record_registration.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from consistency_check import ( # noqa: E402
|
||||
ConsistencyReport,
|
||||
_check_work_record_index_freshness,
|
||||
_generate_work_record_index,
|
||||
)
|
||||
|
||||
REGISTRY_YAML = textwrap.dedent(
|
||||
"""
|
||||
version: "0.1"
|
||||
kinds:
|
||||
- kind: workplan
|
||||
id_patterns: ['^[A-Z]+-WP-[0-9]{4}$']
|
||||
- kind: task
|
||||
id_patterns: ['^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$']
|
||||
- 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}$']
|
||||
- kind: engagement
|
||||
id_patterns: ['^[A-Z]+-ENG-[0-9]{4}-[0-9]{3}$']
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@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 TestGenerateWorkRecordIndex:
|
||||
def test_empty_repo_returns_none(self, tmp_path, registry_file):
|
||||
assert _generate_work_record_index(tmp_path, "testrepo") is None
|
||||
|
||||
def test_includes_workplan_and_tasks(self, tmp_path, registry_file):
|
||||
wp_dir = tmp_path / "workplans"
|
||||
wp_dir.mkdir()
|
||||
(wp_dir / "CUST-WP-0001-demo.md").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
---
|
||||
id: CUST-WP-0001
|
||||
status: active
|
||||
---
|
||||
```task
|
||||
id: CUST-WP-0001-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
content = _generate_work_record_index(tmp_path, "testrepo")
|
||||
assert content is not None
|
||||
assert "| workplan | CUST-WP-0001 | active | — | workplans/CUST-WP-0001-demo.md |" in content
|
||||
assert "| task | CUST-WP-0001-T01 | done | — |" in content
|
||||
|
||||
def test_excludes_archived_workplans(self, tmp_path, registry_file):
|
||||
wp_dir = tmp_path / "workplans" / "archived"
|
||||
wp_dir.mkdir(parents=True)
|
||||
(wp_dir / "260101-CUST-WP-0000-old.md").write_text(
|
||||
"---\nid: CUST-WP-0000\nstatus: finished\n---\n", encoding="utf-8"
|
||||
)
|
||||
assert _generate_work_record_index(tmp_path, "testrepo") is None
|
||||
|
||||
def test_includes_intake_decision_engagement(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
```yaml
|
||||
id: AWQ-010
|
||||
title: qonto mcp
|
||||
status: open
|
||||
lane: green
|
||||
```
|
||||
```yaml
|
||||
id: BINKY-DEC-2026-004
|
||||
title: qonto approval
|
||||
status: resolved
|
||||
lane: red
|
||||
```
|
||||
```yaml
|
||||
id: BINKY-ENG-2026-001
|
||||
title: bank call
|
||||
status: queued
|
||||
```
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
content = _generate_work_record_index(tmp_path, "testrepo")
|
||||
assert "| intake | AWQ-010 | open | green | queue.md |" in content
|
||||
assert "| decision | BINKY-DEC-2026-004 | resolved | red | queue.md |" in content
|
||||
assert "| engagement | BINKY-ENG-2026-001 | queued | — | queue.md |" in content
|
||||
|
||||
def test_ignores_unregistered_ids(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text(
|
||||
"```yaml\nid: FOO-QX-001\nstatus: open\n```\n", encoding="utf-8"
|
||||
)
|
||||
assert _generate_work_record_index(tmp_path, "testrepo") is None
|
||||
|
||||
def test_ignores_template_placeholders(self, tmp_path, registry_file):
|
||||
(tmp_path / "AGENTS.md").write_text(
|
||||
"```yaml\nid: BINKY-IN-NNNN\nstatus: open\n```\n", encoding="utf-8"
|
||||
)
|
||||
assert _generate_work_record_index(tmp_path, "testrepo") is None
|
||||
|
||||
def test_does_not_scan_its_own_output_file(self, tmp_path, registry_file):
|
||||
(tmp_path / "WORK-RECORDS.md").write_text(
|
||||
"| intake | AWQ-999 | open | green | ghost.md |\n", encoding="utf-8"
|
||||
)
|
||||
assert _generate_work_record_index(tmp_path, "testrepo") is None
|
||||
|
||||
def test_header_and_sort_order(self, tmp_path, registry_file):
|
||||
(tmp_path / "b.md").write_text("```yaml\nid: AWQ-002\nstatus: open\n```\n", encoding="utf-8")
|
||||
(tmp_path / "a.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8")
|
||||
content = _generate_work_record_index(tmp_path, "testrepo")
|
||||
assert content.startswith("# Work Records — testrepo")
|
||||
assert "[auto]" in content
|
||||
# sorted by source path first (a.md before b.md)
|
||||
assert content.index("AWQ-001") < content.index("AWQ-002")
|
||||
|
||||
|
||||
class TestCheckWorkRecordIndexFreshness:
|
||||
def _report(self, repo_dir: Path, repo_slug: str = "testrepo") -> ConsistencyReport:
|
||||
report = ConsistencyReport(repo_slug=repo_slug, repo_path=str(repo_dir))
|
||||
_check_work_record_index_freshness(repo_dir, repo_slug, report)
|
||||
return report
|
||||
|
||||
def test_no_issue_when_no_records(self, tmp_path, registry_file):
|
||||
report = self._report(tmp_path)
|
||||
assert report.issues == []
|
||||
|
||||
def test_flags_missing_index(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8")
|
||||
report = self._report(tmp_path)
|
||||
assert len(report.issues) == 1
|
||||
assert report.issues[0].check_id == "C-33"
|
||||
assert report.issues[0].fixable is True
|
||||
|
||||
def test_no_issue_when_index_already_current(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8")
|
||||
content = _generate_work_record_index(tmp_path, "testrepo")
|
||||
(tmp_path / "WORK-RECORDS.md").write_text(content, encoding="utf-8")
|
||||
report = self._report(tmp_path)
|
||||
assert report.issues == []
|
||||
|
||||
def test_flags_stale_index_after_new_record_added(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8")
|
||||
content = _generate_work_record_index(tmp_path, "testrepo")
|
||||
(tmp_path / "WORK-RECORDS.md").write_text(content, encoding="utf-8")
|
||||
# a new record appears
|
||||
(tmp_path / "queue.md").write_text(
|
||||
"```yaml\nid: AWQ-001\nstatus: open\n```\n```yaml\nid: AWQ-002\nstatus: open\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
report = self._report(tmp_path)
|
||||
assert len(report.issues) == 1
|
||||
assert report.issues[0].check_id == "C-33"
|
||||
|
||||
def test_fix_context_carries_correct_content(self, tmp_path, registry_file):
|
||||
(tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8")
|
||||
report = self._report(tmp_path)
|
||||
ctx = report.issues[0]._fix_context
|
||||
assert ctx["index_file"] == tmp_path / "WORK-RECORDS.md"
|
||||
assert "AWQ-001" in ctx["content"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue