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
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ not re-derive them.
project's GitHub issue (Now, Runs, the last five entries) between two
markers; the Brain's `cortex issue --apply` writes it there.
- `link <project> <Repo#N | URL | none>` — sets the ledger's `Issue:`.
- `retire <project> --why "<one line>"` — the only verb that writes
`projects.yaml`: the row's `status:` becomes `retired`, its `note:`
- `birth <project> --local-path … --ral-root … --sync-verbs a,b
--summary "<one line>" [...]` — appends one row and opens its ledger: an
add-only diff that `ledger_merge.py` classifies as a birth, so it lands by
itself. It refuses a taken key or `local_path` and restores
`projects.yaml` if the row or the ledger fails. The Brain's `cortex
birth` derives the fields from a scaffolded project folder and calls it.
- `retire <project> --why "<one line>"` — with `birth`, the only verbs that
write `projects.yaml`: the row's `status:` becomes `retired`, its `note:`
records why, and the ledger logs it. It refuses while the ledger still
lists a run.
- **`dashboard.md` / `dashboard.html`** — GENERATED, never hand-edited: the
Expand Down
12 changes: 12 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ outside the Science tree (repo-backed roots under the workspace) are exempt
from the category rules. It writes nothing; `census` and `checkin` print one
summary line, off the laptop a skip.

### Birth

A new project is scaffolded by its domain assistant (`start-new-project`)
into `<Science root>/<active|development>/<key>/`, then registered with
`pyauto-brain cortex birth <key> --category … --assistant … --summary …`,
which derives the row from the folder and the other rows and writes it
through `scripts/cortex.py birth` on `claude/birth-<key>`. `cortex.py birth`
appends the row in field order at the end of `projects.yaml`, reads it back
(restoring the file on any problem) and opens the ledger with `new`. The
diff — one row plus `projects/<key>.md` — is a birth to `ledger_merge.py`,
so the branch lands on `main` by itself.

### `checkin.yaml`

```yaml
Expand Down
16 changes: 16 additions & 0 deletions docs/schema_decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,19 @@ Choices made in phase 2 — the conductor, the board and the workflows
`Workspace\Science`, every `local_path` went stale and `pull` quietly
reached 3 of 12 active projects for weeks; nothing in the organism
compared the map with the territory.

63. **A project is born by its assistant and registered by the Cortex**
(2026-10-29, PyAutoBrain#545). `scripts/cortex.py birth` appends one row
and opens its ledger — with `retire`, the only verbs that write
`projects.yaml`, and the only one that adds a row. The Brain's `cortex
birth` derives that row from a project folder the domain assistant's
`start-new-project` already scaffolded, cuts `claude/birth-<key>`, and
pushes only when `ledger_merge.py classify --base origin/main` calls the
commit a birth (the add-only rule in `scripts/ledger_merge.py`,
2026-09-17), so it lands by itself.
Scaffolding stays with the assistants: they are public and must not
learn the private Science tree, and a second, Cortex-owned project
template would drift from theirs.
Why: projects were started by hand-copying an older one and never got a
row; `drift` (decision 62) now reports such a folder as unregistered, and
birth is the one step that prevents it.
127 changes: 125 additions & 2 deletions scripts/cortex.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ def cmd_issue(args) -> int:
# --------------------------------------------------------------------------- #
def retire_project(root: Path, key: str, why: str, today: date) -> str:
"""Flip one `projects.yaml` row to `status: retired` and stamp the reason
on its `note:`. The only verb that writes `projects.yaml`. It does not
on its `note:`. With `birth`, the only verbs that write `projects.yaml`. It does not
delete the row (it is the one record of where the data lives), it does not
touch the ledger file (history is not rewritten by a change of status), and
it refuses while the ledger still lists a run on the cluster."""
Expand Down Expand Up @@ -821,6 +821,91 @@ def retire_project(root: Path, key: str, why: str, today: date) -> str:
return f"retired {key}"


def _yaml_scalar(field: str, value: str) -> str:
"""A `projects.yaml` scalar: bare, or `"..."` (no escapes) when it holds
`#`, `:` or edge spaces. A double quote, a backslash (YAML would read it
as an escape inside quotes) or a newline cannot be written."""
value = str(value)
if '"' in value or "\\" in value or "\n" in value:
raise CortexError(f"{field} cannot hold a double quote, a backslash or a "
"newline (projects.yaml quotes with no escapes)")
if not value.strip():
raise CortexError(f"{field} is empty")
if "#" in value or ":" in value or value != value.strip():
return f'"{value}"'
return value


def birth_project(root: Path, key: str, row: dict, summary: str, today: date, *,
issue: str = "none", now: str = "") -> str:
"""Append one `projects.yaml` row and open its ledger — a project birth.

