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
5 changes: 4 additions & 1 deletion .github/workflows/spawn_drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest
# pyyaml: the contract tests parse generated workflows. Declared here
# because setup-python gives a clean interpreter — relying on the runner
# image having PyYAML would fail collection, not just a test.
- run: pip install pytest pyyaml
- name: spawn privacy invariant + template contract
# Whole dir, not one file: a suite named in the workflow by filename
# silently stops covering anything added beside it.
Expand Down
39 changes: 37 additions & 2 deletions docs/pyautobrain/spawn_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ deliberately, never silently shipped into a template.
| 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 |
| 7 | `active/ complete/ z_features/ z_vault/ autoprompt/` + instance reference docs (`docs/**` now holds only reference material like `spawn_spec.md`) + instance root docs (`dashboard.md`, `overview.md`) + legacy pre-migration prompt dirs (`autolens/`) | DROP (lifecycle records + instance content) |
| 8 | `skills/**`, `policy/**` | KEEP verbatim (`OWNERSHIP.md`, `create_issue/` are generic; `policy/` is org-agnostic safety text) |
| 9 | `.github/**` | KEEP verbatim EXCEPT workflows that reference live secrets/repos beyond the org placeholder — those SUBSTITUTE `PyAutoLabs` → `YOURORG` and keep |
| 9 | `.github/**` | **Per file, by the succeed-on-a-fresh-repo test below.** Not a blanket rule: owner substitution alone does NOT make a workflow work, because `YOURORG` is a literal placeholder — the template's own `spawn_drift` run failed `repository 'https://github.com/YOURORG/PyAutoMind/' not found`. See rules 9a–9c |
| 9a | `.github/workflows/lifecycle_drift.yml` | KEEP verbatim — operates only on its own repo (checkout + local scripts) and contains no owner reference at all, so it needs no substitution and succeeds unmodified in a fresh org. Empirically the one green workflow in the template's run history |
| 9b | `.github/workflows/spawn_drift.yml` | SPECIAL → keep with the `schedule:` trigger **stripped**. The generator machinery is generic and worth shipping, but a fresh org has no published `*-template` repos, so a weekly run would fail until it does. `pull_request` + `workflow_dispatch` remain; a comment says to re-add the schedule once templates are published |
| 9c | `.github/workflows/{morning_status,morning_health,arxiv_papers}.yml`, `.github/scripts/**` | DROP — instance automation. They hardcode sibling repo lists, organ-specific workflow names (`PyAutoHeart`/`PyAutoBrain`/`PyAutoHands`), org secrets (`PYAUTO_PAPERS_WEBHOOK_URL`, `CLAUDE_CODE_OAUTH_TOKEN`) and, in `arxiv_fetch.py`, strong-lensing search vocabulary plus dated incident notes. All 13 failing runs in the published template came from these |
| 9d | any other `.github/**` | **No catch-all rule — UNMATCHED by design.** A fallback here is fail-*open*: a workflow added to Mind later would ride it into the template carrying whatever schedule and secrets it has, which is precisely the defect 9a–9c fix. A new `.github` file must fail the run and get an explicit entry above, like every other new file class |
| 10 | `.claude/**`, `.codex/**` | DROP — agent-discovery symlinks are install artifacts recreated by the PyAutoBrain installer, not source content |

### PyAutoMemory → PyAutoMemory-template
Expand All @@ -58,7 +62,38 @@ deliberately, never silently shipped into a template.
| 5 | `index.md` | SUBSTITUTE → skeleton: intro line + a table with the single `wiki/example/` row + "add sub-wikis following the same schema" |
| 6 | `reading-queue.md` | EMPTY → header + section-format comment |
| 7 | `README.md` | GENERATE → a template README asset held inside spawn (text surgery on the live README is brittle across edits; the asset keeps `--check` round-trips stable) |
| 8 | `.github/**` | KEEP with owner substitution; `logo.png` (instance branding) | DROP. The old legacy-family DROP rules (root `*.bib`, PDFs, `CTI/` etc.) are retired — those files are gone from the live repo and PyAutoMemory's structure lint (`make validate-structure`, in CI) prevents their return at the source |
| 8 | `.github/workflows/validate.yml` | KEEP with owner substitution — self-contained (no schedule, no secrets, no sibling repos), so it clears the fresh-repo invariant. As in the Mind table's rule 9d there is **no `.github/**` catch-all**: a new Memory workflow is UNMATCHED and needs an explicit decision. `logo.png` (instance branding) | DROP. The old legacy-family DROP rules (root `*.bib`, PDFs, `CTI/` etc.) are retired — those files are gone from the live repo and PyAutoMemory's structure lint (`make validate-structure`, in CI) prevents their return at the source |

