From 390ad58a813bf75998d283e03973796a9ec4ac60 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 29 Oct 2026 13:31:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20cortex.py=20birth=20=E2=80=94=20append?= =?UTF-8?q?=20a=20project's=20row=20and=20open=20its=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second verb that writes projects.yaml (with retire), the only one that adds a row. Refuses a taken key or local_path, a double quote, backslash or newline; appends the row in PROJECT_FIELDS order, reads it back and compares every field, restoring projects.yaml on any problem or a failed ledger. The diff is a birth to ledger_merge.py, so it lands by itself. Docs: AGENTS.md, REFERENCE.md "Birth", schema decision 63. Refs PyAutoLabs/PyAutoBrain#545 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++- REFERENCE.md | 12 ++++ docs/schema_decisions.md | 16 +++++ scripts/cortex.py | 127 ++++++++++++++++++++++++++++++++++++++- tests/test_cortex.py | 65 +++++++++++++++++++- 5 files changed, 225 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b10cd85..20f9a26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` — sets the ledger's `Issue:`. - - `retire --why ""` — the only verb that writes - `projects.yaml`: the row's `status:` becomes `retired`, its `note:` + - `birth --local-path … --ral-root … --sync-verbs a,b + --summary "" [...]` — 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 --why ""` — 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 diff --git a/REFERENCE.md b/REFERENCE.md index dad0813..d9490b2 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -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 `///`, then registered with +`pyauto-brain cortex birth --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-`. `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/.md` — is a birth to `ledger_merge.py`, +so the branch lands on `main` by itself. + ### `checkin.yaml` ```yaml diff --git a/docs/schema_decisions.md b/docs/schema_decisions.md index 286efa0..86147b5 100644 --- a/docs/schema_decisions.md +++ b/docs/schema_decisions.md @@ -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-`, 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. diff --git a/scripts/cortex.py b/scripts/cortex.py index 9001393..53ff1f2 100644 --- a/scripts/cortex.py +++ b/scripts/cortex.py @@ -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.""" @@ -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 # --------------------------------------------------------------------------- # @@ -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 @@ -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) diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 84ac0b2..9fdc19a 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -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): @@ -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