From 3a33540be548820ee213573b69a8f31ac2167d39 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 2 Sep 2026 20:15:49 -0400 Subject: [PATCH] cortex: accept numbered partial review files for a rolling slot (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cortex board is rolling, but its review file was not: `check` demanded one `batches/reviews/.md` per record, so the first sitting closed the slot and a human who ruled on the members that had landed could only rule on a later arrival by editing a ledger file that is already someone's evidence. `batch_problems` now resolves `reviews/-r.md` (N >= 2) to the record `batches/.md` for the member check, accepts either the file's own stem or the slot's in the title, and refuses `-r0`/`-r1` — the first review is `.md`. A record's `- review:` key repeats, one line per review file, and every line must resolve (`_record_values`); the old check saw only the first. `rule --batch ` needed no change: it validates the record, never the review file, so a ruling traced to a `-r2` sitting already names the slot. Doctrine: `batches/AGENTS.md` (a slot closes when nothing is left in a board state, not when the first review lands), `batches/reviews/AGENTS.md` (the numbered-file grammar and the title rule), `batches/packets/TEMPLATE.md` (the submit URL commits to the next free review path), `REFERENCE.md` and schema decision 57. Cites PyAutoBrain#341. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VsKeuX83FNGjLqs2axLHeR --- REFERENCE.md | 17 ++++++----- batches/AGENTS.md | 16 +++++++++-- batches/packets/TEMPLATE.md | 9 ++++-- batches/reviews/AGENTS.md | 19 +++++++++---- docs/schema_decisions.md | 13 +++++++++ scripts/cortex.py | 37 +++++++++++++++++++----- tests/test_cortex.py | 57 +++++++++++++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 24 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index c29b7ba..14615be 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -184,7 +184,7 @@ PyAutoCortex/ ├── batches/AGENTS.md ← batch-record schema (rolling board) ├── batches/-.md ← one record per slot (LEDGER) ├── batches/packets/{AGENTS.md,TEMPLATE.md,.html} -├── batches/reviews/{AGENTS.md,.md} +├── batches/reviews/{AGENTS.md,.md,-r.md} │ ├── 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 @@ -363,12 +363,15 @@ Member line: `` is the phase file's stem; `` is comma-separated stems or `none`; `` is the phase state at the last refresh. `check` verifies the path exists, the slug matches its stem, the runs ⊆ the phase's `Runs:`, -the review-minutes are an integer and the state is legal; a `review:` field -must resolve. A review file's title is `# Batch review ` for its own -filename, its batch record `batches/.md` exists, and every section -names a member of it. - -**One review grammar** (`batches/reviews/.md`): +the review-minutes are an integer and the state is legal; every `review:` +field must resolve — the key repeats, one line per review file. A review +file's title is `# Batch review ` for its own filename, its batch record +`batches/.md` exists, and every section names a member of it. A rolling +slot's later sittings are `batches/reviews/-r.md` (N ≥ 2); they +resolve to the same record, may be titled for the file or for the slot, and +are checked identically. + +**One review grammar** (`batches/reviews/.md`, `…--r.md`): ```markdown # Batch review - diff --git a/batches/AGENTS.md b/batches/AGENTS.md index 109d5a5..75b8c0f 100644 --- a/batches/AGENTS.md +++ b/batches/AGENTS.md @@ -18,6 +18,15 @@ shown as running, and a `leave-to-finish` is the only thing the human can say about them; the board is the live view of run progress. Each pull appends a `refreshed:` line — that list is the record of the board filling in. +**The first review does not close the slot.** The human rules on whatever is +reviewable whenever they come in; the slot stays open while any non-carried +member is still `submitted`, `running`, `pulled` or `awaiting-ruling`, and +closes when nothing is left in a board state. Every later sitting lands as +`reviews/-r.md` (N ≥ 2 — the first review is `reviews/.md`) and +the record gains another `- review:` line: the field repeats, one line per +file. The live view is the batch status box at the top of both dashboards, +whose button opens the slot's packet on Pages. + ## Schema The record is **opened by the batch conductor** — `pyauto-brain batch plan @@ -42,7 +51,8 @@ hand-written record always wrote. - reviewed-at: 2026-09-01T17:10Z # when they actually sat down - delivered: / - packet: batches/packets/-.html -- review: batches/reviews/-.md +- review: batches/reviews/-.md # repeats — one line per review file +- review: batches/reviews/--r2.md - review-minutes-actual: - carried: — still at review # one per still-running member, written at close - notes: | @@ -98,4 +108,6 @@ figure is a seed; this is what the slot really cost. **`packet:` is the page the human actually reviewed, archived** — see `packets/AGENTS.md`. **`review:` is what the human said, verbatim** — see `reviews/AGENTS.md`; every ruling `cortex.py rule --batch ` files -traces back to a line in that file. +traces back to a line in one of those files. A rolling slot has more than one: +the field repeats in the order the sittings happened, and `check` resolves +every line. diff --git a/batches/packets/TEMPLATE.md b/batches/packets/TEMPLATE.md index 66fa54e..300fb35 100644 --- a/batches/packets/TEMPLATE.md +++ b/batches/packets/TEMPLATE.md @@ -83,10 +83,13 @@ lines. `(none)` + `ruled: no` is an untouched member. Delivery affordances, in this order: **Copy to clipboard** (always works; clipboard API with `execCommand` fallback), **Download .md** (inert inside a Claude-artifact sandbox — say so in small print), **Commit on GitHub** — a link to -`https://github.com/PyAutoLabs/PyAutoCortex/new/main?filename=batches/reviews/.md&value=`, +`https://github.com/PyAutoLabs/PyAutoCortex/new/main?filename=&value=`, disabled with a use-Copy hint when the URL exceeds 7,500 chars. Close with: "tell the -orchestrator chat 'review submitted'." The committed file lands at -`PyAutoCortex/batches/reviews/-.md`. +orchestrator chat 'review submitted'." `` is the **next free** review path for +the slot — `batches/reviews/-.md` the first time, then +`…--r2.md`, `…-r3.md` as the rolling slot is ruled on again; the refresh writes it +into the page's `REVIEW_PATH`, so a re-submitted packet never overwrites the earlier +sitting. ## Design tokens (brief) diff --git a/batches/reviews/AGENTS.md b/batches/reviews/AGENTS.md index 1ac87ee..38441fb 100644 --- a/batches/reviews/AGENTS.md +++ b/batches/reviews/AGENTS.md @@ -1,10 +1,13 @@ # Batch reviews — what the human ruled, verbatim -One markdown file per slot: `-.md` — the review the human -submitted from the packet page (its "submit" step produces exactly this file, -committed via the page's GitHub button or pasted to the orchestrator), or the -same content dictated in-chat and transcribed. The batch record's `review:` -field points here. +One markdown file per **sitting**: `-.md` for the first, +`--r.md` (N ≥ 2, one per later sitting) for a rolling +slot the human comes back to — the review they submitted from the packet page +(its "submit" step produces exactly this file, committed via the page's GitHub +button or pasted to the orchestrator), or the same content dictated in-chat +and transcribed. The files are append-only in the sense that matters: a later +one never edits an earlier one, it rules on what the earlier one left open. +The batch record carries one `- review:` line per file. The orchestrator parses this file at close-out: every ruling it files (`cortex.py rule --batch `) and every follow-up issue it @@ -32,6 +35,12 @@ The human's words, verbatim, or (no note). For rerun this paragraph becomes the rerun phase's seed; for drop it is the ruling body. ``` +A later sitting is the same grammar in a numbered file: its title is either its +own stem (`# Batch review 2026-09-01-pm-r2`) or the slot's (`# Batch review +2026-09-01-pm`), it lists only the members ruled at that sitting, and `check` +resolves it against `batches/.md` like the first. `-r1` is not a name — +the first review is `.md`. + `Follow-ups accepted` is optional and is an `###`, not a `##`: `check` requires every `##` in a review file to name a member of the slot's batch record, so a non-member section has to sit a level down. diff --git a/docs/schema_decisions.md b/docs/schema_decisions.md index f68f870..aa83d5b 100644 --- a/docs/schema_decisions.md +++ b/docs/schema_decisions.md @@ -366,3 +366,16 @@ Choices made in phase 2 — the conductor, the board and the workflows history that only survives in `git log` is a history the next reader will not find, and two records that disagree are worse than one record and a pointer. + +57. **A rolling slot is ruled more than once: `batches/reviews/-r.md`, + N ≥ 2** (2026-09-02, PyAutoBrain#341 — after phase 1). The first review + stays `.md`; every later sitting is a numbered file in the same + grammar, titled for its own stem or for the slot, ruling only on the + members that had landed. The record carries one `- review:` line per file + — the key repeats — and `check` resolves a numbered review to + `batches/.md` and applies every review rule to it unchanged. + Why: the board was rolling but the review was not. One file per slot meant + the first sitting closed it, so a human who ruled on the two members that + had arrived could only rule on the third by editing a ledger file that is + already someone's evidence. Numbering the sittings keeps each file + append-only and leaves one record resolving them all. diff --git a/scripts/cortex.py b/scripts/cortex.py index 41ba794..7e9c753 100644 --- a/scripts/cortex.py +++ b/scripts/cortex.py @@ -149,6 +149,8 @@ ) REVIEW_SECTION_RE = re.compile(r"^## (?P.+?) — (?P[A-Z]+)$") REVIEW_KEY_RE = re.compile(r"^- (decision|ruled): (.+?)\s*$") +#: the `-r` suffix of a slot's later, partial reviews (`-r2.md`, N ≥ 2). +REVIEW_PART_RE = re.compile(r"-r(\d+)$") class CortexError(Exception): @@ -784,6 +786,17 @@ def _record_members(text: str) -> "tuple[list[tuple[int, re.Match | None, str]], return members, keys +def _record_values(text: str, key: str) -> "list[str]": + """Every non-empty value of a repeatable `- :` line, in file order. + `review:` repeats — one line per review file a rolling slot collected.""" + out = [] + for raw in text.split("\n"): + m = RECORD_KEY_RE.match(raw) + if m and m.group(1) == key and (m.group(2) or "").strip(): + out.append(m.group(2).strip()) + return out + + def batch_problems(root: Path, phases_by_rel: "dict[str, Phase]") -> "list[str]": problems: "list[str]" = [] members_by_slot: "dict[str, set[str]]" = {} @@ -793,7 +806,7 @@ def batch_problems(root: Path, phases_by_rel: "dict[str, Phase]") -> "list[str]" if not SLOT_RE.match(slot): problems.append(f"{rel}: not a slot name (-.md)") text = path.read_text(encoding="utf-8") - members, keys = _record_members(text) + members, _ = _record_members(text) slugs: "set[str]" = set() for lineno, m, raw in members: if m is None: @@ -817,16 +830,26 @@ def batch_problems(root: Path, phases_by_rel: "dict[str, Phase]") -> "list[str]" if m.group("state") not in PHASE_STATES: problems.append(f"{rel}: member {slug}: state '{m.group('state')}' is not a phase state") members_by_slot[slot] = slugs - review = keys.get("review") - if review and not (root / review).is_file(): - problems.append(f"{rel}: review: {review} does not exist") + for review in _record_values(text, "review"): + if not (root / review).is_file(): + problems.append(f"{rel}: review: {review} does not exist") for path in batch_reviews(root): rel = path.relative_to(root).as_posix() - slot = path.stem + stem = path.stem + # A rolling slot is ruled more than once: the first review is + # `.md`, every later partial `-r.md`. They are all the + # same slot's review, so they resolve to the one record. A record whose + # own name ends `-r` wins over the strip. + slot = stem if stem in members_by_slot else REVIEW_PART_RE.sub("", stem) + part = REVIEW_PART_RE.search(stem) if slot != stem else None + if part and int(part.group(1)) < 2: + problems.append(f"{rel}: a partial review is -r2 or later " + f"(the first review of a slot is {slot}.md)") lines = path.read_text(encoding="utf-8").split("\n") title = lines[0] if lines else "" - if title != f"# Batch review {slot}": - problems.append(f"{rel}: title must be `# Batch review {slot}`") + titles = [f"# Batch review {stem}"] + ([f"# Batch review {slot}"] if slot != stem else []) + if title not in titles: + problems.append(f"{rel}: title must be " + " or ".join(f"`{t}`" for t in titles)) if slot not in members_by_slot: problems.append(f"{rel}: no batch record batches/{slot}.md") section = None diff --git a/tests/test_cortex.py b/tests/test_cortex.py index b717fc2..c494721 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -544,6 +544,7 @@ def test_two_chains_of_one_on_a_phase_are_fine(tmp_path): # --------------------------------------------------------------------------- # B = "batches/2026-09-01-pm.md" V = "batches/reviews/2026-09-01-pm.md" +R2 = "batches/reviews/2026-09-01-pm-r2.md" def test_batch_member_lines(tmp_path): @@ -585,6 +586,62 @@ def test_review_title_and_record_must_exist(tmp_path): "2026-09-01-pm.md: review: batches/reviews/2026-09-01-pm.md does not exist") +def _partial(root: Path, rel: str, title: str, slug: str = "06_pulled") -> None: + """A later, partial review of the 2026-09-01-pm slot — one member ruled.""" + (root / rel).write_text( + f"# Batch review {title}\n\n" + "- packet: batches/packets/2026-09-01-pm.html\n" + "- reviewed-at: 2026-09-01T21:30Z\n\n" + f"## {slug} — HEALTHY\n" + "- decision: accept\n" + "- ruled: yes\n\n" + "The phase 6 half is in now. Accept.\n") + + +def test_partial_review_belongs_to_its_slot(tmp_path): + root = _copy(tmp_path) + _partial(root, R2, "2026-09-01-pm-r2") + assert _problems(root) == [] + # either title is the slot's: the file's own stem, or the slot it rules on + _partial(root, R2, "2026-09-01-pm") + assert _problems(root) == [] + # and the record carries one `- review:` line per file + _edit(root, B, "- review: batches/reviews/2026-09-01-pm.md\n", + f"- review: batches/reviews/2026-09-01-pm.md\n- review: {R2}\n") + assert _problems(root) == [] + _edit(root, B, f"- review: {R2}\n", "- review: batches/reviews/2026-09-01-pm-r9.md\n") + _assert_drift(root, "review: batches/reviews/2026-09-01-pm-r9.md does not exist") + + +def test_partial_review_is_checked_like_any_other(tmp_path): + root = _copy(tmp_path) + _partial(root, "batches/reviews/2026-09-01-pm-r3.md", "2026-09-01-pm-r3", slug="05_running") + _partial(root, "batches/reviews/2026-09-01-pm-r1.md", "2026-09-01-pm-r1") + _partial(root, "batches/reviews/2026-09-05-am-r2.md", "2026-09-05-am-r2") + _partial(root, R2, "2026-09-01-am") + _assert_drift(root, "2026-09-01-pm-r3.md: section '05_running' names no member of " + "batches/2026-09-01-pm.md", + "2026-09-01-pm-r1.md: a partial review is -r2 or later " + "(the first review of a slot is 2026-09-01-pm.md)", + "2026-09-05-am-r2.md: no batch record batches/2026-09-05-am.md", + "2026-09-01-pm-r2.md: title must be `# Batch review 2026-09-01-pm-r2` " + "or `# Batch review 2026-09-01-pm`") + + +def test_rule_batch_traces_to_a_partial_review(tmp_path): + """A member the human ruled only in `-r2.md` is ruled `--batch `.""" + root = _copy(tmp_path) + _partial(root, R2, "2026-09-01-pm-r2") + _edit(root, B, "- review: batches/reviews/2026-09-01-pm.md\n", + f"- review: batches/reviews/2026-09-01-pm.md\n- review: {R2}\n") + _move(root, P["06_pulled"], "awaiting-ruling") + r = _rule(root, P["06_pulled"], "accept", _body(tmp_path), "--batch", "2026-09-01-pm") + assert r.returncode == 0, r.stderr + rf = _fields(root, "rulings/2026/09/R-20260902-01.md") + assert rf["Phase"] == P["06_pulled"] and rf["Batch"] == "2026-09-01-pm" + assert _problems(root) == [] + + # --------------------------------------------------------------------------- # # move — every legal transition, every refused one # --------------------------------------------------------------------------- #