From 506c2b4cfb0693bc884978cc36f8e6d89e98b851 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:17:35 +0000 Subject: [PATCH 1/2] prompt: file registry-integrity-check (maintenance/pyautomind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planned.md holds 12 entries, 8 of them wrong, and `lifecycle.py check` reports OK through all of it — cmd_check never opens planned.md or parked.md and never resolves a prompt: path. Found while task-selecting: the two highest-leverage planned entries (notebook-kernel-cwd-auto-simulate, auto-simulate-guard-wrong-simulator-target) are both already fixed on main. Verified rather than assumed — PyAutoHands build_util.py now routes through run_notebook.py, and an audit of all 246 should_simulate guards in autolens_workspace against the 54 simulators' declared outputs found 236 resolvable and 0 mismatches. Scopes an offline-only check plus the reconciliation of the 8 drifted entries and the first test for lifecycle.py. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GW2wFpSkZtXr8VzM5w8MpX --- .../pyautomind/registry_integrity_check.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 draft/maintenance/pyautomind/registry_integrity_check.md diff --git a/draft/maintenance/pyautomind/registry_integrity_check.md b/draft/maintenance/pyautomind/registry_integrity_check.md new file mode 100644 index 00000000..4ae9da92 --- /dev/null +++ b/draft/maintenance/pyautomind/registry_integrity_check.md @@ -0,0 +1,123 @@ +# Teach `lifecycle.py check` to validate the registry files + +Type: maintenance +Target: PyAutoMind +Repos: +- PyAutoMind +Difficulty: small +Autonomy: supervised +Priority: high +Status: formalised + +`scripts/lifecycle.py check` reports **OK** on a `planned.md` in which 8 of 12 +entries are wrong. The registry is the first thing any task-selection pass +reads, so the rot is not cosmetic — it actively costs sessions. This task closes +that gap: extend `cmd_check` to validate the registry files, then fix what it +finds. + +## Why now (measured, 2026-08-08) + +A task-selection pass picked the two highest-leverage `planned.md` entries and +spent most of a session discovering **both were already fixed on main**: + +- **`notebook-kernel-cwd-auto-simulate`** (PyAutoHands#204) — FIXED. + `autohands/build_util.py:302` no longer shells out to + `jupyter nbconvert --execute`; it runs `autohands/run_notebook.py`, which sets + `resources['metadata']['path']` through the Python API so the kernel starts at + the repo root. The in-code comment describes this exact bug. Verified on + PyAutoHands main at `a5bac76`. +- **`auto-simulate-guard-wrong-simulator-target`** (autolens_workspace#359) — + FIXED. All 246 `should_simulate` guards in autolens_workspace were audited + against the 54 simulators' declared `dataset_type`/`dataset_name` outputs: + **236 resolvable, 0 mismatches.** The 10 unresolved are benign — a simulator + matching its own glob, `guides/hpc/example_cpu_and_gpu.py` (path built from an + `hpc_dataset_path` variable), and the `guides/results/` scripts whose first + subprocess target is the `_quick_fit.py` helper rather than a simulator. + +Neither entry had a prompt file, so neither was reachable through the normal +`$start-dev` path — the staleness was only discoverable by reading upstream +code. + +## The drift, classified + +`planned.md` holds 12 entries. Resolving each `prompt:` path through the +fallback chain that `AGENTS.md` documents (`draft/`, bare ``, +`active/`): + +| Class | N | Entries | +|---|---|---| +| Prompt file never existed in git history | 2 | `notebook-kernel-cwd-auto-simulate`, `auto-simulate-guard-wrong-simulator-target` | +| Prompt file truly missing | 3 | `heart-ci-linkage`, `heart-release-validation`, `heart-release-profile-wheel-integration` | +| Legacy `PyAutoMind///` path, resolves only via fallback | 3 | `samples-parameter-paths`, `nfw-truncated-potential-accuracy`, `piemass-potential` | +| State contradiction | 1 | `build-testpypi-rehearsal-mode` — `status: planned`, but its prompt is in `active/`, i.e. issued and in flight | + +Only 4 of 12 entries have an exactly-correct prompt path. + +## Why `check` misses all of it + +`cmd_check` (`scripts/lifecycle.py:376`) validates exactly two conditions: + +1. an `active.md` slug that also has a `complete/` record; +2. a filename present in both `active/` and `complete/`. + +It never opens `planned.md` or `parked.md`, and never resolves a `prompt:` path +in any registry file. `scripts/lifecycle.py` also has **no test** — +`tests/` holds only `test_repos_sync_hygiene_coverage.py`, +`test_spawn_privacy.py`, `test_spawn_template_contract.py`. + +## Scope + +Two legs, one PR — the data fix is required for the new check to land green. + +**Leg 1 — the check.** In `cmd_check`, parse `## ` entries and their +`- key: value` fields from `active.md`, `planned.md` and `parked.md`, and add: + +- **Prompt resolution** — every `prompt:` path resolves through the documented + fallback chain, else `DRIFT`. Report the resolved location when it differs + from the literal path, so legacy paths are visible rather than silently + absorbed. +- **State contradiction** — a `planned.md`/`parked.md` entry whose prompt lives + in `active/` (it is issued) or under `complete/` (it shipped) is drift. This + is the check that would have caught `build-testpypi-rehearsal-mode`. +- **Slug uniqueness** — a slug must not appear in two registries at once. + +Match the existing `problems` list + `lifecycle check: DRIFT` output shape; do +not change the exit-code contract (`0` OK, `1` drift). + +**Leg 2 — reconcile the 8 entries.** + +- Rewrite the 3 legacy paths to their exact `draft/...` form. +- Move `build-testpypi-rehearsal-mode` to `active.md`, or correct its `status:` + — whichever matches the issue's real state at implementation time. +- Remove the 2 verified-shipped entries, citing the evidence above. +- The 3 `pyautoheart/` entries have no prompt file: either write the prompt from + the entry's existing `summary:` block (each carries a substantial one) or move + the entry to `ideas.md`. **Human call at implementation time** — these are the + M0–M3 release-validation milestone chain and may still be wanted. + +**Leg 3 — `tests/test_lifecycle_check.py`.** Drive the real `cmd_check` against +fixture registry trees (tmp_path with `draft/`, `active/`, `complete/` and +synthetic registry files) — one case per new condition, plus a clean tree +asserting `OK`. Follow `test_repos_sync_hygiene_coverage.py` for style. + +## Explicitly out of scope + +- **Online issue cross-checking.** A local check catches the two shipped entries + only because their prompt files are missing; had the files existed, nothing + offline would flag "this shipped upstream". Catching that class needs each + entry's `issue:` cross-checked against GitHub, which makes `check` + non-hermetic and credentialed. Decided 2026-08-08 to keep `check` offline; + filed as a follow-up idea instead. +- **Closing the upstream issues.** PyAutoHands#204 and autolens_workspace#359 + are still open on trackers outside this task's repo. Flagged here for a later + `/issue_cleanup` run; this PR touches PyAutoMind only. +- `condemned.md`, `queue.md` and `ideas.md` — different schemas, no `prompt:` + field. Not covered. + +## Acceptance + +- `python3 scripts/lifecycle.py check` exits `0` on the reconciled tree, and + exits `1` with a named problem for each of the three new conditions when + seeded with a fixture that violates it. +- `pytest tests/test_lifecycle_check.py` green. +- No entry left in `planned.md` whose prompt path does not resolve exactly. From ebbc3c1283ed1747b483758af1c3fdde2e1df65e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:27:43 +0000 Subject: [PATCH 2/2] lifecycle: validate the registry files in `check`, reconcile planned.md `check` reported OK over a planned.md in which 9 of 13 entries were wrong, because cmd_check never opened planned.md or parked.md and never resolved a `prompt:` path. It validated only two conditions, both about active.md and the state folders. Adds three registry checks: every `prompt:` path resolves, exactly rather than via the legacy fallback; it resolves into a state folder its registry implies (planned -> draft, active -> active, parked -> either, since parked holds both scoped and started-then-parked tasks); and no slug is listed in two registries. Reconciles what it found. Six of the thirteen entries were work that had already shipped -- verified on each upstream main, not inferred: notebook-kernel-cwd-auto-simulate PyAutoHands build_util.py routes through run_notebook.py, setting the kernel path auto-simulate-guard-wrong-target autolens_workspace: 236 resolvable guards audited, 0 mismatches build-testpypi-rehearsal-mode (M1) PyAutoHands release.yml `rehearsal` input heart-ci-linkage (M0) PyAutoHeart heart/checks/ci_status.* heart-release-validation (M2) PyAutoHeart heart/validate.py + ingest heart-release-profile-wheel (M3) PyAutoHeart named `release` profile The entire M0-M3 release-validation chain shipped without one entry being retired. Removed rather than given fabricated complete/ records: they shipped under other tasks' PRs, and dated records nobody verified would put a worse lie somewhere more trusted. Three legacy PyAutoMind/// paths normalised to draft/. First test for lifecycle.py: fictional, hermetic fixtures per the KEEP-copied-into-the-template rule, each leg driven with input that trips it. Two checks were themselves wrong before these tests pinned them -- the parked.md state rule and the trailing-parenthetical path form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GW2wFpSkZtXr8VzM5w8MpX --- .../pyautomind/registry_integrity_check.md | 41 ++- planned.md | 122 +-------- scripts/lifecycle.py | 135 ++++++++++ tests/test_lifecycle_check.py | 251 ++++++++++++++++++ 4 files changed, 421 insertions(+), 128 deletions(-) create mode 100644 tests/test_lifecycle_check.py diff --git a/draft/maintenance/pyautomind/registry_integrity_check.md b/draft/maintenance/pyautomind/registry_integrity_check.md index 4ae9da92..fde7cddc 100644 --- a/draft/maintenance/pyautomind/registry_integrity_check.md +++ b/draft/maintenance/pyautomind/registry_integrity_check.md @@ -49,9 +49,25 @@ fallback chain that `AGENTS.md` documents (`draft/`, bare ``, | Prompt file never existed in git history | 2 | `notebook-kernel-cwd-auto-simulate`, `auto-simulate-guard-wrong-simulator-target` | | Prompt file truly missing | 3 | `heart-ci-linkage`, `heart-release-validation`, `heart-release-profile-wheel-integration` | | Legacy `PyAutoMind///` path, resolves only via fallback | 3 | `samples-parameter-paths`, `nfw-truncated-potential-accuracy`, `piemass-potential` | -| State contradiction | 1 | `build-testpypi-rehearsal-mode` — `status: planned`, but its prompt is in `active/`, i.e. issued and in flight | +| State contradiction | 1 | `build-testpypi-rehearsal-mode` — `status: planned`, but its prompt is in `active/` | -Only 4 of 12 entries have an exactly-correct prompt path. +Only 4 of 13 entries had an exactly-correct prompt path. + +**Six of the thirteen were work that had already shipped.** Chasing each of the +five missing-prompt entries to its upstream repo found the capability live on +main in every case: + +| Entry | Milestone | Shipped as | +|---|---|---| +| `notebook-kernel-cwd-auto-simulate` | — | PyAutoHands `build_util.py:302` → `run_notebook.py`, setting `resources['metadata']['path']` | +| `auto-simulate-guard-wrong-simulator-target` | — | autolens_workspace: 236 resolvable guards, 0 mismatches | +| `build-testpypi-rehearsal-mode` | M1 | PyAutoHands `release.yml` — `rehearsal` dispatch input, `resolve_mode` job, downstream jobs gated `if: rehearsal != 'true'`, dev-segment version output. Entry targeted "PyAutoBuild", which is now PyAutoHands | +| `heart-ci-linkage` | M0 | PyAutoHeart `heart/checks/ci_status.{sh,py}` + `tests/test_ci_status.py`; the script's own comment says it "replaces the old `gh run list --limit 1`" — verbatim the defect the entry described | +| `heart-release-validation` | M2 | PyAutoHeart `pyauto-heart validate --ingest` → `heart/validate.py`, `validation_report.json`, `.github/workflows/release-integrate.yml` | +| `heart-release-profile-wheel-integration` | M3 | PyAutoHeart `heart/validate.py` carries the named `release` profile and gates fidelity on `profile == release`; TestPyPI wheel install in `heart/checks/verify_install.sh` | + +The whole M0–M3 release-validation milestone chain shipped without a single +registry entry being retired. That is the cost this check exists to prevent. ## Why `check` misses all of it @@ -87,13 +103,12 @@ not change the exit-code contract (`0` OK, `1` drift). **Leg 2 — reconcile the 8 entries.** - Rewrite the 3 legacy paths to their exact `draft/...` form. -- Move `build-testpypi-rehearsal-mode` to `active.md`, or correct its `status:` - — whichever matches the issue's real state at implementation time. -- Remove the 2 verified-shipped entries, citing the evidence above. -- The 3 `pyautoheart/` entries have no prompt file: either write the prompt from - the entry's existing `summary:` block (each carries a substantial one) or move - the entry to `ideas.md`. **Human call at implementation time** — these are the - M0–M3 release-validation milestone chain and may still be wanted. +- Remove the 6 verified-shipped entries, citing the evidence table above. The + M0–M3 chain needed no reconstruction from its `summary:` blocks after all — + every milestone was already live upstream. +- Removal is the right disposal, not a fabricated `complete/` record: these + shipped under other tasks' PRs, and inventing dated records with merge + evidence nobody verified would put a worse lie in a more trusted place. **Leg 3 — `tests/test_lifecycle_check.py`.** Drive the real `cmd_check` against fixture registry trees (tmp_path with `draft/`, `active/`, `complete/` and @@ -111,6 +126,14 @@ asserting `OK`. Follow `test_repos_sync_hygiene_coverage.py` for style. - **Closing the upstream issues.** PyAutoHands#204 and autolens_workspace#359 are still open on trackers outside this task's repo. Flagged here for a later `/issue_cleanup` run; this PR touches PyAutoMind only. +- **The orphaned `active/` prompt.** Removing `build-testpypi-rehearsal-mode` + from planned.md leaves `active/release_yml_testpypi_rehearsal_mode.md` sitting + in `active/` with no registry entry — shipped work whose prompt was never + advanced to `complete/`. `check` does not look for prompts that no registry + claims, so this is invisible to it. Two follow-ups, both deliberately not + taken here: give that prompt a proper `complete/` record via the ship path, + and add an orphan-prompt check (every `active/*.md` is claimed by an + `active.md` or `parked.md` entry) — the mirror of the checks added here. - `condemned.md`, `queue.md` and `ideas.md` — different schemas, no `prompt:` field. Not covered. diff --git a/planned.md b/planned.md index c2c806a3..840483c9 100644 --- a/planned.md +++ b/planned.md @@ -28,7 +28,7 @@ - autolens_assistant ## samples-parameter-paths -- prompt: PyAutoMind/bug/health_fixes/samples_parameter_paths.md +- prompt: draft/bug/health_fixes/samples_parameter_paths.md - issue: https://github.com/PyAutoLabs/PyAutoFit/issues/1327 (open, parked) - status: parked - filed: 2026-07-08 @@ -47,88 +47,6 @@ Full trail: PyAutoFit#1327 comments. - affected-repos: -## heart-ci-linkage -- prompt: PyAutoMind/feature/pyautoheart/ci_linkage.md -- status: planned -- filed: 2026-06-30 -- classification: organism (PyAutoHeart CI signal + registry) -- suggested-branch: feature/heart-ci-linkage -- milestone: M0 (foundational — release-validation gate builds on a trustworthy CI signal) -- summary: | - Final-review finding: Heart's CI signal is too coarse/narrow to gate a - release. ci_status reads `gh run list --limit 1` (newest run, any workflow, - any branch) but workspaces gate on 3 workflows × 2 Pythons; readiness gates - only the 5 libraries' CI (workspace CI observed but never gated); and the - signal should come from the Actions server (mobile-reachable via MCP) with - report.json as enrichment, not a hard dependency. Plus repos.yaml is stale - (PyAutoPrompt→Mind, PyAutoPaper→Memory; organism repos unpolled). Rework - ci_status to per-required-workflow-on-main, gate workspace CI, make the run - conclusion the primary test_run signal, refresh the registry. -- affected-repos: - - PyAutoHeart - -## heart-release-validation -- prompt: PyAutoMind/feature/pyautoheart/release_validation.md -- status: planned -- filed: 2026-06-30 -- classification: organism (PyAutoHeart deep validation + report + readiness gate) -- suggested-branch: feature/heart-release-validation -- milestone: M2 (depends on M1 = build-testpypi-rehearsal-mode) -- boundary: | - Heart never mutates a repo and never triggers a build. The Brain Release - Agent dispatches the rehearsal + validation workflows and awaits them; Heart's - `validate` is ingest-and-judge only; the Health Agent (read-only) reports the - verdict. Heart and Build never call each other. - -## heart-release-profile-wheel-integration -- prompt: PyAutoMind/feature/pyautoheart/release_profile_and_wheel_integration.md -- status: planned -- filed: 2026-06-30 -- classification: organism (validation fidelity — wheels + release env profile) -- suggested-branch: feature/heart-release-profile-wheel-integration -- milestone: M3 (depends on M1 + M2; closes Gaps A & B) -- summary: | - Make the validation run install the TestPyPI wheels (no source on PYTHONPATH, - scripts run from inside the workspace checkout so autoconf resolves workspace - config/) and run at release fidelity via a named `release` env profile - (user workspaces TEST_MODE=1+small+fast; *_test TEST_MODE=0, full-res), - mirroring release.yml's tier split. Env-var profile only — does not touch - config/general.yaml test:/version: toggles. -- affected-repos: - - PyAutoHeart - - PyAutoBuild - - autolens_workspace_test / autogalaxy_workspace_test / autofit_workspace_test - - autolens_workspace / autogalaxy_workspace / autofit_workspace -- summary: | - New third Heart tier: a release-grade `pyauto-heart validate` that composes - a TestPyPI build rehearsal + unit tests + the full workspace/workspace_test - integration surface, ingests the run reports into a tracked - `validation_report.json`, and hard-gates `readiness` GREEN on a fresh pass - for the current source SHAs. Driven from mobile via the Brain health agent - (GitHub dispatch/poll via MCP; Heart stays credential-free). Bakes in two - verified gaps the current `workspace-validation.yml` has: it tests source - not wheels (PYTHONPATH-shadow), and it runs the smoke profile - (PYAUTO_TEST_MODE=2 + PYAUTO_SMALL_DATASETS=1) not a release-fidelity profile. -- affected-repos: - - PyAutoHeart - - PyAutoBrain - - PyAutoBuild - -## build-testpypi-rehearsal-mode -- prompt: PyAutoMind/feature/pyautobuild/release_yml_testpypi_rehearsal_mode.md -- status: planned -- filed: 2026-06-30 -- classification: organism (PyAutoBuild executor capability) -- suggested-branch: feature/build-testpypi-rehearsal-mode -- milestone: M1 (prerequisite for M2 = heart-release-validation) -- summary: | - Add a TestPyPI-only "rehearsal" dispatch mode to release.yml: build current - source, publish to TestPyPI, emit the version string, and STOP before - PyPI/tag/notebook steps — so Heart can install and validate the actual wheels - before any release. Small, isolated, highest-value first piece. -- affected-repos: - - PyAutoBuild - ## jax-point-source-point-smoke-sentinel - prompt: draft/bug/autolens/jax_point_source_point_smoke_sentinel.md - status: planned @@ -155,7 +73,7 @@ source_plane.py in the same dir — they share the seed dataset. ## nfw-truncated-potential-accuracy -- prompt: PyAutoMind/bug/autogalaxy/nfw_truncated_potential_accuracy.md +- prompt: draft/bug/autogalaxy/nfw_truncated_potential_accuracy.md - status: planned - filed: 2026-06-05 - classification: library (accuracy bug) @@ -172,7 +90,7 @@ ## piemass-potential -- prompt: PyAutoMind/feature/autogalaxy/piemass_potential.md +- prompt: draft/feature/autogalaxy/piemass_potential.md - status: planned - filed: 2026-06-05 - classification: library (missing feature) @@ -196,37 +114,3 @@ - affected-repos: - autolens_workspace_test - note: latent/latent_nan_robustness.py PASSES but VACUOUSLY under the smoke profile — TEST_MODE=2 yields only 4 bypass samples, and DISABLE_JAX=1 silently flips its deliberate AnalysisImaging(use_jax=True) to False (PyAutoLens analysis/analysis/dataset.py:89), so the JAX column-masking branch the guard exists to catch is never taken. MultiStartAdam/BlackJAXNUTS precedent. Work = (1) config/build/env_vars.yaml override for `latent/latent_nan_robustness` with unset: [PYAUTO_TEST_MODE, PYAUTO_DISABLE_JAX]; (2) trim the script under the 300s cap. MEASURED: honest run = 412s; PYAUTO_TEST_MODE=1 does NOT help (455s) — Nautilus is NOT the bottleneck (~136s post-fit results update + ~56s latent compute on 100 samples), so the lever is sample count. Script is in the curated smoke_tests.txt, which DOES read env_vars.yaml, so this lands in the per-PR gate. Adjacent to the blocker's own follow-up ("re-time the SLOW siblings"). NOT bugs, verified passing from clean output, no change needed: imaging/model_fit.py and latent/latent_variables_smoke.py. - -## notebook-kernel-cwd-auto-simulate -- issue: https://github.com/PyAutoLabs/PyAutoHands/issues/204 -- prompt: draft/bug/workspaces/notebook_kernel_cwd_breaks_auto_simulate.md -- status: planned -- filed: 2026-07-27 -- classification: library+workspace (PyAutoHands runner vs per-script path resolution — fix option is a HUMAN CALL, see prompt) -- suggested-branch: feature/notebook-kernel-cwd-auto-simulate -- summary: | - jupyter nbconvert runs the kernel in the NOTEBOOK'S OWN directory, but the - auto-simulate guard shells out to a workspace-root-relative simulator path. - So every notebook that auto-simulates dies with exit status 2 ("can't open - file"). Proven empirically: launcher cwd .../cwdtest, kernel cwd - .../cwdtest/notebooks/sub. Accounts for ~20 of the 29 failing jobs in - workspace-validation run 30242158468. Scripts are unaffected (they do run - from the root), which is why run_scripts mostly passes and run_notebooks - mostly fails. - -## auto-simulate-guard-wrong-simulator-target -- issue: https://github.com/PyAutoLabs/autolens_workspace/issues/359 -- prompt: draft/bug/autolens_workspace/auto_simulate_guard_wrong_simulator_target.md -- status: planned -- filed: 2026-07-27 -- classification: workspace -- suggested-branch: feature/auto-simulate-guard-wrong-simulator-target -- summary: | - likelihood_function.py scripts load dataset/imaging/simple but their - auto-simulate guard runs no_lens_light/simulator.py, which writes - simple__no_lens_light — so the guard fires and the load still fails. Guard - target dates to 1f39244f; surfaced now because #354 swapped the raw - path-exists check for should_simulate. FIRST establish whether the target - was always wrong or should_simulate changed the predicate (the latter would - be a much wider bug), THEN sweep all 116 migrated guards for the same - mismatch. diff --git a/scripts/lifecycle.py b/scripts/lifecycle.py index 990e48ba..2a5d5b3a 100644 --- a/scripts/lifecycle.py +++ b/scripts/lifecycle.py @@ -33,6 +33,9 @@ Drift guard (mirrors repos_sync.py --check; non-zero exit on drift): * no active.md slug has a complete/ record (finished but still active) * no file lives in two states at once + * every registry `prompt:` path resolves, exactly rather than by + fallback, and into the state folder its registry implies + * no slug is listed in two registries at once Wire into /health and CI. This file is intentionally stdlib-only (no PyAuto imports) so it runs in any @@ -84,6 +87,136 @@ def ledger_slugs(path: Path) -> "set[str]": return slugs +# --------------------------------------------------------------------------- # +# registry integrity +# +# The registry files are the first thing a task-selection pass reads, so a wrong +# entry costs a whole session before it is noticed. `check` used to ignore them +# entirely — it never opened planned.md or parked.md and never resolved a single +# `prompt:` path, so it printed OK over a planned.md in which 8 of 12 entries +# were wrong (2026-08-08 audit). +# --------------------------------------------------------------------------- # +REGISTRY_FILES = ("active.md", "planned.md", "parked.md") + +# A field is a ZERO-INDENT `- key: value`. Nested two-space bullets are values +# of their parent key (` - SomeRepo: some-branch` under `repos:`), NOT fields — +# reading them as fields would invent keys out of branch names. +FIELD_RE = re.compile(r"^-\s*([^:\s][^:]*?):\s*(.*)$") + +# Which state folder(s) each registry's prompts may live in. parked.md takes +# BOTH: it holds tasks that were merely scoped (prompt still in draft/) and +# tasks that were started and then parked (prompt already advanced to active/). +EXPECTED_STATE = { + "active.md": {"active"}, + "planned.md": {"draft"}, + "parked.md": {"draft", "active"}, +} + + +def registry_entries(path: Path) -> "list[tuple[str, dict]]": + """[(slug, {key: value})] for each `## slug` section of a registry file. + + First occurrence of a key wins, matching how a reader scans the block.""" + entries: "list[tuple[str, dict]]" = [] + if not path.exists(): + return entries + fields: "dict[str, str]" = {} + slug = None + for line in path.read_text(errors="replace").splitlines(): + m = H2_RE.match(line) + if m: + if slug is not None: + entries.append((slug, fields)) + slug, fields = _slugify_h2(m.group(1)), {} + continue + if slug is None: + continue + f = FIELD_RE.match(line) + if f: + fields.setdefault(f.group(1).strip(), f.group(2).strip()) + if slug is not None: + entries.append((slug, fields)) + return entries + + +def resolve_prompt(root: Path, raw: str) -> "tuple[Path | None, str | None]": + """(path, state) for a registry `prompt:` value, else (None, None). + + Mirrors the fallback chain AGENTS.md documents for `$start-dev`: the literal + path, the pre-lifecycle `PyAutoMind///` and bare + `//` forms under draft/, and the bare filename in active/ + or as a complete/ record. `state` is the state folder the file ACTUALLY sits + in, which is what makes a state contradiction visible — resolving is not the + same as being in the right place.""" + rel = raw[len("PyAutoMind/"):] if raw.startswith("PyAutoMind/") else raw + stripped = rel[len("draft/"):] if rel.startswith("draft/") else rel + name = Path(rel).name + + candidates = [root / rel, root / "draft" / stripped, root / "active" / name] + complete = root / "complete" + if complete.is_dir(): + candidates += [ + f for f in sorted(complete.rglob(name)) + if (complete / "archive") not in f.parents + ] + + for cand in candidates: + if cand.is_file(): + try: + top = cand.resolve().relative_to(root.resolve()).parts[0] + except ValueError: + return cand, "outside" + return cand, top if top in ("draft", "active", "complete") else "other" + return None, None + + +def registry_problems(root: Path) -> "list[str]": + """Drift across active.md / planned.md / parked.md.""" + problems: "list[str]" = [] + seen: "dict[str, str]" = {} + + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + key = safe_name(slug) + if key in seen and seen[key] != reg: + problems.append( + f"slug listed in two registries: {slug} ({seen[key]} + {reg})" + ) + seen.setdefault(key, reg) + + raw = fields.get("prompt") + if not raw: + continue + # Entries annotate the path with a trailing parenthetical + # ("... .md (carries the phase-1 record)") — the path is the first + # token, the rest is prose for a human. + raw = raw.split()[0] + resolved, state = resolve_prompt(root, raw) + if resolved is None: + problems.append(f"{reg}: {slug}: prompt path does not resolve: {raw}") + continue + + rel = resolved.relative_to(root).as_posix() + expected = EXPECTED_STATE[reg] + if state == "complete": + problems.append( + f"{reg}: {slug}: prompt is a complete/ record (shipped but " + f"still listed): {rel}" + ) + elif state not in expected: + want = "/ or ".join(sorted(expected)) + problems.append( + f"{reg}: {slug}: prompt is in {state}/ but {reg} implies " + f"{want}/: {rel}" + ) + elif rel != raw: + problems.append( + f"{reg}: {slug}: legacy prompt path, resolves only via " + f"fallback: {raw} -> {rel}" + ) + return problems + + def _prune_ledger_section(path: Path, slug: str) -> bool: """Drop the `## ` H2 section (heading through the line before the next H2, or EOF) from a ledger file. Returns True if a section was removed.""" @@ -395,6 +528,8 @@ def cmd_check(args) -> int: if f.name in active_names: problems.append(f"file in both active/ and complete/: {f.name}") + problems.extend(registry_problems(ROOT)) + if problems: print("lifecycle check: DRIFT") for p in problems: diff --git a/tests/test_lifecycle_check.py b/tests/test_lifecycle_check.py new file mode 100644 index 00000000..0c322e0e --- /dev/null +++ b/tests/test_lifecycle_check.py @@ -0,0 +1,251 @@ +"""Contract tests for the registry-integrity leg of `lifecycle.py check`. + +The registry files are the first thing a task-selection pass reads, so a wrong +entry costs a whole session before anyone notices. `check` used to ignore them +completely — it never opened planned.md or parked.md and never resolved a +`prompt:` path — so it printed OK over a registry in which half the entries +pointed at files that had moved, shipped, or never existed. + +Two things these tests deliberately do, matching `test_repos_sync_hygiene_coverage.py`: + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template (see `test_spawn_privacy.py`), so nothing here names a real + repository, task or prompt. It also keeps the tests hermetic — they assert + the check's logic, not the state of whatever happens to be checked out. +2. **Prove each leg FAILS.** A drift check that cannot fail is decoration. + Every condition below is driven with input that must trip it, and the + clean-tree case proves the checks stay quiet when nothing is wrong. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import lifecycle # noqa: E402 + + +# --------------------------------------------------------------------------- # +# fixtures +# --------------------------------------------------------------------------- # +def _tree(root: Path, *, draft=(), active=(), complete=(), registries=None): + """Build a fictional Mind tree: prompt files in state folders + registries.""" + for rel in draft: + p = root / "draft" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture prompt\n") + for name in active: + p = root / "active" / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture prompt\n") + for rel in complete: + p = root / "complete" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture record\n") + for name, body in (registries or {}).items(): + (root / name).write_text(body) + return root + + +def _entry(slug, prompt=None, extra=""): + body = f"## {slug}\n- status: planned\n" + if prompt is not None: + body += f"- prompt: {prompt}\n" + return body + extra + "\n" + + +# --------------------------------------------------------------------------- # +# the clean case — the checks must stay quiet +# --------------------------------------------------------------------------- # +def test_clean_tree_has_no_problems(tmp_path): + root = _tree( + tmp_path, + draft=["feature/flywheel/sprocket_calibration.md"], + active=["widget_alignment.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + "active.md": _entry("widget-alignment", "active/widget_alignment.md"), + }, + ) + assert lifecycle.registry_problems(root) == [] + + +def test_entry_without_a_prompt_field_is_not_a_problem(tmp_path): + """Plenty of real entries legitimately carry no `prompt:` (release drives, + trackers). Their absence must not be reported as unresolvable.""" + root = _tree(tmp_path, registries={"planned.md": _entry("no-prompt-here")}) + assert lifecycle.registry_problems(root) == [] + + +# --------------------------------------------------------------------------- # +# leg 1 — the prompt path must resolve at all +# --------------------------------------------------------------------------- # +def test_unresolvable_prompt_path_is_drift(tmp_path): + root = _tree( + tmp_path, + registries={ + "planned.md": _entry("ghost-task", "draft/bug/flywheel/never_written.md") + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "does not resolve" in problems[0] + assert "ghost-task" in problems[0] + + +# --------------------------------------------------------------------------- # +# leg 2 — resolving only via the legacy fallback is still drift +# --------------------------------------------------------------------------- # +def test_legacy_path_resolving_via_fallback_is_drift(tmp_path): + """The pre-lifecycle `PyAutoMind///` form still resolves + for $start-dev, but leaving it in the registry hides where the file is.""" + root = _tree( + tmp_path, + draft=["bug/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "PyAutoMind/bug/flywheel/sprocket_calibration.md", + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "legacy prompt path" in problems[0] + # the message must name where it actually landed, or it is not actionable + assert "draft/bug/flywheel/sprocket_calibration.md" in problems[0] + + +# --------------------------------------------------------------------------- # +# leg 3 — state contradictions +# --------------------------------------------------------------------------- # +def test_planned_entry_whose_prompt_is_in_active_is_drift(tmp_path): + """planned.md means "scoped, not started". A prompt already advanced to + active/ means the task is in flight and the registry is lying.""" + root = _tree( + tmp_path, + active=["sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "active/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "prompt is in active/" in problems[0] + + +def test_entry_whose_prompt_is_a_complete_record_is_drift(tmp_path): + """The expensive class: work that shipped but is still listed as pending.""" + root = _tree( + tmp_path, + complete=["2031/07/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "draft/bug/flywheel/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "shipped but still listed" in problems[0] + + +def test_parked_accepts_both_draft_and_active_prompts(tmp_path): + """parked.md holds tasks that were merely scoped (prompt still in draft/) + AND tasks that were started then parked (prompt already in active/). + Treating it like planned.md flags every genuinely-parked task.""" + root = _tree( + tmp_path, + draft=["feature/flywheel/scoped_then_parked.md"], + active=["started_then_parked.md"], + registries={ + "parked.md": ( + _entry( + "scoped-then-parked", + "draft/feature/flywheel/scoped_then_parked.md", + ) + + _entry("started-then-parked", "active/started_then_parked.md") + ) + }, + ) + assert lifecycle.registry_problems(root) == [] + + +# --------------------------------------------------------------------------- # +# leg 4 — a slug belongs to exactly one registry +# --------------------------------------------------------------------------- # +def test_slug_in_two_registries_is_drift(tmp_path): + root = _tree( + tmp_path, + draft=["feature/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + "parked.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + }, + ) + problems = lifecycle.registry_problems(root) + assert any("listed in two registries" in p for p in problems) + + +# --------------------------------------------------------------------------- # +# parser contracts — these bit during development, so they are pinned +# --------------------------------------------------------------------------- # +def test_nested_repo_bullets_are_not_read_as_fields(tmp_path): + """` - SomeRepo: some-branch` under `repos:` is a VALUE, not a field. + Reading indented bullets as fields invents keys out of branch names.""" + body = _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + extra="- repos:\n - FlywheelRepo: feature/sprocket\n - GadgetRepo: feature/sprocket\n", + ) + (tmp_path / "planned.md").write_text(body) + entries = lifecycle.registry_entries(tmp_path / "planned.md") + assert len(entries) == 1 + _, fields = entries[0] + assert set(fields) == {"status", "prompt", "repos"} + assert "FlywheelRepo" not in fields + + +def test_trailing_parenthetical_after_the_path_is_tolerated(tmp_path): + """Entries annotate the path with prose: `... .md (carries the table)`. + The path is the first token; the annotation must not break resolution.""" + root = _tree( + tmp_path, + draft=["bug/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/bug/flywheel/sprocket_calibration.md (carries the phase table)", + ) + }, + ) + assert lifecycle.registry_problems(root) == [] + + +def test_archive_material_does_not_satisfy_a_prompt_path(tmp_path): + """complete/archive/ holds retired non-record material and is skipped + everywhere else in this module; a shelved copy must not make a missing + prompt look present.""" + root = _tree( + tmp_path, + complete=["archive/shelved/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "draft/bug/flywheel/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "does not resolve" in problems[0]