Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/pyautobrain/spawn_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` + `<!-- schema: REFERENCE.md -->`); 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` + `<!-- schema: REFERENCE.md -->`). **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 |
Expand Down
48 changes: 40 additions & 8 deletions scripts/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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):
Expand Down
97 changes: 96 additions & 1 deletion tests/test_spawn_privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
Loading