From 3d6f51289890d38d748729f012bfe54363a26f43 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 4 Sep 2026 16:06:52 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20`cortex.py=20retire=20=20--why?= =?UTF-8?q?=20"=E2=80=A6"`=20=E2=80=94=20a=20fourth=20project=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects.yaml` had a closed `active | dormant | planned` vocabulary, a parser and no writer, so a project that was over stayed dormant forever and the board had nowhere to send a human who wanted to end one. `status: retired` is the fourth value, and `retire` is the one verb that writes this file. It flips the row's `status:` and rewrites (or appends) its `note:` as `"retired : "` โ€” two lines edited in place, every other byte of the file preserved, and the result re-parsed before it is kept, the original bytes restored if it does not read back clean. Three things it deliberately does not do. It does not delete the row: that row is the only record of where the project's data lives. It does not touch a phase or a ruling โ€” `rulings/` is append-only and a change of status is not a rewrite of history. And it does not retire over live work: every state outside `accepted | rerun | dropped | planned` is an unfinished question, so the refusal names each ` โ€” ` and the human rules or drops them first. `planned` phases are unasked questions and may stay. Docs: REFERENCE.md gains the retirement paragraph under `projects.yaml` and a verb-reference entry; AGENTS.md lists the verb; the `projects.yaml` header comment carries the fourth value. The dashboards are regenerated against the Brain's paired change โ€” every dormant row now shows a ๐Ÿ“‹ retire chip whose payload is this command. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JYs4cX2wT5vsZScjLNLZif --- AGENTS.md | 5 ++ REFERENCE.md | 25 ++++++++- dashboard.html | 18 +++---- dashboard.md | 76 ++++++++++++++++++++++---- projects.yaml | 2 +- scripts/cortex.py | 97 +++++++++++++++++++++++++++++++-- tests/test_cortex.py | 126 +++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 319 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7002d36..18402cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,11 @@ human's turn. Full rules in [rulings/AGENTS.md](rulings/AGENTS.md). REFERENCE.md; refuses the ruling edges. - `new --phase โ€ฆ` โ€” writes a phase file from the template. + - `retire --why ""` โ€” the only verb that writes + `projects.yaml`: the row's `status:` becomes `retired` and its + `note:` records `retired : `. The row, its phases and its + rulings all stay; it refuses while any phase of that project is + outside `accepted | rerun | dropped | planned`. - **`dashboard.md` / `dashboard.html`** โ€” GENERATED, never hand-edited: the board, rendered by the Brain's cortex conductor and self-healed on `main` (see "Driving the Cortex" below). They are ledger for the merge gate. diff --git a/REFERENCE.md b/REFERENCE.md index 999418a..68dadc7 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -188,7 +188,7 @@ PyAutoCortex/ โ”œโ”€โ”€ docs/schema_decisions.md โ† every dated choice the epic did not fix โ”œโ”€โ”€ policy/never_rewrite_history.md policy/remote_sessions.md โ† copies of the Mind's; spliced into AGENTS.md โ”‚ -โ”œโ”€โ”€ scripts/cortex.py โ† check ยท gates ยท rule ยท move ยท new (stdlib + PyYAML) +โ”œโ”€โ”€ scripts/cortex.py โ† check ยท gates ยท rule ยท move ยท new ยท retire (stdlib + PyYAML) โ”œโ”€โ”€ scripts/ledger_merge.py โ† the default-deny ledger classifier (+ append-only on rulings/, batches/) โ”œโ”€โ”€ tests/test_cortex.py tests/test_ledger_merge.py โ”œโ”€โ”€ tests/fixtures/skeleton/ โ† one project, one phase per state, five rulings โ€” the witness @@ -387,10 +387,24 @@ listed here. A document PyYAML cannot read is reported as one problem. `local_path` (absolute laptop path โ€” the Cortex-only exception to the workspace-paths rule), `ral_root`, `mirror` (path | `none`), `sync_cli`, `sync_verbs`, `ledger`, `witness_file` (glob), `partition` - (`gpu | ral | both`), `status` (`active | dormant | planned`). + (`gpu | ral | both`), `status` + (`active | dormant | planned | retired`). - `note` (free text) is the **one optional field**: a row may omit it, an empty `note:` is drift, and any other field is still an unknown-field error. A note holding `:` or `#` is quoted like any other scalar. +- **Retiring a project.** `cortex.py retire --why ""` is + the one verb that writes this file: it flips the row's `status:` to + `retired` and rewrites (or appends) its `note:` as + `"retired : "`. The **row stays** โ€” it is the only record of + where that project's data lives โ€” and no phase or ruling is touched; + `rulings/` is append-only and a change of status is not a rewrite of + history. It refuses while the project holds a phase in any state + outside `accepted | rerun | dropped | planned`, naming each one, so the + live questions are ruled or dropped first; `planned` phases are unasked + questions and may stay. The edit is exactly those two lines โ€” every + other byte of the file is preserved โ€” and the result is re-parsed + before it is kept, the original bytes restored if it does not read + back clean. - Science repos are **not** added to `PyAutoMind/repos.yaml` โ€” that map is the workspace, this one is the science. - A file with no rows parses to an empty map (`{}`); PyYAML returns `None` @@ -493,6 +507,13 @@ Stdlib only; `main(argv)`; no import-time side effects; every verb takes wall by hand โ€” and `--where` (required) as its `where:`; a legacy-born phase refuses `--gates`. Refuses a duplicate phase number, an existing file or an unknown project. +- **`retire --why ""`** โ€” the only verb that writes + `projects.yaml`: `status: retired` plus a `note:` reading + `"retired : "`, two lines edited in place and every other + byte preserved. Refuses an unknown key, an already-retired row, and any + project still holding a phase outside `accepted | rerun | dropped | + planned` (the message names each ` โ€” `). The row, its + phases and its rulings all stay; see `projects.yaml` above. --- diff --git a/dashboard.html b/dashboard.html index ac4c87f..88a9b4d 100644 --- a/dashboard.html +++ b/dashboard.html @@ -238,15 +238,15 @@