The second verb that writes `projects.yaml` (with `retire`), and the only
one that adds a row. It refuses a key or a `local_path` already in the
map, writes the row in `PROJECT_FIELDS` order at the end of the file,
reads the file back and restores it on any problem, then opens the ledger
with `new_ledger`. The diff is add-only — a row plus its ledger — which
`ledger_merge.py` classifies as a birth, so it lands by itself."""
if not PROJECT_KEY_RE.match(key or ""):
raise CortexError(f"{key!r} is not a project key (^[a-z][a-z0-9_]*$)")
projects, problems = load_projects(root)
if problems:
raise CortexError("projects.yaml does not parse clean — run `check` "
"and fix it before a birth: " + "; ".join(problems))
if key in projects:
raise CortexError(f"{key} is already a projects.yaml key")
unknown = set(row) - set(PROJECT_FIELDS + PROJECT_OPTIONAL_FIELDS)
missing = [f for f in PROJECT_FIELDS if not row.get(f)]
if unknown or missing:
raise CortexError("a birth row needs every field and no others — "
+ "; ".join([f"missing {', '.join(missing)}"] * bool(missing)
+ [f"unknown {', '.join(sorted(unknown))}"] * bool(unknown)))
local = row["local_path"].rstrip("/")
for other, r in projects.items():
if (r.get("local_path") or "").rstrip("/") == local:
raise CortexError(f"{local} is already the local_path of {other}")
if (root / LEDGER_DIR / f"{key}.md").exists():
raise CortexError(f"{LEDGER_DIR}/{key}.md already exists")

lines = [f"{key}:"]
for field in PROJECT_FIELDS + PROJECT_OPTIONAL_FIELDS:
if field not in row:
continue
value = row[field]
if field == "sync_verbs":
verbs = list(value)
if not verbs or not all(re.match(r"^[a-z][a-z0-9-]*$", v) for v in verbs):
raise CortexError(f"sync_verbs must be bare words, not {verbs!r}")
lines.append(f" sync_verbs: [{', '.join(verbs)}]")
else:
lines.append(f" {field}: {_yaml_scalar(field, value)}")

path = root / "projects.yaml"
original = path.read_text(encoding="utf-8")
path.write_text(original.rstrip("\n") + "\n\n" + "\n".join(lines) + "\n",
encoding="utf-8")
rows, problems = load_projects(root)
back = rows.get(key, {})
differs = [f for f in row if (back.get(f) != list(row[f]) if f == "sync_verbs"
else back.get(f) != str(row[f]))]
if problems or not back or differs:
path.write_text(original, encoding="utf-8")
if problems:
why = "; ".join(problems)
elif not back:
why = f"{key} is missing"
else:
why = f"{key} came back with {', '.join(differs)} changed"
raise CortexError("the row would not read back as written — projects.yaml "
f"is unchanged: {why}")
try:
new_ledger(root, key, summary, today, issue=issue, now=now)
except CortexError:
path.write_text(original, encoding="utf-8")
raise
return f"born {key}: projects.yaml row + {LEDGER_DIR}/{key}.md"


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -857,6 +942,22 @@ def cmd_now(args) -> int:
return 0


def cmd_birth(args) -> int:
row = {
"remote": args.remote, "local_path": args.local_path, "ral_root": args.ral_root,
"mirror": args.mirror, "sync_cli": args.sync_cli,
"sync_verbs": [v.strip() for v in args.sync_verbs.split(",") if v.strip()],
"ledger": args.ledger, "assistant": args.assistant,
"witness_file": args.witness_file, "partition": args.partition,
"status": args.status,
}
if args.note:
row["note"] = args.note
print(birth_project(args.root, args.project, row, args.summary, _today(args),
issue=args.issue, now=args.now or ""))
return 0


def cmd_retire(args) -> int:
print(retire_project(args.root, args.project, args.why, _today(args)))
return 0
Expand Down Expand Up @@ -933,7 +1034,29 @@ def build_parser() -> argparse.ArgumentParser:
_common(p)
p.set_defaults(func=cmd_link)

p = sub.add_parser("retire", help="retire a project's row (the only verb that writes projects.yaml)")
p = sub.add_parser("birth", help="add a project's row and open its ledger (a birth; "
"lands by itself)")
p.add_argument("project")
p.add_argument("--local-path", required=True)
p.add_argument("--ral-root", required=True)
p.add_argument("--remote", default="none")
p.add_argument("--mirror", default="none")
p.add_argument("--sync-cli", default="hpc/sync")
p.add_argument("--sync-verbs", required=True, help="comma-separated")
p.add_argument("--ledger", default="wiki/project/state.md")
p.add_argument("--assistant", default="none")
p.add_argument("--witness-file", default="results/**/*.json")
p.add_argument("--partition", default="ral", choices=PARTITIONS)
p.add_argument("--status", default="active", choices=PROJECT_STATUSES)
p.add_argument("--note")
p.add_argument("--summary", required=True, help=f"one line, at most {SUMMARY_MAX_WORDS} words")
p.add_argument("--issue", default="none", help="Repo#N or an issue URL (default none)")
p.add_argument("--now", help="the opening `## Now` text")
_common(p, dated=True)
p.set_defaults(func=cmd_birth)

p = sub.add_parser("retire", help="retire a project's row (with `birth`, the only verbs "
"that write projects.yaml)")
p.add_argument("project")
p.add_argument("--why", required=True)
_common(p, dated=True)
Expand Down
65 changes: 64 additions & 1 deletion tests/test_cortex.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ def test_retire_preserves_every_other_byte_of_projects_yaml(tmp_path):
def test_every_verb_is_registered():
parser = cortex.build_parser()
verbs = set(parser._subparsers._group_actions[0].choices)
assert verbs == {"check", "new", "run", "running", "done", "log", "now", "issue", "link", "retire"}
assert verbs == {"check", "new", "run", "running", "done", "log", "now", "issue", "link", "retire", "birth"}


def test_a_bad_root_is_a_usage_error(tmp_path):
Expand All @@ -439,3 +439,66 @@ def test_today_is_injectable_and_validated(tmp_path):
assert r.returncode == 1 and "--today must be YYYY-MM-DD" in r.stderr
assert _run("log", "example", "x", root=root).returncode == 0
assert _ledger(root).log[0].date == date.today().isoformat()


BIRTH = ("birth", "fresh", "--local-path", "/tmp/sci/active/fresh",
"--ral-root", "/mnt/ral/u/active/fresh", "--sync-verbs", "push,pull,jobs",
"--assistant", "autolens_assistant", "--summary", "A new project",
"--today", TODAY)


def test_birth_appends_one_row_and_opens_its_ledger_byte_for_byte(tmp_path):
root = _copy(tmp_path)
before = (root / "projects.yaml").read_text()
r = _run(*BIRTH, root=root)
assert r.returncode == 0, r.stderr
after = (root / "projects.yaml").read_text()
assert after.startswith(before.rstrip("\n"))
rows, _ = cortex.load_projects(root)
assert rows["fresh"]["local_path"] == "/tmp/sci/active/fresh"
assert rows["fresh"]["sync_verbs"] == ["push", "pull", "jobs"]
assert rows["fresh"]["status"] == "active"
assert _ledger(root, "fresh").log[0].text() == "ledger opened"
assert _problems(root) == []


def test_birth_refuses_a_taken_key_a_taken_path_and_a_bad_value(tmp_path):
root = _copy(tmp_path)
assert _run(*BIRTH, root=root).returncode == 0
snapshot = (root / "projects.yaml").read_text()
r = _run(*BIRTH, root=root)
assert r.returncode == 1 and "already a projects.yaml key" in r.stderr
r = _run("birth", "twin", *BIRTH[2:], root=root)
assert r.returncode == 1 and "already the local_path of fresh" in r.stderr
r = _run("birth", "Bad", *BIRTH[2:], root=root)
assert r.returncode == 1 and "not a project key" in r.stderr
r = _run("birth", "quoted", "--local-path", '/tmp/a"b', "--ral-root", "/r",
"--sync-verbs", "pull", "--summary", "x", root=root)
assert r.returncode == 1 and "double quote" in r.stderr
assert (root / "projects.yaml").read_text() == snapshot


def test_birth_quotes_a_value_holding_a_colon_and_reads_it_back(tmp_path):
root = _copy(tmp_path)
r = _run(*BIRTH, "--note", "born 2026-10-29: from autolens_assistant", root=root)
assert r.returncode == 0, r.stderr
rows, _ = cortex.load_projects(root)
assert rows["fresh"]["note"] == "born 2026-10-29: from autolens_assistant"
assert _problems(root) == []


def test_a_failed_ledger_restores_projects_yaml(tmp_path):
root = _copy(tmp_path)
snapshot = (root / "projects.yaml").read_text()
r = _run(*BIRTH[:-4], "--summary", " ".join(["word"] * 40), root=root)
assert r.returncode == 1 and "words" in r.stderr
assert (root / "projects.yaml").read_text() == snapshot
assert not (root / "projects" / "fresh.md").exists()


def test_birth_refuses_a_backslash_that_yaml_would_read_as_an_escape(tmp_path):
root = _copy(tmp_path)
snapshot = (root / "projects.yaml").read_text()
r = _run(*BIRTH, "--note", "see: C:\\new dir", root=root)
assert r.returncode == 1 and "backslash" in r.stderr
assert (root / "projects.yaml").read_text() == snapshot
Loading