**Fresh-repo invariant (hard rule, rule 9):** no workflow shipped into a
template may **fail unattended** on a freshly-spawned repo. A job that runs on
its own and cannot succeed is not "configuration the new owner will finish" —
it fails on their repo and emails them, forever, for work they never asked for.
That is what produced the published template's 13 failing runs.

Precisely, a shipped workflow must satisfy **both**:

1. **No unattended trigger it cannot satisfy.** No `schedule:`, and nothing
else that fires without a human, unless it succeeds with no secrets and no
sibling repos. `lifecycle_drift.yml` keeps its `push:` trigger because it
genuinely does succeed (rule 9a).
2. **No configured secret.** `secrets.GITHUB_TOKEN` is auto-provided by Actions
and allowed; anything the org must create is not.

Deliberately **not** required: that every human-invoked path succeeds.
`spawn_drift.yml` ships under rule 9b with its schedule stripped but still
clones `<owner>/*-template`, so a manual `workflow_dispatch` in an org that has
not published templates yet will fail. That is a human asking for it, with the
generated comment explaining why — not an inherited job failing on its own. Do
not "fix" this by asserting no `YOURORG/` reference: that would forbid shipping
the generator machinery at all.

When in doubt DROP. A spawned org that later builds the same organs can copy a
workflow across deliberately; it cannot easily discover why an inherited one
keeps failing.

The implementation must include tests asserting (1) and (2) over every shipped
workflow, and that a NEW `.github` file is UNMATCHED rather than classified by
a catch-all (rule 9d).

**Privacy invariant (hard rule):** no live wiki page, bibliography entry,
reading-queue line, prompt, or registry entry may ever appear in a template
Expand Down
130 changes: 128 additions & 2 deletions scripts/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,29 @@
# Instance root docs:
("dashboard.md", "DROP"), ("overview.md", "DROP"),
("skills/*", "KEEP"), ("policy/*", "KEEP"),
(".github/*", "KEEP_SUB"),
# .github is decided PER FILE by the spec's fresh-repo invariant (rule 9):
# a shipped workflow must succeed on a freshly-spawned repo with no secrets
# and no sibling repos. Owner substitution alone does NOT achieve that —
# YOURORG is a literal placeholder, and the template's own spawn_drift run
# failed with `repository 'https://github.com/YOURORG/PyAutoMind/' not
# found`. Anything cloning or querying a sibling repo is broken on arrival.
#
# Ordered before the .github/scripts DROP and each other; first match wins.
(".github/workflows/lifecycle_drift.yml", "KEEP"), # 9a: self-contained
(".github/workflows/spawn_drift.yml", "SPECIAL:unscheduled"), # 9b
# 9c — instance automation: sibling repo lists, organ-specific workflow
# names, org secrets, strong-lensing vocabulary. Every one of the 13 failing
# runs in the published template came from these.
(".github/workflows/morning_status.yml", "DROP"),
(".github/workflows/morning_health.yml", "DROP"),
(".github/workflows/arxiv_papers.yml", "DROP"),
(".github/scripts/*", "DROP"),
# NO `.github/*` catch-all, deliberately. A catch-all is fail-OPEN: a new
# Mind workflow would ride it into the template carrying whatever schedule
# and secrets it has — the exact defect this rule exists to fix. With no
# fallback, a new .github file is UNMATCHED, spawn fails, and a human adds
# an explicit rule 9 entry. Same doctrine as every other new file class:
# "extend the spec's tables, then mirror here", never classify ad hoc.
# Agent-discovery symlinks are install artifacts (recreated by the
# PyAutoBrain installer), not source content — drop them from the template.
(".claude/*", "DROP"), (".codex/*", "DROP"),
Expand All @@ -87,7 +109,11 @@
# Same org-wide pointer docs as MIND_RULES — owner substitution.
("AI_POLICY.md", "KEEP_SUB"), ("CONTRIBUTING.md", "KEEP_SUB"),
("bibliography/*", "EMPTY"),
(".github/*", "KEEP_SUB"),
# Same fail-closed discipline as MIND_RULES (spec rule 9d). validate.yml is
# self-contained — no schedule, no secrets, no sibling repos — so it clears
# the fresh-repo invariant and ships. No catch-all: a new Memory workflow is
# UNMATCHED and gets an explicit decision.
(".github/workflows/validate.yml", "KEEP_SUB"),
# The shared wiki schema is template content; the sub-wikis are instance
# content (the generator stamps an empty wiki/example/ instead).
("wiki/CLAUDE.md", "KEEP"),
Expand Down Expand Up @@ -408,6 +434,104 @@ def empty_body(src, rel=None):
return header + "\n\n<!-- emptied by spawn; schema: REFERENCE.md -->\n"