Nothing open

- - - - - - - - - + + + + + + + + +
ProjectStatusPhases
euclid_dr1_prelimplannednone
concrdormantnone
cowls_dianadormantnone
ic50_workspacedormantplanned 1
pj011646dormantnone
profilingdormantnone
slope_hierarchydormantplanned 2
subhalo_simulationsdormantnone
ProjectStatusPhasesRetire
euclid_dr1_prelimplannednone
concrdormantnone
cowls_dianadormantnone
ic50_workspacedormantplanned 1
pj011646dormantnone
profilingdormantnone
slope_hierarchydormantplanned 2
subhalo_simulationsdormantnone

Projects markdown version

diff --git a/dashboard.md b/dashboard.md index 2af5c31..4664a4d 100644 --- a/dashboard.md +++ b/dashboard.md @@ -38,16 +38,72 @@ One row per active project โ€” the next thing, what it is holding, and when it l #### Nothing open -| Project | Status | Phases | -|---|---|---| -| euclid_dr1_prelim | planned | none | -| concr | dormant | none | -| cowls_diana | dormant | none | -| ic50_workspace | dormant | planned 1 | -| pj011646 | dormant | none | -| profiling | dormant | none | -| slope_hierarchy | dormant | planned 2 | -| subhalo_simulations | dormant | none | +| Project | Status | Phases | Retire | +|---|---|---|---| +| euclid_dr1_prelim | planned | none | | +| concr | dormant | none | retire โ†“ | +| cowls_diana | dormant | none | retire โ†“ | +| ic50_workspace | dormant | planned 1 | retire โ†“ | +| pj011646 | dormant | none | retire โ†“ | +| profiling | dormant | none | retire โ†“ | +| slope_hierarchy | dormant | planned 2 | retire โ†“ | +| subhalo_simulations | dormant | none | retire โ†“ | + +
๐Ÿ“‹ retire concr + +``` +/cortex โ€” retire the science project concr: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire concr --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire cowls_diana + +``` +/cortex โ€” retire the science project cowls_diana: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire cowls_diana --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire ic50_workspace + +``` +/cortex โ€” retire the science project ic50_workspace: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire ic50_workspace --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire pj011646 + +``` +/cortex โ€” retire the science project pj011646: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire pj011646 --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire profiling + +``` +/cortex โ€” retire the science project profiling: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire profiling --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire slope_hierarchy + +``` +/cortex โ€” retire the science project slope_hierarchy: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire slope_hierarchy --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
+ +
๐Ÿ“‹ retire subhalo_simulations + +``` +/cortex โ€” retire the science project subhalo_simulations: confirm it is holding nothing live, then in PyAutoCortex run `python3 scripts/cortex.py retire subhalo_simulations --why ""`, `python3 scripts/cortex.py check`, `pyauto-brain cortex dashboard --apply`, and push the ledger. The row, its phases and its rulings all stay โ€” only the status and the note change. +``` + +
## Projects diff --git a/projects.yaml b/projects.yaml index 9dcd0a2..f6835a1 100644 --- a/projects.yaml +++ b/projects.yaml @@ -24,7 +24,7 @@ # ledger: # the project's own commentary ledger, relative to local_path # witness_file: # where a run's witness lands, relative to the mirror # partition: gpu | ral | both # which SLURM partition(s) the project runs on -# status: active | dormant | planned +# status: active | dormant | planned | retired # `cortex.py retire` writes the last # note: # OPTIONAL, and the only optional field โ€” free text # # about the row; an empty `note:` is drift # diff --git a/scripts/cortex.py b/scripts/cortex.py index a187612..2c31478 100644 --- a/scripts/cortex.py +++ b/scripts/cortex.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""cortex.py โ€” the Cortex's lifecycle script: check ยท gates ยท rule ยท move ยท new. +"""cortex.py โ€” the Cortex's lifecycle script: check ยท gates ยท rule ยท move ยท new ยท retire. The run-and-ruling registry (project โ†’ phase โ†’ runs โ†’ rulings) is a tree of markdown files with light headers, and this script is the one thing that @@ -19,6 +19,7 @@ python3 scripts/cortex.py rule --body [...] python3 scripts/cortex.py move [...] python3 scripts/cortex.py new --phase [...] + python3 scripts/cortex.py retire --why "" # a project's row Exit codes: 0 = done ยท 1 = drift or a refused edit ยท 2 = bad arguments. """ @@ -99,7 +100,7 @@ #: any field outside these two tuples is still an error. PROJECT_OPTIONAL_FIELDS = ("note",) PARTITIONS = ("gpu", "ral", "both") -PROJECT_STATUSES = ("active", "dormant", "planned") +PROJECT_STATUSES = ("active", "dormant", "planned", "retired") # --------------------------------------------------------------------------- # # grammars (REFERENCE.md) @@ -347,7 +348,7 @@ def _finish_row(key: str, row: dict, problems: "list[str]") -> None: f"not {row['partition']}") if row.get("status") and row["status"] not in PROJECT_STATUSES: problems.append(f"projects.yaml: {key}.status must be " - f"active | dormant | planned, not {row['status']}") + f"active | dormant | planned | retired, not {row['status']}") remote = row.get("remote") if remote and remote != "none" and not re.match(r"^[\w.-]+/[\w.-]+$", remote): problems.append(f"projects.yaml: {key}.remote must be owner/repo or none") @@ -1010,6 +1011,89 @@ def cmd_move(args) -> int: return 0 +# --------------------------------------------------------------------------- # +# retire +# --------------------------------------------------------------------------- # +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`. + + Three things it deliberately does not do. It does not **delete the row**: + that row is the only record of where the project's data lives, and a + retired project still has to be findable. It does not touch a **phase** or + a **ruling**: `rulings/` is append-only and history is not rewritten by a + change of status. And it does not retire over **live work** โ€” every state + outside `RULED_STATES` and `planned` is an unfinished question, so the + refusal names each one and the human rules or drops it first. `planned` + stays: an unasked question costs nothing to leave behind. + + The edit is two lines. Everything else in the file โ€” comments, blank + lines, the order of the rows, the other rows' bytes โ€” is preserved, and + the result is re-parsed before it is kept: a `projects.yaml` this verb + could not read back is restored to the bytes it had. + """ + why = (why or "").strip() + if not why: + raise CortexError("--why must say why, in one line") + if '"' in why or "\n" in why: + raise CortexError("--why cannot hold a double quote or a newline " + "(projects.yaml quotes with no escapes)") + projects, problems = load_projects(root) + if problems: + raise CortexError("projects.yaml does not parse clean โ€” run `check` " + "and fix it before retiring: " + "; ".join(problems)) + if key not in projects: + raise CortexError(f"{key} is not a projects.yaml key") + if projects[key].get("status") == "retired": + raise CortexError(f"{key} is already retired") + phases, _ = load_phases(root) + live = [f"{ph.rel} โ€” {ph.state}" for ph in phases + if ph.project_dir == key + and ph.state not in RULED_STATES | {"planned"}] + if live: + raise CortexError(f"{key} still has live work โ€” rule or drop it " + f"first: {', '.join(live)}") + + path = root / "projects.yaml" + original = path.read_text(encoding="utf-8") + lines = original.split("\n") + starts = [i for i, ln in enumerate(lines) if ln == f"{key}:"] + if len(starts) != 1: + raise CortexError(f"projects.yaml: expected one `{key}:` line, " + f"found {len(starts)}") + start = starts[0] + stop = start + 1 + while stop < len(lines) and lines[stop].startswith(" "): + stop += 1 + block = lines[start:stop] + at_status = [i for i, ln in enumerate(block) if re.match(r"^ status: .*$", ln)] + at_note = [i for i, ln in enumerate(block) if re.match(r"^ note:(?: .*)?$", ln)] + if len(at_status) != 1 or len(at_note) > 1: + raise CortexError(f"projects.yaml: {key} is not one ` status:` line " + f"and at most one ` note:` line") + block[at_status[0]] = " status: retired" + note = f' note: "retired {today.isoformat()}: {why}"' + if at_note: + block[at_note[0]] = note + else: + block.append(note) + lines[start:stop] = block + path.write_text("\n".join(lines), encoding="utf-8") + + rows, problems = load_projects(root) + if problems or rows.get(key, {}).get("status") != "retired": + path.write_text(original, encoding="utf-8") + raise CortexError("the edit would not read back โ€” projects.yaml is " + "unchanged: " + ("; ".join(problems) or + f"{key} did not come back retired")) + return f"retired {key}" + + +def cmd_retire(args) -> int: + print(retire_project(args.root, args.project, args.why, _today(args))) + return 0 + + # --------------------------------------------------------------------------- # # rule # --------------------------------------------------------------------------- # @@ -1317,6 +1401,13 @@ def build_parser() -> argparse.ArgumentParser: n.add_argument("--minutes", type=int, help="Review-minutes seed") n.add_argument("--title", help="the title after `phase :` (default: the slug's words)") n.set_defaults(func=cmd_new) + + t = sub.add_parser("retire", help="flip a project's row to status: retired") + _common(t, dated=True) + t.add_argument("project", help="the projects.yaml key") + t.add_argument("--why", required=True, + help="one line: why the project is being retired") + t.set_defaults(func=cmd_retire) return parser diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 5c41c6b..46badd2 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -249,12 +249,14 @@ def test_missing_projects_field_and_bad_enums(tmp_path): _assert_drift(root, "example is missing ledger", "partition must be gpu | ral | both") -def test_planned_is_a_legal_status(tmp_path): +def test_planned_and_retired_are_legal_statuses(tmp_path): root = _copy(tmp_path) - _edit(root, "projects.yaml", " status: active\n", " status: planned\n") - assert _problems(root) == [] - _edit(root, "projects.yaml", " status: planned\n", " status: someday\n") - _assert_drift(root, "status must be active | dormant | planned") + for status in ("planned", "retired"): + _edit(root, "projects.yaml", " status: active\n", f" status: {status}\n") + assert _problems(root) == [], status + _edit(root, "projects.yaml", f" status: {status}\n", " status: active\n") + _edit(root, "projects.yaml", " status: active\n", " status: someday\n") + _assert_drift(root, "status must be active | dormant | planned | retired") def test_note_is_optional_but_never_empty_and_the_field_set_still_closes(tmp_path): @@ -273,6 +275,120 @@ def test_note_is_optional_but_never_empty_and_the_field_set_still_closes(tmp_pat _assert_drift(root, "example.note is empty") +# --------------------------------------------------------------------------- # +# retire +# --------------------------------------------------------------------------- # +#: The states `retire` allows a project to still hold: the three ruled ones +#: plus `planned`, which is an unasked question. +_QUIET = {"01_scope", "08_accepted", "09_rerun", "10_dropped"} + + +def _quiesce(root: Path, project: str = "example") -> "list[str]": + """Leave `project` holding only ruled and planned phases โ€” dropping the + rulings that named the phases removed, so `check` stays clean.""" + gone = [] + for p in sorted((root / "phases" / project).glob("*.md")): + if p.stem not in _QUIET: + gone.append(p.relative_to(root).as_posix()) + p.unlink() + for r in sorted((root / "rulings").rglob("*.md")): + if any(f"Phase: {g}" in r.read_text() for g in gone): + r.unlink() + assert _problems(root) == [], _problems(root) + return gone + + +def test_retire_flips_the_status_and_stamps_the_note(tmp_path): + """Two lines change and nothing else โ€” the row, its phases and its + rulings all stay, and `check` is clean afterwards.""" + root = _copy(tmp_path) + _quiesce(root) + before = (root / "projects.yaml").read_text().split("\n") + phases_before = sorted(p.name for p in (root / "phases" / "example").glob("*.md")) + assert cortex.retire_project(root, "example", "the question was answered", + date(2026, 9, 4)) == "retired example" + after = (root / "projects.yaml").read_text().split("\n") + assert " status: retired" in after + assert ' note: "retired 2026-09-04: the question was answered"' in after + # every byte outside the two edited lines is identical: the fixture row + # carries no `note:`, so the note is an appended line. + assert [ln for ln in after if not ln.startswith((" status:", " note:"))] == \ + [ln for ln in before if not ln.startswith(" status:")] + assert sorted(p.name for p in (root / "phases" / "example").glob("*.md")) == \ + phases_before + assert _problems(root) == [] + rows, problems = cortex.parse_projects((root / "projects.yaml").read_text()) + assert problems == [] and rows["example"]["status"] == "retired" + + +def test_retire_rewrites_an_existing_note_in_place(tmp_path): + root = _copy(tmp_path) + _quiesce(root) + _edit(root, "projects.yaml", " status: active\n", + ' status: active\n note: "a fact: recorded"\n') + before = (root / "projects.yaml").read_text() + cortex.retire_project(root, "example", "no science since 2026-05", + date(2026, 9, 4)) + after = (root / "projects.yaml").read_text() + assert ' note: "a fact: recorded"' not in after + assert ' note: "retired 2026-09-04: no science since 2026-05"' in after + assert after.count(" note:") == before.count(" note:") == 1 + assert _problems(root) == [] + + +def test_retire_refuses_while_a_phase_is_still_live(tmp_path): + """Every state outside `accepted | rerun | dropped | planned` is an + unfinished question, and the refusal names each one.""" + root = _copy(tmp_path) + before = (root / "projects.yaml").read_text() + with pytest.raises(cortex.CortexError) as e: + cortex.retire_project(root, "example", "why", date(2026, 9, 4)) + message = str(e.value) + assert "still has live work" in message + for name in ("02_gated_on_dev", "03_ready_cleared", "04_submitted_override", + "05_running_array", "06_pulled", "07_awaiting_ruling"): + assert f"phases/example/{name}.md โ€” " in message + # planned and the ruled states are not named โ€” they are allowed to stay + for name in ("01_scope", "08_accepted", "09_rerun", "10_dropped"): + assert f"phases/example/{name}.md" not in message + assert (root / "projects.yaml").read_text() == before + + +def test_retire_refuses_an_unknown_key_a_repeat_and_a_bad_why(tmp_path): + root = _copy(tmp_path) + _quiesce(root) + before = (root / "projects.yaml").read_text() + for why, needle in ((" ", "--why must say why"), + ('a "quoted" reason', "double quote")): + with pytest.raises(cortex.CortexError) as e: + cortex.retire_project(root, "example", why, date(2026, 9, 4)) + assert needle in str(e.value) + with pytest.raises(cortex.CortexError) as e: + cortex.retire_project(root, "nobody", "why", date(2026, 9, 4)) + assert "not a projects.yaml key" in str(e.value) + assert (root / "projects.yaml").read_text() == before + cortex.retire_project(root, "example", "done", date(2026, 9, 4)) + with pytest.raises(cortex.CortexError) as e: + cortex.retire_project(root, "example", "done again", date(2026, 9, 4)) + assert "already retired" in str(e.value) + + +def test_retire_round_trips_through_the_cli(tmp_path): + root = _copy(tmp_path) + r = _run("retire", "example", "--why", "the question was answered", + "--today", TODAY, root=root) + assert r.returncode == 1 and "still has live work" in r.stderr + _quiesce(root) + r = _run("retire", "example", "--why", "the question was answered", + "--today", TODAY, root=root) + assert r.returncode == 0, r.stderr + assert r.stdout.strip() == "retired example" + text = (root / "projects.yaml").read_text() + assert " status: retired" in text + assert f' note: "retired {TODAY}: the question was answered"' in text + assert _run("check", root=root).returncode == 0 + + # --------------------------------------------------------------------------- # # check โ€” phases # --------------------------------------------------------------------------- #