diff --git a/docs/pyautobrain/spawn_spec.md b/docs/pyautobrain/spawn_spec.md index 04c91061..cef9bdf8 100644 --- a/docs/pyautobrain/spawn_spec.md +++ b/docs/pyautobrain/spawn_spec.md @@ -37,7 +37,8 @@ deliberately, never silently shipped into a template. | 3 | `README.md` | KEEP verbatim (already generic post-Phase-1) | | 3b | `AI_POLICY.md`, `CONTRIBUTING.md` | KEEP with owner substitution — both are org-wide pointer docs: generic prose that names the owning org and links the canonical copy inside that org's `PyAutoScientist`. They take the same `PyAutoLabs` → `YOURORG` substitution as rule 9 rather than a verbatim KEEP; verbatim would stamp a literal "Contributing to PyAutoLabs" into a template spawned for another org | | 4 | `repos.yaml` | SUBSTITUTE → the **template body map**: the five organ rows kept with `github:` owner replaced by `YOURORG`; all live satellite rows replaced by the PyAutoProject family rows (`PyAutoProject` category `library`, `autoproject_workspace` category `workspace`, `autoproject_workspace_test` category `workspace_test`) + a commented-out `autoproject_assistant` row ("uncomment when the clone agent seeds it") | -| 5 | `active.md`, `planned.md`, `parked.md`, `condemned.md`, `ideas.md`, `queue.md`, `autonomy_log.md` | EMPTY → header line + schema pointer comment only (e.g. `# Active Tasks` + ``); autonomy_log keeps its schema header rows. **The header is GENERATED, never read from the live file** — `planned.md` and `ideas.md` carry no H1 at all, so "keep line 1" yields a registry entry (issue #118), and a heading-shape test cannot save it either: `## rhayes-audit-validation-phases-2-4` is a valid `##` heading. Each file's title lives in `EMPTY_TITLES`; an EMPTY-ruled file with no entry is an UNMATCHED-class human decision, not a guess | +| 5 | `active.md`, `planned.md`, `parked.md`, `condemned.md`, `ideas.md`, `queue.md` | EMPTY → header line + schema pointer comment only (e.g. `# Active Tasks` + ``). **The header is GENERATED, never read from the live file** — `planned.md` and `ideas.md` carry no H1 at all, so "keep line 1" yields a registry entry (issue #118), and a heading-shape test cannot save it either: a task slug written as an H2 is a structurally valid heading. Each file's title lives in `EMPTY_TITLES`; an EMPTY-ruled file with no entry is an UNMATCHED-class human decision, not a guess | +| 5b | `autonomy_log.md` | SPECIAL → the ledger's schema header (H1, the prose explaining the log, the `Outcome ∈ …` legend, the table header row and separator), held as the `AUTONOMY_LOG_TEMPLATE` asset. Not rule 5: it is `SPECIAL`, not in `EMPTY_TITLES`, and carries prose and a table rather than a title plus schema-pointer comment. Like rule 5 it is **GENERATED, never parsed** (issue #123): the old implementation copied lines until one started with `\|---`, so a row inserted above the separator was copied, and a cosmetically reformatted separator (`\| --- \|`) meant the break never fired — 231 live task records into a public repo. The canary scan is no backstop: a leaked row with no dataset or person token scans clean. A test asserts the live ledger still starts with the constant, so generating cannot silently go stale | | 6 | `draft/**` (the work-type dirs `feature/ bug/ refactor/ docs/ test/ release/ maintenance/ research/ experiment/ triage/` now live under `draft/`) | SKELETON → keep a single `draft/.gitkeep`; drop all draft prompts and their work-type/target subdirs (a fresh Mind starts with an empty `draft/`; intake recreates the work-type subdirs on demand) | | 6b | `complete/AGENTS.md` | KEEP verbatim (the finished-work archive **schema** is template content; matched before rule 7's `complete/*` DROP) | | 6c | `complete/index.md` | GENERATE → stamped by running the **generated tree's own** `scripts/lifecycle.py index --apply` after the tree is written (`lifecycle.py` resolves its root from `__file__`, and rule 1 already KEEPs it). The live `index.md` is still DROPped by rule 7 — this is a fresh empty-archive index, not a copy. Required because the template ships `lifecycle_drift.yml`, whose self-heal (PyAutoMind#116) regenerates this file on every push to the template's own `main`: if spawn did not produce it, each sync would be followed by a bot commit creating a file the next `--check` reports as drift, forever. Do NOT hold the text as a constant here — `lifecycle.py` owns the index format, and a second copy would drift from it | diff --git a/scripts/spawn.py b/scripts/spawn.py index e6ae3fcd..d3e22291 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -170,6 +170,30 @@ # Generated assets # -------------------------------------------------------------------------- +# The autonomy ledger's schema header, GENERATED rather than parsed out of the +# live file (spec rule 5; issue #123). The old implementation copied lines +# until one started with "|---", which leaked two ways: a row inserted above +# the separator was copied, and a cosmetically reformatted separator +# ("| --- |") meant the break never fired — 231 live task records into a +# public repo. The canary scan does not cover this; a leaked row with no +# dataset or person token scans clean. +# +# Byte-identical to what the parse produced for the current ledger, so +# adopting it introduces no template drift. +AUTONOMY_LOG_TEMPLATE = """\ +# Autonomy calibration log + +Append-only record of `--auto` workflow runs — the evidence base for raising +or lowering the per-work-type autonomy caps in `PyAutoBrain/AUTONOMY.md` (the +autonomy contract). One row per run, appended at PR-open or on parking. + +Outcome ∈ `merged-unchanged` / `amended` / `rejected` / `parked` / +`corrective`. + +| date | task | effective level | gates (tests/smoke/review/heart) | outcome | +|------|------|-----------------|----------------------------------|---------| +""" + BODY_MAP_TEMPLATE = """\ # repos.yaml — the body map: the single source of repo IDENTITY. # @@ -532,14 +556,22 @@ def substantive(idx): return "\n".join(out) + "\n" -def autonomy_log_body(src): - lines = src.read_text(errors="replace").splitlines() - kept = [] - for line in lines: - kept.append(line) - if line.startswith("|---"): - break - return "\n".join(kept) + "\n" +def autonomy_log_body(src=None): + """Return the autonomy ledger's schema header WITHOUT reading the source. + + `src` is accepted and ignored so the generator's dispatch stays uniform; + the whole point is that no live byte reaches the template. + + The previous implementation copied lines until one started with `|---`, + which leaked two ways (issue #123, both reproduced against the real + ledger): a row inserted above the separator was copied, and a cosmetically + reformatted separator (`| --- |`) meant the break never fired at all — + 231 live task records into a public repo. The canary scan is no backstop + here: a leaked row with no dataset or person token scans clean. + + Same fix as the EMPTY ledgers (#118): generate, never parse. + """ + return AUTONOMY_LOG_TEMPLATE def substitute_owner(text): diff --git a/tests/test_spawn_privacy.py b/tests/test_spawn_privacy.py index 090734aa..efa154e8 100644 --- a/tests/test_spawn_privacy.py +++ b/tests/test_spawn_privacy.py @@ -158,6 +158,91 @@ def test_glob_matched_empty_files_get_comment_headers(tmp_path, filename, lead): # -------------------------------------------------------------------------- +LIVE_AUTONOMY_LOG = Path(__file__).resolve().parents[1] / "autonomy_log.md" + + +@pytest.mark.skipif( + not LIVE_AUTONOMY_LOG.exists(), + reason="no live ledger here (e.g. a freshly-spawned Mind before its first run)", +) +def test_autonomy_log_constant_still_matches_the_live_schema(): + """The constant must not go stale against the ledger it models. + + Generating instead of parsing removes the leak, but it also removes the + feedback that kept the header current: nothing else notices if the live + ledger grows a column and the template keeps shipping the old table. This + is that feedback, and it is the only check here that reads the real file. + """ + live = LIVE_AUTONOMY_LOG.read_text() + assert live.startswith(spawn.AUTONOMY_LOG_TEMPLATE), ( + "the live autonomy_log.md header no longer matches AUTONOMY_LOG_TEMPLATE " + "— update the constant (and re-run spawn --apply), do not re-add parsing" + ) + + +def test_generated_ledger_equals_the_constant_exactly(fake_workspace, tmp_path): + """Pin the EMITTED file, not just the helper's return value. + + Asserting `autonomy_log_body(...) == AUTONOMY_LOG_TEMPLATE` alone is + circular — it compares the helper with what the helper returns. This checks + what actually lands in the template. + """ + out = tmp_path / "out" + spawn.generate_all(fake_workspace, out) + shipped = out / "PyAutoMind-template" / "autonomy_log.md" + assert shipped.read_text() == spawn.AUTONOMY_LOG_TEMPLATE + + +def test_autonomy_log_template_is_a_usable_table_header(): + """Independent shape check, so a malformed constant cannot pass silently.""" + lines = [ln for ln in spawn.AUTONOMY_LOG_TEMPLATE.splitlines() if ln.strip()] + table = [ln for ln in lines if ln.startswith("|")] + assert len(table) == 2, f"expected a header row + separator, got {table}" + header, separator = table + assert set(separator.replace("|", "").replace("-", "").strip()) == set(), ( + f"second table line is not a separator: {separator!r}" + ) + assert header.count("|") == separator.count("|"), "column counts disagree" + assert lines[0].startswith("# "), "the ledger needs its H1 title" + + +def test_autonomy_log_skeleton_never_opens_the_source(tmp_path): + """The ledger header is generated, not parsed (issue #123). + + Passing a file that does not exist is the strongest form of the assertion: + if the source is never opened, no future edit to the live ledger — a row + inserted above the separator, a reformatted separator, a markdown + formatter's reflow — can change what the template ships. + """ + missing = tmp_path / "autonomy_log.md" # deliberately not created + body = spawn.autonomy_log_body(missing) + assert body == spawn.AUTONOMY_LOG_TEMPLATE + assert body.rstrip().endswith("|"), "the table header/separator is missing" + + +@pytest.mark.parametrize( + "ledger", + [ + # Well-formed: the case that happened to work before. + "| date | task |\n|------|------|\n| 2026-01-01 | LEAKME |\n", + # A row above the separator — copied by the old parse. + "| date | task |\n| 2026-01-01 | LEAKME |\n|------|------|\n", + # Separator reformatted — the old parse never broke, copying everything. + "| date | task |\n| --- | --- |\n| 2026-01-01 | LEAKME |\n", + # No separator at all. + "| date | task |\n| 2026-01-01 | LEAKME |\n", + # Prose above the table, as the real ledger has. + "# Log\n\nsome prose\n\n| date |\n|---|\n| 2026-01-01 LEAKME |\n", + ], +) +def test_autonomy_log_skeleton_is_constant_whatever_the_ledger(tmp_path, ledger): + """Shape assumptions about the live file are exactly what leaked before.""" + src = tmp_path / "autonomy_log.md" + src.write_text(ledger) + assert "LEAKME" not in spawn.autonomy_log_body(src) + assert spawn.autonomy_log_body(src) == spawn.AUTONOMY_LOG_TEMPLATE + + def test_canary_scan_catches_a_leaked_registry_slug(tmp_path): """The scan must flag a person-name token, not just a science dataset.""" (tmp_path / "planned.md").write_text(f"## {NAME_TOKEN}-audit-phases\n") @@ -250,7 +335,17 @@ def fake_workspace(tmp_path): "active.md": ledger, "planned.md": ledger, "parked.md": ledger, "condemned.md": ledger, "ideas.md": f"- {LIVE_MARKER} idea line.\n", "queue.md": ledger, - "autonomy_log.md": f"| a | b |\n|---|---|\n| {LIVE_MARKER} | row |\n", + # Hostile on BOTH known axes (issue #123): a live row ABOVE the + # separator, and the separator itself cosmetically reformatted to + # `| --- |`. The old parse copied lines until one started with + # "|---", so the first leaked that row and the second never broke + # at all — it copied the entire ledger. + "autonomy_log.md": ( + f"| date | task |\n" + f"| {LIVE_MARKER} | row above the separator |\n" + f"| --- | --- |\n" + f"| {LIVE_MARKER} | row below the separator |\n" + ), "README.md": "# Mind\n", "AGENTS.md": "# Agents\n", "REFERENCE.md": "# Ref\n", "ROUTING.md": "# Routing\n", "CLAUDE.md": "# Claude\n", "LICENSE": "MIT\n", ".gitignore": "tmp/\n",