def unscheduled_workflow_body(src):
"""Drop a workflow's `schedule:` trigger (spec rule 9b).

`spawn_drift.yml` is generic machinery worth shipping, but its scheduled run
clones `<owner>/*-template` repos a freshly-spawned org does not have yet —
so on a schedule it would fail weekly and email the new owner. Stripping the
trigger keeps the capability (`workflow_dispatch`, `pull_request`) without
the noise.

Structural, not textual: only the `schedule:` key that is a DIRECT CHILD of
the top-level `on:` mapping is removed, together with its block. Every other
line is passed through unchanged, except that line endings are normalised to
`\\n` (`splitlines()` discards the originals).

Three near-misses this scoping exists to avoid, all found by testing rather
than by reading:
* a `schedule:` line inside a `run: |` shell block is script text, not a
trigger — a bare line match rewrote it into comments;
* `on.workflow_call.inputs.schedule` is a legitimate input, not a trigger,
so depth matters, not just "somewhere under `on:`";
* a comment sitting at the SAME indent as `schedule:` used to end block
consumption early, orphaning the `- cron` line and emitting invalid YAML.

Anything this cannot handle confidently (flow style, no schedule at all)
raises instead of guessing.
"""
def substantive(idx):
"""Next line that is neither blank nor a comment, or None."""
while idx < len(lines):
s = lines[idx].strip()
if s and not s.startswith("#"):
return idx
idx += 1
return None

lines = substitute_owner(src.read_text(errors="replace")).splitlines()
out, i, dropped = [], 0, False
on_child_indent = None # set once we know the `on:` block's child depth
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
indent_here = len(line) - len(stripped)
is_blank_or_comment = (not stripped) or stripped.startswith("#")

# Enter/leave the top-level `on:` mapping.
if indent_here == 0 and not is_blank_or_comment:
key = stripped.split(":", 1)[0].strip().strip("\"'")
if key == "on" and not stripped.split(":", 1)[1].strip():
nxt = substantive(i + 1)
on_child_indent = (
len(lines[nxt]) - len(lines[nxt].lstrip()) if nxt is not None else None
)
else:
# Any other top-level key ends the block. Flow-style `on: {...}`
# lands here too and never sets a child indent, so it reaches
# the loud failure below rather than being edited blind.
on_child_indent = None

if (
on_child_indent is not None
and indent_here == on_child_indent # DIRECT child of `on:`
and stripped.startswith("schedule:")
and not stripped.startswith("#")
):
pad = " " * indent_here
out.append(pad + "# schedule: removed by spawn — a fresh org has")
out.append(pad + "# no published *-template repos yet, so the run")
out.append(pad + "# would fail until it does. Re-add once you publish.")
i += 1
# Consume the block. Blanks and comments belong to it only when
# deeper content follows; otherwise they introduce the NEXT key.
while i < len(lines):
s = lines[i].strip()
if not s or s.startswith("#"):
nxt = substantive(i)
if nxt is not None and (
len(lines[nxt]) - len(lines[nxt].lstrip())
) > indent_here:
i += 1
continue
break
if (len(lines[i]) - len(lines[i].lstrip())) <= indent_here:
break
i += 1
dropped = True
continue
out.append(line)
i += 1
if not dropped:
# The trigger this rule exists to remove is gone — the rule is now
# silently a no-op, which is how a guard rots. Fail loudly instead.
raise SystemExit(
f"spawn: {src.name} has no top-level 'on: schedule:' trigger to strip.\n"
f" SPECIAL:unscheduled is now a no-op — re-check spawn_spec.md rule 9b."
)
return "\n".join(out) + "\n"


def autonomy_log_body(src):
lines = src.read_text(errors="replace").splitlines()
kept = []
Expand Down Expand Up @@ -452,6 +576,8 @@ def generate_mind(mind_root, out_dir):
dest.write_text(substitute_owner(src.read_text(errors="replace")))
elif action == "EMPTY":
dest.write_text(empty_body(src, rel))
elif action == "SPECIAL:unscheduled":
dest.write_text(unscheduled_workflow_body(src))
elif action == "SPECIAL:autonomy_log":
dest.write_text(autonomy_log_body(src))
elif action == "SPECIAL:body_map":
Expand Down
Loading
Loading