From bf826b24b671d628bda876dfa7e63de0ba33a926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 19:28:11 +0000 Subject: [PATCH] lifecycle: add `issues`, the online tracking-issue cross-check The offline checks catch structural rot -- bad paths, state contradictions. They cannot catch the class that costs most: an entry describing work that is already finished. The 2026-08-08 audit found six of those, including the whole M0-M3 release-validation chain, and not one was locally detectable. Every one had correct upstream state the Mind never read back. `lifecycle.py issues` reads it back: each registry entry's tracking issue, cross-checked against GitHub via `gh`. A closed issue on an entry still listed as pending is drift. Scoped deliberately: * only `issue:`/`epic:` count. A shipped task's library-pr/workspace-pr are merged by definition, so grading those would bury the signal. * entries carrying prose instead of a URL ("(no issue -- a release drive)", "NEEDS A FRESH ISSUE") have nothing to query and are skipped. * NOT part of `check`, which stays hermetic for CI. This needs network and credentials, so it is its own subcommand. * missing `gh` raises GhUnavailable and exits 2 rather than reporting all clear -- a check that silently could not run is worse than one that fails loudly. Unreadable individual issues are reported, not swallowed. `fetch` is injectable, so all six new tests run with no network and no live repo, per the fictional-fixtures rule. First live finding: autolens_workspace#466 is closed as completed while mge-sigma-min-workspace-sweep still sits in active.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GW2wFpSkZtXr8VzM5w8MpX --- scripts/lifecycle.py | 123 ++++++++++++++++++++++++++++++++++ tests/test_lifecycle_check.py | 95 ++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/scripts/lifecycle.py b/scripts/lifecycle.py index 653d9f9f..c9a79155 100644 --- a/scripts/lifecycle.py +++ b/scripts/lifecycle.py @@ -42,6 +42,12 @@ The mirror of `check`: active/ prompts that no registry entry claims. Report-only by default (see cmd_orphans for why it is not yet a gate). + issues + The ONLINE leg (needs `gh` + network, so deliberately not part of + `check`): every registry entry's tracking issue cross-checked against + GitHub. Catches finished work still listed as pending — the class no + offline check can see. + This file is intentionally stdlib-only (no PyAuto imports) so it runs in any environment, including a bare template checkout. """ @@ -221,6 +227,118 @@ def registry_problems(root: Path) -> "list[str]": return problems +# --------------------------------------------------------------------------- # +# the online leg — tracking-issue state +# +# The offline checks catch STRUCTURAL rot (bad paths, state contradictions). +# They cannot catch the class that costs most: an entry describing work that is +# finished. The 2026-08-08 audit found six such entries, including the whole +# M0-M3 release-validation chain, and not one was locally detectable — every one +# had correct upstream state (a closed issue, a merged PR, a capability live on +# main) that the Mind simply never read back. This is that read-back. +# +# Deliberately NOT part of `check`: it needs the network and `gh` credentials, +# and `check` is wired into CI where it must stay hermetic. +# --------------------------------------------------------------------------- # +# Only TRACKING refs. A task's `library-pr:`/`workspace-pr:` are merged by +# definition once it ships, so reporting those as closed would be pure noise. +ISSUE_FIELDS = ("issue", "epic") +ISSUE_URL_RE = re.compile(r"https://github\.com/([\w.-]+)/([\w.-]+)/issues/(\d+)") + + +class GhUnavailable(RuntimeError): + """`gh` is not installed. Distinct from "gh ran and said no" so the command + can report "could not run" instead of the far worse "nothing to report".""" + + +def registry_issue_refs(root: Path) -> "list[tuple[str, str, str]]": + """(registry, slug, issue_url) for every entry carrying a tracking issue. + + Entries legitimately carry prose instead of a URL ("(no issue — a + human-authorized release drive)", "NEEDS A FRESH ISSUE — ..."); those have + nothing to query and are skipped rather than reported.""" + refs = [] + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + for key in ISSUE_FIELDS: + m = ISSUE_URL_RE.search(fields.get(key, "")) + if m: + refs.append((reg, slug, m.group(0))) + return refs + + +def _gh_issue_states(urls: "list[str]") -> "dict[str, str]": + """{url: state} via the `gh` CLI. Requires gh + network; online leg only.""" + import subprocess + + states: "dict[str, str]" = {} + for url in urls: + m = ISSUE_URL_RE.match(url) + if not m: + continue + owner, repo, num = m.groups() + try: + r = subprocess.run( + ["gh", "api", f"repos/{owner}/{repo}/issues/{num}", "--jq", ".state"], + capture_output=True, text=True, + ) + except FileNotFoundError: + raise GhUnavailable + if r.returncode != 0: + tail = (r.stderr.strip().splitlines() or ["error"])[-1] + states[url] = f"unreadable: {tail}" + continue + states[url] = r.stdout.strip() + return states + + +def issue_problems(root: Path, fetch=None) -> "list[str]": + """Registry entries whose tracking issue is CLOSED — i.e. finished work + still listed as pending. + + `fetch` maps urls -> {url: state}; injectable so the logic is testable + without a network.""" + refs = registry_issue_refs(root) + if not refs: + return [] + fetch = fetch or _gh_issue_states + states = fetch([url for _, _, url in refs]) + + problems = [] + for reg, slug, url in refs: + state = states.get(url, "unknown") + if state == "closed": + problems.append( + f"{reg}: {slug}: tracking issue is CLOSED but the entry is still " + f"listed as pending: {url}" + ) + elif state != "open": + problems.append(f"{reg}: {slug}: could not read issue state ({state}): {url}") + return problems + + +def cmd_issues(args) -> int: + """Cross-check every registry entry's tracking issue against GitHub.""" + try: + problems = issue_problems(ROOT) + except GhUnavailable: + print( + "lifecycle issues: cannot run — the `gh` CLI is not installed.\n" + " This leg needs GitHub; it is deliberately separate from `check`,\n" + " which stays hermetic for CI. Install gh, or run this from a\n" + " session that has it.", + file=sys.stderr, + ) + return 2 + if not problems: + print(f"lifecycle issues: OK ({len(registry_issue_refs(ROOT))} tracking issue(s) open)") + return 0 + print("lifecycle issues: DRIFT") + for p in problems: + print(f" - {p}") + return 1 + + def orphan_prompts(root: Path) -> "list[Path]": """active/*.md that no registry entry claims — the mirror of registry_problems(). @@ -624,6 +742,11 @@ def main() -> int: c = sub.add_parser("check", help="drift guard (non-zero exit on drift)") c.set_defaults(func=cmd_check) + iss = sub.add_parser( + "issues", help="cross-check registry tracking issues against GitHub (needs gh)" + ) + iss.set_defaults(func=cmd_issues) + o = sub.add_parser("orphans", help="report active/ prompts no registry claims") o.add_argument("--check", action="store_true", help="exit non-zero if any orphan exists (once the backlog is cleared)") diff --git a/tests/test_lifecycle_check.py b/tests/test_lifecycle_check.py index d1e5a4dc..fa8af738 100644 --- a/tests/test_lifecycle_check.py +++ b/tests/test_lifecycle_check.py @@ -233,6 +233,101 @@ def test_trailing_parenthetical_after_the_path_is_tolerated(tmp_path): assert lifecycle.registry_problems(root) == [] +# --------------------------------------------------------------------------- # +# the online leg — tracking-issue state +# +# `fetch` is injected so these stay hermetic: no network, no `gh`, no live repo. +# --------------------------------------------------------------------------- # +GHOST_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/17" +OTHER_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/18" + + +def _states(mapping): + return lambda urls: {u: mapping.get(u, "unknown") for u in urls} + + +def test_closed_tracking_issue_on_a_pending_entry_is_drift(tmp_path): + """The class no offline check can see: the entry reads as pending, the work + is finished, and only GitHub knows.""" + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) + assert len(problems) == 1 + assert "CLOSED" in problems[0] + assert "sprocket-calibration" in problems[0] + + +def test_open_tracking_issue_is_not_drift(tmp_path): + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + assert lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "open"})) == [] + + +def test_prose_instead_of_an_issue_url_is_skipped(tmp_path): + """Real entries carry '(no issue — a human-authorized release drive)' and + 'NEEDS A FRESH ISSUE — ...'. There is nothing to query; not a finding.""" + body = ( + _entry("release-drive", extra="- issue: (no issue — a release drive)\n") + + _entry("needs-one", extra="- issue: NEEDS A FRESH ISSUE — file at start_dev\n") + ) + (tmp_path / "active.md").write_text(body) + assert lifecycle.registry_issue_refs(tmp_path) == [] + assert lifecycle.issue_problems(tmp_path, fetch=_states({})) == [] + + +def test_epic_field_is_treated_as_a_tracking_ref(tmp_path): + (tmp_path / "planned.md").write_text( + _entry("phased-task", extra=f"- epic: {GHOST_ISSUE} (the public watch point)\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) + assert len(problems) == 1 + + +def test_merged_pr_links_are_not_treated_as_tracking_refs(tmp_path): + """A shipped task's library-pr/workspace-pr are merged by definition. + Reporting those as closed would bury the real signal in noise.""" + body = _entry( + "sprocket-calibration", + extra=( + f"- issue: {GHOST_ISSUE}\n" + "- library-pr: https://github.com/FictionalOrg/FlywheelRepo/pull/99\n" + "- workspace-pr: https://github.com/FictionalOrg/GadgetRepo/pull/12\n" + ), + ) + (tmp_path / "active.md").write_text(body) + refs = lifecycle.registry_issue_refs(tmp_path) + assert [r[2] for r in refs] == [GHOST_ISSUE] + + +def test_missing_gh_propagates_rather_than_reporting_all_clear(tmp_path): + """"gh is not installed" must never be mistaken for "no findings" — a check + that silently could not run is worse than one that fails loudly.""" + import pytest + + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + + def _no_gh(urls): + raise lifecycle.GhUnavailable + + with pytest.raises(lifecycle.GhUnavailable): + lifecycle.issue_problems(tmp_path, fetch=_no_gh) + + +def test_unreadable_issue_state_is_reported_not_swallowed(tmp_path): + """A deleted repo, a revoked token or a network failure must surface — a + silent 'no findings' from a check that could not run is the worst outcome.""" + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {OTHER_ISSUE}\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({})) + assert len(problems) == 1 + assert "could not read issue state" in problems[0] + + # --------------------------------------------------------------------------- # # the mirror direction — active/ prompts no registry claims # --------------------------------------------------------------------------- #