From 29d39b547b915fbf15e1254acfa879bdf9da4c95 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 3 Sep 2026 20:19:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(cortex):=20one=20check-in=20door=20?= =?UTF-8?q?=E2=80=94=20pull,=20score,=20render,=20push,=20summarise=20by?= =?UTF-8?q?=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pyauto-brain cortex checkin` is the verb behind "where is my science?". It composes the primitives phase 1 decoupled and reasons nothing extra of its own: 1. Sync — every `status: active` project in `projects.yaml`, plus any project owning a phase in `submitted | running`, pulled with **its own** `/ pull`. Output is streamed, not captured: a pull runs for minutes and the human is watching this one command. A non-zero exit is recorded against that project and the sweep continues — one unreachable mirror must not hide the other six. After a good pull it writes `/.cortex/pull.json` (`project`, `pulled_at`, `cmd`, `rc`, `phases_live`), **merged** into whatever the project's own CLI already wrote there, so the richer `checkpoints`/`runs` tables the scorer's checkpoint leg reads survive untouched. 2. Score + move — `collect`'s scorer over every live phase, then the same rehearsed `_apply_checked` moves. 3. Render — `dashboard.md` + `dashboard.html`. 4. Push — `claude/checkin-` cut from a fresh `origin/main`, explicit paths, no force, never `main`, and refused before the branch is cut if `scripts/ledger_merge.py classify` calls the diff code. Allowed only when `gh auth status` succeeds AND the Cortex is clean on `main` — read before anything is written, because "clean" stops being true the moment the phases move. That is the cloud/laptop split, and it is also the default. 5. Summarise, by project, LAST — so a chat sees it above the fold: each project's paths, what its pull did, its phase counts, and every phase a human could act on today with its health verdict and the fenced, copy-ready prompt its state already has. `--dry-run` is the default and reaches nothing at all: it prints the exact pull command per project, the pull root, and every phase it would score. `--project KEY` narrows; `--skip-pull` re-scores offline, stamping the refresh from the newest manifest. `/cortex` is installed rather than merely advertised: the skill body is now the check-in sequence with the verbs as an appendix, and the committed `.claude`/`.codex` discovery links exist alongside the other 29 commands. Tests: dry-run listing, per-project pull-failure isolation (a fake `sync_cli` exiting 1 for one project — the other still pulls and every phase is still scored), manifest write and merge-not-clobber, `--skip-pull`, `--project`, the summary's ordering and by-project keying, the render leg leaving the board current, the no-push path, and the push refusal on a code-classified diff. No test runs a real `hpc/sync pull`. Stacked on #348 — retargets to main when it merges. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HsZA1ufn3msgPiDFxEXa6 --- .claude/commands/cortex.md | 1 + .claude/skills/cortex | 1 + .codex/skills/cortex | 1 + AGENTS.md | 2 +- agents/conductors/cortex/AGENTS.md | 60 ++- agents/conductors/cortex/_cortex.py | 542 +++++++++++++++++++++++++++- bin/pyauto-brain | 4 +- docs/organs/cortex.md | 14 +- skills/COMMANDS.md | 2 +- skills/cortex/SKILL.md | 9 +- skills/cortex/cortex.md | 155 +++++--- tests/test_cortex_conductor.py | 190 ++++++++++ 12 files changed, 914 insertions(+), 67 deletions(-) create mode 120000 .claude/commands/cortex.md create mode 120000 .claude/skills/cortex create mode 120000 .codex/skills/cortex diff --git a/.claude/commands/cortex.md b/.claude/commands/cortex.md new file mode 120000 index 00000000..d7432023 --- /dev/null +++ b/.claude/commands/cortex.md @@ -0,0 +1 @@ +../../skills/cortex/cortex.md \ No newline at end of file diff --git a/.claude/skills/cortex b/.claude/skills/cortex new file mode 120000 index 00000000..23e8f211 --- /dev/null +++ b/.claude/skills/cortex @@ -0,0 +1 @@ +../../skills/cortex \ No newline at end of file diff --git a/.codex/skills/cortex b/.codex/skills/cortex new file mode 120000 index 00000000..23e8f211 --- /dev/null +++ b/.codex/skills/cortex @@ -0,0 +1 @@ +../../skills/cortex \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index e1abe23d..af539085 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ the `/` slash commands. | `workspace` | The voice — the organism's expressive function: plan/survey workspace + HowTo example authorship (workspace\|howto registers) — WorkspaceDecision (never writes) | `bin/pyauto-brain workspace` | | `eyes` | The perceptive function — the organism's sense of its own appearance: survey/review a visualization workspace's figure surface, critiques route to intake/start_dev — EyesSurvey/EyesReviewSurface (never renders, never edits) | `bin/pyauto-brain eyes` | | `profiling` | The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision | `bin/pyauto-brain profiling` | -| `cortex` | The learning function — where the organism finds out what is true: reason over PyAutoCortex (phases, gates, rulings), render its board, and check in on the runs — pull, score against the pre-registered witness, move what came back to awaiting-ruling; never submits a run, never writes a ruling | `bin/pyauto-brain cortex` | +| `cortex` | The learning function — where the organism finds out what is true: the science check-in over PyAutoCortex ('cortex checkin') — pull every active project through its own sync CLI, score every live run against its pre-registered witness, move what came back to awaiting-ruling, re-render the board and summarise it by project; never submits a run, never writes a ruling | `bin/pyauto-brain cortex` | | `hygiene` | The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision | `bin/pyauto-brain hygiene` | | `clone` | The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build; 'clone sync' patches the reference's generic-file diff onto the born siblings (dry run by default; rejected hunks are a human decision) | `bin/pyauto-brain clone` | | `build` | Coordinate execution: consult the vitals faculty, then delegate to PyAutoHands | `bin/pyauto-brain build` | diff --git a/agents/conductors/cortex/AGENTS.md b/agents/conductors/cortex/AGENTS.md index 3990e4e4..873fa453 100644 --- a/agents/conductors/cortex/AGENTS.md +++ b/agents/conductors/cortex/AGENTS.md @@ -3,10 +3,12 @@ > **Tier: conductor** — a front-door agent you *drive*. The *learning > function* — where the organism finds out what is true: it reasons over > PyAutoCortex (the science body map, the pre-registered phases, the rulings -> of record), renders the Cortex board, lists what the gates are waiting on -> and scores what came back from a pull. It **never submits a run and never -> writes a ruling**: the run is the human's act, the verdict is the human's word, and -> the ruling file is the Cortex script's to write. +> of record) and answers one question — *where is my science?* — with one +> command, `checkin`: pull every active project, score every live run, move +> what came back, re-render the board, push the ledger, summarise by project. +> It **never submits a run and never writes a ruling**: the run is the human's +> act, the verdict is the human's word, and the ruling file is the Cortex +> script's to write. The same split the organism already uses twice — **Heart ↔ vitals**, **Gut ↔ hygiene**: the organ keeps the state, the conductor reasons over it. The @@ -17,6 +19,7 @@ the Cortex's intake-and-dashboard conductor. | Verb | Question | Emits | |------|----------|-------| +| `checkin [--dry-run \| --apply] [--push \| --no-push] [--project KEY] [--skip-pull] [--refreshed ISO]` | **Where is my science?** | the door — the sequence below, ending in a summary keyed by project; exit **1** when a pull failed or the tree needs a look | | `census` | What is the Cortex holding? | phase counts by state, the board's section counts, rulings, projects, epics (`--json` for the lot) | | `dashboard --check` | Are the committed pages current? | exit 0 current · **1 stale** · 2 no checkout · 3 unreadable tree | | `dashboard --apply` | — | writes `dashboard.md` + `dashboard.html` | @@ -24,10 +27,12 @@ the Cortex's intake-and-dashboard conductor. | `collect [--pull] [--refreshed ISO] [--apply] [--out F] [--phase REL]` | What came back, and is it worth reviewing? | one block per phase — six scored legs, the readout, a blank ruling line — and exit **1** when any is not HEALTHY. **Default scope: every `submitted \| running` phase**; `--phase REL` narrows | ``` +pyauto-brain cortex checkin --dry-run # what it would pull and score +pyauto-brain cortex checkin --apply # the check-in pyauto-brain cortex # census pyauto-brain cortex dashboard --apply pyauto-brain cortex gates -pyauto-brain cortex collect --pull --apply # the check-in: pull, score, move on +pyauto-brain cortex collect --pull --apply # the scorer on its own pyauto-brain cortex --cortex # another checkout ``` @@ -77,6 +82,51 @@ gated refs, 0 flips in its lifetime; sequencing is prose `Ready when:` lines per Cortex schema decision 54). A human opens the refs and, when they have closed, types `python3 scripts/cortex.py move ready`. +## The door (`checkin`) + +The verb a human types. It composes what is already here and reasons nothing +extra of its own: + +1. **Sync.** Every project with `status: active` in `projects.yaml`, plus any + project that owns a phase in `submitted | running` (a dormant project with a + job still out there is still out there). Each is pulled with **its own** + `/ pull` — the verb all seven implement — and the + output is **streamed**, not captured: a pull runs for minutes and the human + is watching this one command. A non-zero exit is recorded against that + project and the sweep continues; one unreachable mirror must not hide the + other six. After a good pull the door writes `/.cortex/pull.json` + (`project`, `pulled_at`, `cmd`, `rc`, `phases_live`) — **merged** into + whatever is already there, because one project's own CLI writes the richer + `checkpoints` / `runs` tables the scorer's checkpoint leg reads. +2. **Score + move.** `collect`'s scorer over every live phase, then the same + rehearsed `_apply_checked` moves. +3. **Render.** `dashboard.md` + `dashboard.html`. +4. **Push** (the rule below). +5. **Summarise, by project** — printed **last**, so a chat sees it above the + fold: each project's paths, what its pull did, its phase counts, and every + phase a human could act on today with its health verdict and the fenced, + copy-ready prompt its state already has (`_ruling_payload`, + `_live_payload`, `_ready_payload`, `_gate_payload`). `project_digest()` + builds one project's block and `checkin_summary()` the whole thing. + +`--dry-run` (the default) prints the exact pull command per project, the pull +root, and every phase it would score — and reaches nothing at all. `--project +KEY` narrows the sweep; `--skip-pull` re-scores what is already on the laptop, +stamping the refresh from the newest manifest. + +### The push rule + +`--push` is allowed only when **`gh auth status` succeeds** *and* **the Cortex +checkout is clean on `main`** — read *before* anything is written, because +"clean" stops being true the moment the phases move. That is the whole +cloud/laptop split, and it is also the default: a laptop pushes without asking, +a cloud session cannot and says so. The push cuts +`claude/checkin-` from a fresh `origin/main`, commits the changed +paths **explicitly**, pushes, and names `ledger_merge.yml` as what lands it. +`scripts/ledger_merge.py classify` is asked first and a code-classified diff is +refused before the branch is cut. Never `main`, never `--force`, and a +same-day re-check-in reuses its branch rather than resetting a pushed ref. + ## The check-in (`collect`) This is the verb the human actually drives: *what came back?* With no diff --git a/agents/conductors/cortex/_cortex.py b/agents/conductors/cortex/_cortex.py index 1c628cd9..541d4ea5 100644 --- a/agents/conductors/cortex/_cortex.py +++ b/agents/conductors/cortex/_cortex.py @@ -31,12 +31,21 @@ one place that carries such a path is the Cortex's own `projects.yaml`, and every path this module prints is read from a row of it at runtime. -Verbs: `census [--json]` · `dashboard --check|--apply` · `gates` · +Verbs: `checkin [--dry-run|--apply] [--push|--no-push] [--project KEY] +[--skip-pull] [--refreshed ISO]` · `census [--json]` · +`dashboard --check|--apply` · `gates` · `collect [--pull] [--refreshed ISO] [--apply] [--out F] [--phase REL]`. -`collect` is the check-in: with no `--phase` it scopes to every phase in -`submitted | running`, which is what "where is my science?" asks for. It needs -no batch record — the review-slot apparatus was retired 2026-09-03. +`checkin` is **the door** — the one command behind "where is my science?": it +pulls every active project through that project's own sync CLI, scores every +`submitted | running` phase, moves what came back, re-renders the board, +optionally pushes the ledger, and prints a summary keyed **by project** with +the copy-ready prompt each phase's state already has. It composes the verbs +below and reasons nothing extra of its own. + +`collect` is the scorer it composes: with no `--phase` it scopes to every phase +in `submitted | running`. It needs no batch record — the review-slot apparatus +was retired 2026-09-03. Exit codes: 0 ok · 1 dashboard drift (the `dashboard_refresh.yml` contract), and for `collect` a member the human must look at · 2 bad args / no Cortex @@ -1607,6 +1616,490 @@ def _apply_checked(root: Path, mod, scored: list) -> tuple: return mod.check_problems(root), notes, True +# --------------------------------------------------------------- check-in --- +# The one door. `collect` scores; `dashboard` renders; each project's own sync +# CLI pulls. `checkin` is the sequence a human actually wants when they ask +# "where is my science?" — sync every active project, score every live phase, +# move what finished, re-render the board, optionally push the ledger, and +# hand back the prompts. It composes the primitives above and adds none of its +# own reasoning; a phase edit is still `cortex.py move`'s and a verdict is +# still the human's. +# +# Three rules it does not bend: +# +# - **It reaches the cluster only through the projects' own CLIs.** No SSH of +# its own, ever; `--dry-run` reaches nothing at all and prints the exact +# command each project would run. +# - **One project's failure is that project's.** A pull that exits non-zero is +# recorded against its project and the sweep continues — a check-in that +# aborts on the first broken mirror tells you nothing about the other six. +# - **The push is a ledger push or it does not happen.** `claude/checkin-` +# cut from a fresh `origin/main`, explicit paths, no force, never `main`, and +# refused outright if `ledger_merge.py classify` calls the diff code. +CHECKIN_BRANCH_PREFIX = "claude/checkin-" + +#: The rule the `--push` default resolves by, stated wherever it is applied. +PUSH_RULE = ("`--push` needs `gh auth status` to succeed and the Cortex " + "checkout to be clean on `main`") + + +def checkin_keys(projects: dict, phases: list, only: list) -> tuple: + """`(keys, notes)` — the projects one check-in sweeps. + + Every `status: active` row, plus any project that owns a phase in + `submitted | running`: a dormant project with a job still out there is + still out there, and the run is what the check-in is about. + """ + notes: list[str] = [] + keys = {key for key, row in projects.items() + if (row.get("status") or "").strip() == "active"} + for ph in phases: + if ph.state in LIVE_STATES: + keys.add(ph.get("Project") or ph.project_dir) + for key in sorted(keys): + if key not in projects: + notes.append(f"{key}: a live phase names a project with no row in " + "projects.yaml — not pulled") + keys &= set(projects) + if only: + for key in only: + if key not in projects: + notes.append(f"{key}: no such project in projects.yaml") + keys &= set(only) + return sorted(keys), notes + + +def pull_cmd(row: dict) -> tuple | None: + """`(argv, cwd)` for this project's own ` pull`, or None when the + row has no such verb. Every path comes from the row.""" + local = (row.get("local_path") or "").strip() + cli = (row.get("sync_cli") or "").strip() + if not local or not cli or "pull" not in (row.get("sync_verbs") or []): + return None + return [str(Path(local) / cli), "pull"], local + + +def pull_shell(row: dict) -> str: + """The pull as a human would type it — what `--dry-run` prints.""" + cmd = pull_cmd(row) + return (f"cd {cmd[1]} && {row.get('sync_cli')} pull" if cmd + else "(no `pull` verb in projects.yaml)") + + +def run_pull_streamed(projects: dict, keys: list) -> dict: + """`{key: (rc, note)}` — each project's own pull, **streamed**. + + Not captured: a pull runs for minutes and the human is watching this one + command; buffering it would hold every project's output back to the end. + `rc` is None when nothing ran. + """ + results: dict[str, tuple] = {} + for key in keys: + row = projects.get(key, {}) + cmd = pull_cmd(row) + if cmd is None: + results[key] = (None, "no `pull` verb in projects.yaml — not pulled") + continue + argv, cwd = cmd + print(f"\n$ {pull_shell(row)}", flush=True) + try: + rc = subprocess.run(argv, cwd=cwd).returncode + except (OSError, subprocess.SubprocessError) as e: + results[key] = (None, f"pull could not run ({e}) — scored anyway") + continue + results[key] = (rc, "" if rc == 0 else + f"pull exited {rc} — scored anyway, and the rest of " + "the sweep ran") + return results + + +def pull_root(row: dict) -> Path | None: + """Where this project's pull lands: the mirror it fills, else the + checkout. The manifest is written at the top of that tree, which is the + root `pull_manifest()` reads it back from.""" + for key in ("mirror", "local_path"): + value = (row.get(key) or "").strip() + if value and value != "none": + path = Path(value).expanduser() + if path.is_dir(): + return path + return None + + +def write_pull_manifest(root_dir: Path, key: str, cmd: str, rc: int, + phases_live: list) -> Path | None: + """Record this check-in's pull in `/.cortex/pull.json`. + + **Merge, never clobber.** One project's own sync CLI already writes a + richer manifest there (the `checkpoints` / `runs` tables the scorer's + checkpoint leg reads); this adds the check-in's own keys and leaves every + other key exactly as it found it. An unreadable file is replaced — it was + telling the scorer nothing. + """ + path = root_dir.joinpath(*PULL_MANIFEST) + data: dict = {} + if path.is_file(): + try: + loaded = json.loads(_read(path)) + except json.JSONDecodeError: + loaded = None + if isinstance(loaded, dict): + data = loaded + data.update({"project": key, "pulled_at": _utc_now(), "cmd": cmd, + "rc": rc, "phases_live": list(phases_live)}) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + except OSError: + return None + return path + + +# ------------------------------------------------------------- the push --- +def _git(root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(root), *args], + capture_output=True, text=True) + + +def push_preflight(root: Path) -> tuple: + """`(ok, reason)` — may this check-in push its own ledger diff? + + Read **before** anything is written, because "the checkout is clean" stops + being true the moment the phases move. Two legs, both of them facts about + this machine rather than a policy: a `gh` that is logged in (the cloud + sessions have none, and that is the whole cloud/laptop split), and a + Cortex checkout sitting clean on `main` (a dirty tree or a feature branch + means the human is mid-something, and the check-in is not going to guess + what). + """ + if shutil.which("gh") is None: + return False, "no `gh` on PATH — this is not a laptop session" + if subprocess.run(["gh", "auth", "status"], capture_output=True, + text=True).returncode != 0: + return False, "`gh auth status` fails — not authenticated" + head = _git(root, "rev-parse", "--abbrev-ref", "HEAD") + if head.returncode != 0: + return False, "the Cortex checkout is not a git repo" + branch = head.stdout.strip() + if branch != "main": + return False, f"the Cortex checkout is on `{branch}`, not `main`" + dirty = _git(root, "status", "--porcelain") + if dirty.returncode != 0 or dirty.stdout.strip(): + return False, "the Cortex checkout has uncommitted changes" + return True, "`gh` is authenticated and the Cortex is clean on `main`" + + +def dirty_paths(root: Path) -> list[str]: + """Every path the check-in's own writes left changed. Safe to read as + *ours* only because the preflight demanded a clean tree first.""" + out = _git(root, "status", "--porcelain") + paths = [] + for line in out.stdout.splitlines(): + if not line.strip(): + continue + path = line[3:].strip().strip('"') + if " -> " in path: # a rename: the destination is what we commit + path = path.split(" -> ", 1)[1].strip().strip('"') + if path not in paths: + paths.append(path) + return sorted(paths) + + +def classify_paths(root: Path, paths: list) -> tuple: + """`(rc, text)` from the Cortex's own `scripts/ledger_merge.py classify`. + + The gate the Cortex already owns, asked before pushing rather than after: + 0 = ledger (auto-merges), 1 = holds code (a human's call), 2 = the gate + could not run. 1 and 2 are both refusals here, and they are not the same + refusal. + """ + script = root / "scripts" / "ledger_merge.py" + if not script.is_file(): + return 2, "no scripts/ledger_merge.py in this checkout" + r = subprocess.run([sys.executable, str(script), "classify", *paths], + cwd=str(root), capture_output=True, text=True) + return r.returncode, (r.stdout + r.stderr).strip() + + +def push_ledger(root: Path, date: str, paths: list) -> tuple: + """Commit the check-in's ledger diff on `claude/checkin-` and push. + + Never `main`, never `--force`, never a path the classifier calls code. + Returns `(ok, lines)` — the lines are the summary's push block. + """ + L: list[str] = [] + if not paths: + return True, ["push: nothing changed — no branch cut"] + rc, text = classify_paths(root, paths) + if rc != 0: + L.append("push: REFUSED — " + ("the diff holds code, which is a " + "human's call" if rc == 1 else + "the classifier could not run")) + L += [f" {line}" for line in text.splitlines()[:8]] + L.append(" nothing was pushed; the changes are in the checkout") + return False, L + branch = f"{CHECKIN_BRANCH_PREFIX}{date}" + fetch = _git(root, "fetch", "origin") + if fetch.returncode != 0: + return False, ["push: REFUSED — `git fetch origin` failed", + f" {fetch.stderr.strip()[:200]}"] + exists = _git(root, "rev-parse", "--verify", "--quiet", branch).returncode == 0 + # A same-day re-check-in reuses its branch rather than resetting it: the + # branch may already be pushed, and moving a pushed ref needs a force. + co = (_git(root, "checkout", branch) if exists else + _git(root, "checkout", "-b", branch, "origin/main")) + if co.returncode != 0: + return False, [f"push: REFUSED — could not cut `{branch}` from a fresh " + "origin/main", f" {co.stderr.strip()[:200]}", + " the changes are still in the checkout"] + add = _git(root, "add", "--", *paths) + if add.returncode != 0: + return False, ["push: REFUSED — `git add` failed", + f" {add.stderr.strip()[:200]}"] + if _git(root, "diff", "--cached", "--quiet").returncode == 0: + return True, [f"push: nothing staged on `{branch}` — already recorded"] + msg = (f"cortex: check-in {date}\n\n" + "Phase moves and the re-rendered board from " + "`pyauto-brain cortex checkin --apply`.\n") + commit = _git(root, "commit", "-m", msg) + if commit.returncode != 0: + return False, ["push: REFUSED — `git commit` failed", + f" {commit.stderr.strip()[:200]}"] + push = _git(root, "push", "-u", "origin", branch) + if push.returncode != 0: + return False, [f"push: FAILED — `git push -u origin {branch}`", + f" {push.stderr.strip()[:200]}", + " the commit is on the branch; push it by hand"] + L.append(f"push: `{branch}` pushed ({len(paths)} path(s), ledger-only)") + L.append(" `ledger_merge.yml` merges a ledger-only `claude/**` push into " + "main and deletes the branch — no PR to open, nothing to merge " + "by hand") + return True, L + + +# ---------------------------------------------------- the by-project summary --- +def _payload_block(payload: str) -> list[str]: + return ["", "```", *payload.split("\n"), "```", ""] + + +def project_digest(key: str, row: dict, c: dict, scored_by_rel: dict, + pull_line: str) -> list[str]: + """One project's block of the check-in summary — where it lives, what came + of its pull, and every phase of it a human could act on today, each with + the prompt that already exists for that state. + + Phase 3 enriches this (the two missing prompts, the folders); it is keyed + by project here because that is the axis the human checks in along — they + ask about a project, never about a phase id. + """ + rows = [r for r in c["phases"] if r["project"] == key] + counts: dict[str, int] = {} + for r in rows: + counts[r["state"]] = counts.get(r["state"], 0) + 1 + L = [f"### {key}", ""] + where = [f"local `{row.get('local_path', '?')}`"] + if (row.get("mirror") or "none") != "none": + where.append(f"mirror `{row['mirror']}`") + where.append(f"RAL `{row.get('ral_root', '?')}`") + L += ["- " + " · ".join(where), + f"- {pull_line}", + "- phases: " + (" · ".join(f"{state} {n}" for state, n + in sorted(counts.items())) + or "none")] + + def _phase_lines(title: str, phase_rows: list, payload) -> list[str]: + if not phase_rows: + return [] + out = ["", f"**{title}**", ""] + for r in phase_rows: + s = scored_by_rel.get(r["rel"]) + head = f"- `{r['rel']}` — {r['title']}" + if s: + head += f" — {s['health']}" + out.append(head) + note = _live_note(r) + if note: + out.append(f" - {note}") + if s: + legs = ", ".join(f"{k} {s['legs'][k][0]}" for k in LEGS) + out.append(f" - legs: {legs}") + for label, value in (("run dir", s["run_dir"]), + ("zip", s["zip"])): + if value is not None: + out.append(f" - {label}: `{value}`") + for kind in ("out", "err"): + for path in s["logs"][kind][:1]: + out.append(f" - `.{kind}`: `{path}`") + out += _payload_block(payload(r)) + return out + + L += _phase_lines("Awaiting your ruling", + [r for r in c["awaiting"] if r["project"] == key], + _ruling_payload) + L += _phase_lines("Still out there", + [r for r in c["live"] if r["project"] == key], + lambda r: _live_payload(r, c["projects"])) + L += _phase_lines("Ready to submit", + [r for r in c["ready"] if r["project"] == key], + lambda r: _ready_payload(r, c["projects"])) + L += _phase_lines("Gated", + [r for r in c["gated"] if r["project"] == key], + _gate_payload) + L += [""] + return L + + +def checkin_summary(c: dict, keys: list, scored: list, pulls: dict, + notes: list) -> str: + """The whole by-project summary — the LAST thing the door prints, so a + chat sees it above the fold and can paste from it.""" + scored_by_rel = {s["rel"]: s for s in scored} + L = ["", "=" * 72, "", f"# Cortex check-in — {c['generated']}", "", + f"{len(keys)} project(s) swept · {len(scored)} live phase(s) scored · " + f"{len(c['awaiting'])} awaiting a ruling · {len(c['ready'])} ready", + ""] + for key in keys: + row = c["projects"].get(key, {}) + rc, note = pulls.get(key, (None, "not pulled (--skip-pull)")) + pull_line = ("pull: ok" if rc == 0 and not note else + f"pull: {note}" if note else "pull: not run") + L += project_digest(key, row, c, scored_by_rel, pull_line) + if notes: + L += ["### Notes", ""] + [f"- {n}" for n in notes] + [""] + return "\n".join(L) + + +# ----------------------------------------------------------- the door --- +def cmd_checkin(root: Path, mod, a) -> int: + """`checkin` — sync, score, move, render, push, summarise by project.""" + if a.dry_run and a.apply: + print("cortex: --dry-run and --apply are the two halves of this door " + "— pass one", file=sys.stderr) + return RC_USAGE + projects = mod.load_projects(root)[0] + phases_all = mod.load_phases(root)[0] + keys, notes = checkin_keys(projects, phases_all, a.project) + live = [ph for ph in phases_all if ph.state in LIVE_STATES + and (ph.get("Project") or ph.project_dir) in keys] + live_by_key: dict[str, list[str]] = {} + for ph in live: + live_by_key.setdefault(ph.get("Project") or ph.project_dir, + []).append(ph.rel) + + if not a.apply: # the default: say what it would do, touch nothing + print(f"cortex check-in (dry run) — {len(keys)} project(s), " + f"{len(live)} live phase(s). Nothing is pulled, nothing is " + "written, no cluster is reached.") + for key in keys: + row = projects.get(key, {}) + print(f"\n{key} [{(row.get('status') or '?').strip()}]") + print(f" pull: $ {pull_shell(row)}") + print(f" root: {pull_root(row) or '(no readable pull root)'}") + rels = live_by_key.get(key, []) + print(" score: " + (", ".join(rels) if rels + else "(no submitted | running phase)")) + for note in notes: + print(f"\nnote: {note}") + ok, why = push_preflight(root) + print(f"\npush would be: {'yes' if ok else 'no'} — {why}") + print(f"the rule: {PUSH_RULE}") + return RC_OK + + # --- the push question is asked first: "clean on main" stops being true + # the moment the phases move. + if a.push is False: + push_ok, push_why = False, "--no-push" + else: + push_ok, push_why = push_preflight(root) + if a.push is True and not push_ok: + print(f"cortex: --push refused — {push_why}. The rule: {PUSH_RULE}.", + file=sys.stderr) + push_now = push_ok and a.push is not False + print(f"push: {'yes' if push_now else 'no'} — {push_why}" + + ("" if push_now else f" (the rule: {PUSH_RULE})")) + + # --- 1. sync --------------------------------------------------------- + pulls: dict[str, tuple] = {} + if not a.skip_pull: + pulls = run_pull_streamed(projects, keys) + for key, (rc, note) in pulls.items(): + if rc != 0: + if note: + notes.append(f"{key}: {note}") + continue + row = projects.get(key, {}) + target = pull_root(row) + if target is None: + notes.append(f"{key}: pulled, but no readable pull root — no " + "manifest written") + continue + written = write_pull_manifest(target, key, pull_shell(row), rc, + live_by_key.get(key, [])) + notes.append(f"{key}: pulled → {written}" if written else + f"{key}: pulled, but the manifest could not be written") + stamp = a.refreshed.strip() or (_utc_now() if pulls else + _newest_pull_stamp(projects, keys)) + if not stamp: + print("cortex: --apply needs a refresh stamp — run it without " + "--skip-pull, or pass --refreshed when you pulled by hand", + file=sys.stderr) + return RC_USAGE + + # --- 2. score + move ------------------------------------------------- + scored = [score_phase(mod, ph, projects) for ph in live] + problems, applied, wrote = _apply_checked(root, mod, scored) + notes += applied + if problems: + print("cortex: the tree does not check after the moves — " + + ("they were written; run `python3 scripts/cortex.py check`" + if wrote else "nothing was written") + ":", file=sys.stderr) + for problem in problems[:10]: + print(f" {problem}", file=sys.stderr) + return RC_DRIFT + + # --- 3. render ------------------------------------------------------- + c = census(root) + pages = render_pages(c) + for name, want in pages.items(): + (root / name).write_text(want, encoding="utf-8") + print(f"Wrote: {' + '.join(pages)} ({len(c['phases'])} phase(s), " + f"{len(c['rulings'])} ruling(s))") + + # --- 4. push --------------------------------------------------------- + push_lines: list[str] = [] + rc_out = RC_OK + if push_now: + ok, push_lines = push_ledger(root, _dt.date.today().isoformat(), + dirty_paths(root)) + if not ok: + rc_out = RC_DRIFT + if any(rc not in (0, None) for rc, _n in pulls.values()): + rc_out = RC_DRIFT + + # --- 5. summarise, by project, last ---------------------------------- + print(checkin_summary(c, keys, scored, pulls, notes)) + for line in push_lines: + print(line) + print(f"\nRefreshed: {stamp}") + return rc_out + + +def _newest_pull_stamp(projects: dict, keys: list) -> str: + """The newest `pulled_at` any project's manifest carries — what + `--skip-pull` scores against when the human pulled earlier themselves.""" + stamps = [] + for key in keys: + root_dir = pull_root(projects.get(key, {})) + if root_dir is None: + continue + data = pull_manifest([root_dir]) + value = str(data.get("pulled_at") or "").strip() + if value: + stamps.append(value) + return max(stamps) if stamps else "" + + # -------------------------------------------------------------------- cli --- def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser( @@ -1649,6 +2142,34 @@ def common(p): "the record (needs --pull or --refreshed)") k.add_argument("--out", default="", metavar="FILE", help="write the packet markdown here instead of stdout") + + n = common(sub.add_parser( + "checkin", help="the check-in door: pull every active project, score " + "every live phase, move what came back, re-render the " + "board and summarise it by project")) + n.add_argument("--dry-run", dest="dry_run", action="store_true", + help="the default — say what would be pulled and scored, " + "and touch nothing") + n.add_argument("--apply", action="store_true", + help="do it: pull, score, move, render (and push, per the " + "push rule)") + n.add_argument("--push", dest="push", action="store_const", const=True, + default=None, + help="push the ledger diff on `claude/checkin-` " + "(allowed only when `gh auth status` succeeds and the " + "Cortex is clean on main; that is also the default " + "when neither flag is given)") + n.add_argument("--no-push", dest="push", action="store_const", const=False, + help="never push — the default in any session without a " + "logged-in `gh`") + n.add_argument("--project", action="append", default=[], metavar="KEY", + help="sweep only these projects (repeatable)") + n.add_argument("--skip-pull", dest="skip_pull", action="store_true", + help="score what is already on the laptop — an offline " + "re-score") + n.add_argument("--refreshed", default="", metavar="ISO", + help="stamp the refresh at this time — for a pull you ran " + "by hand") return ap @@ -1673,6 +2194,19 @@ def main(argv=None) -> int: print("\n".join(lines)) return rc + if verb == "checkin": + # The door composes the verbs below; like `collect` it reads the tree + # phase by phase rather than through `census`, because a tree that + # does not check is exactly when a human checks in. + try: + return cmd_checkin(root, mod, a) + except mod.CortexError as e: + print(f"cortex: {root}: {e}", file=sys.stderr) + return RC_UNREADABLE + except OSError as e: + print(f"cortex: cannot read {root}: {e}", file=sys.stderr) + return RC_UNREADABLE + if verb == "collect": # Scoring reads the tree phase by phase rather than through `census`: # a collect must work on a tree that does not fully check, because a diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 826f1404..668e218c 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -18,7 +18,7 @@ # pyauto-brain workspace [args] (conductor) the Voice: plan/survey workspace + HowTo example authorship — WorkspaceDecision (writes nothing) # pyauto-brain eyes [args] (conductor) the Eyes: survey/review a visualization workspace's figure surface — EyesSurvey/EyesReviewSurface (writes nothing) # pyauto-brain profiling [args] (conductor) campaign/ingest/triage plans over the profiling workspace -# pyauto-brain cortex [args] (conductor) the Cortex board and check-in: census/dashboard/gates/collect +# pyauto-brain cortex [args] (conductor) the science check-in: checkin/census/dashboard/gates/collect # pyauto-brain hygiene [args] (conductor) code-quality upkeep: perf/tidy/noise/deps/docs — HygieneDecision (modes staged) # pyauto-brain clone [args] (conductor) analyze a library+workspace against the reference assistant — CloneDecision (writes nothing) # (clone sync: re-apply the reference's generic-file diff to its born @@ -87,7 +87,7 @@ declare -A AGENT_DESC=( [refactor]="The renewal function: plan behaviour-preserving restructuring — RefactorDecision; default-safe under --auto" [workspace]="The voice — the organism's expressive function: plan/survey workspace + HowTo example authorship (workspace|howto registers) — WorkspaceDecision (never writes)" [eyes]="The perceptive function — the organism's sense of its own appearance: survey/review a visualization workspace's figure surface, critiques route to intake/start_dev — EyesSurvey/EyesReviewSurface (never renders, never edits)" - [cortex]="The learning function — where the organism finds out what is true: reason over PyAutoCortex (phases, gates, rulings), render its board, and check in on the runs — pull, score against the pre-registered witness, move what came back to awaiting-ruling; never submits a run, never writes a ruling" + [cortex]="The learning function — where the organism finds out what is true: the science check-in over PyAutoCortex ('cortex checkin') — pull every active project through its own sync CLI, score every live run against its pre-registered witness, move what came back to awaiting-ruling, re-render the board and summarise it by project; never submits a run, never writes a ruling" [profiling]="The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision" [hygiene]="The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision" [clone]="The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build; 'clone sync' patches the reference's generic-file diff onto the born siblings (dry run by default; rejected hunks are a human decision)" diff --git a/docs/organs/cortex.md b/docs/organs/cortex.md index 6f8151d2..2fa5fdb9 100644 --- a/docs/organs/cortex.md +++ b/docs/organs/cortex.md @@ -99,16 +99,20 @@ same split as **Heart ↔ vitals** and **Gut ↔ hygiene**. | Verb | What it does | |------|--------------| +| `checkin [--dry-run \| --apply] [--push \| --no-push] [--project KEY] [--skip-pull]` | **The door.** Pull every active project through its own sync CLI (streamed; one project's failure does not stop the sweep), score every live phase, move what came back, re-render the board, push the ledger where the rule allows, and print a summary **by project** with the copy-ready prompt each phase's state already has. `--dry-run` (the default) says what it would do and reaches nothing | | `census` | What the Cortex is holding — phases by state, rulings, projects, epics | | `dashboard --check` \| `--apply` | The generated board, `dashboard.md` + `dashboard.html`. `--check` exits **1** on drift — the contract the Cortex's `dashboard_refresh.yml` runs on. The two pages are generated: never hand-edit them | | `gates` | The refs each gated phase waits on and the URL each resolves to — read-only and offline. A human opens them and, when they have closed, types `cortex.py move ready` | | `collect [--pull] [--apply] [--phase REL]` | **The check-in.** What came back, one block per phase: six legs (`err`, `wall`, `version`, `checkpoint`, `resume`, `witness`) each `PASS`/`FAIL`/**`UNOBSERVABLE`**, the witness readout and a blank ruling line. Default scope is every `submitted \| running` phase; `--pull` runs each project's own sync CLI; `--apply` moves each phase `running → pulled → awaiting-ruling`. Exit **1** = a phase the human must look at | -**Checking in is one command.** `pyauto-brain cortex collect --pull --apply` -pulls every active project through its own sync CLI, scores every phase the -Cortex believes is out there against its pre-registered witness, and moves what -came back to `awaiting-ruling` — then the human rules with -`scripts/cortex.py rule`. It needs no record, no slot and no packet. +**Checking in is one command.** `pyauto-brain cortex checkin --apply` pulls +every active project through its own sync CLI, scores every phase the Cortex +believes is out there against its pre-registered witness, moves what came back +to `awaiting-ruling`, re-renders the board, pushes the ledger on +`claude/checkin-` when `gh` is logged in and the checkout is clean on +`main`, and ends with a by-project summary the human reads and pastes from — +then they rule with `scripts/cortex.py rule`. It needs no record, no slot and +no packet. A review-slot apparatus — a rolling board with its own record under `batches/`, a scored packet page, partial reviews and carry-forward — was built on the diff --git a/skills/COMMANDS.md b/skills/COMMANDS.md index f98ae732..1ee83934 100644 --- a/skills/COMMANDS.md +++ b/skills/COMMANDS.md @@ -37,7 +37,7 @@ readiness gate, or execution — those belong to the organs. | `/workspace` | Workspace Agent | `bin/pyauto-brain workspace` → WorkspaceDecision (plan/survey example authorship) → `start_dev` → `start_workspace` → `ship_workspace` | | `/eyes` | Eyes Agent | `bin/pyauto-brain eyes` → survey/review a visualization workspace's figures; render via its `gallery_run.sh`; accepted critiques → `/intake` → `start_dev` | | `/profiling` | Profiling Agent | `bin/pyauto-brain profiling` → campaign/ingest/triage plans over the autolens_profiling workspace | -| `/cortex` | Cortex Agent | `bin/pyauto-brain cortex` → the science board and the check-in (census/dashboard/gates/collect) over PyAutoCortex; the run and the ruling stay the human's | +| `/cortex` | Cortex Agent | `bin/pyauto-brain cortex checkin` → the science check-in over PyAutoCortex: pull every active project, score every live run, move what came back, re-render the board and summarise it by project (`census`/`dashboard`/`gates`/`collect` are the verbs it composes); the run and the ruling stay the human's | | `/hygiene` | Hygiene Agent | `bin/pyauto-brain hygiene` → perf/tidy/noise/deps/docs/docstrings upkeep plans; delegates fixes to refactor/bug/feature | | `/build` | Build Agent | `bin/pyauto-brain build` → vitals faculty → Heart → PyAutoHands | | `/health` | Health Agent | `bin/pyauto-brain health` loop → vitals faculty → Heart → GREEN | diff --git a/skills/cortex/SKILL.md b/skills/cortex/SKILL.md index e19a0baa..ebc660f7 100644 --- a/skills/cortex/SKILL.md +++ b/skills/cortex/SKILL.md @@ -1,10 +1,11 @@ --- name: cortex -description: Read and drive the science side of the organism through the PyAutoBrain Cortex Agent — the Cortex board (what awaits a ruling, what is running, what is ready, what is gated), the daily gate grading, and which ready phases fit a laptop slot. Use for science runs, phases, gates and rulings; never for development tasks, which are the Mind's. +description: Check in on the science — run the PyAutoBrain Cortex Agent's one door (pull every active project through its own sync CLI, score every live run against its pre-registered witness, move what came back, re-render the board, push the ledger) and read the by-project summary back to the human with the prompt each phase needs next. Use for science runs, phases, gates and rulings; never for development tasks, which are the Mind's. --- # Cortex -Follow [`cortex.md`](cortex.md) exactly. The conductor reasons; it never -submits a run and never writes a ruling — the submission and the verdict stay -the human's. +Follow [`cortex.md`](cortex.md) exactly: it is the check-in sequence, and the +individual verbs are its appendix. The conductor reasons; it never submits a +run and never writes a ruling — the submission and the verdict stay the +human's. diff --git a/skills/cortex/cortex.md b/skills/cortex/cortex.md index b02f4080..e56c4d38 100644 --- a/skills/cortex/cortex.md +++ b/skills/cortex/cortex.md @@ -1,59 +1,124 @@ -# /cortex — the science board (via the Brain Cortex Agent) +# /cortex — check in on the science (via the Brain Cortex Agent) -Route science-run work — phases, gates, runs, rulings — through PyAutoBrain's -**Cortex Agent**, the learning function. Development work is the Mind's and -goes through `/intake` and `/start_dev`; this door is for what the organism is -finding out, not what it is building. +**This is the check-in.** You have runs on a cluster; this door finds out where +they got to. It pulls every active project through that project's *own* sync +CLI, scores every live phase against the witness it pre-registered, moves what +came back to `awaiting-ruling`, re-renders the board, pushes the ledger when it +is allowed to, and hands you a summary **by project** with the prompt you would +paste to carry each one forward. +Development work is the Mind's and goes through `/intake` and `/start_dev`; +this door is for what the organism is *finding out*, not what it is building. Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. ## Do -1. Run `bin/pyauto-brain cortex [census | dashboard --check|--apply | gates | - collect [--pull] [--apply]]`. `census`, `gates` and a bare `collect` are - read-only; `dashboard --apply` writes the two generated pages; - `collect --apply` moves the scored phases - `running → pulled → awaiting-ruling` and writes nothing else. -2. Read the result to the human and hand them the command the row carries. - **Never** decide a ruling, never submit a job, never edit a phase file by - hand — every phase edit goes through `python3 scripts/cortex.py move` and - every verdict through `python3 scripts/cortex.py rule`, both in the - PyAutoCortex checkout, and the ruling body is the human's words verbatim. -3. Point at another checkout with `--cortex ` (default: `$PYAUTO_CORTEX`, - then `PyAutoCortex` beside PyAutoBrain). - -## The verbs +### 1. Look before you pull -| Verb | Answers | -|------|---------| -| `census` | What is the Cortex holding? Phase counts by state, rulings, projects, epics (`--json` for all of it) | -| `dashboard --check` | Are the committed pages current? Exit **1** = stale (the refresh workflow's contract) | -| `dashboard --apply` | Regenerate `dashboard.md` + `dashboard.html` — never hand-edit those two files | -| `gates` | What is each gated phase waiting on? Its refs and their URLs — read-only and offline | -| `collect [--pull] [--refreshed ISO] [--apply] [--out F] [--phase REL]` | **The check-in.** What came back? One block per phase — six legs each `PASS`/`FAIL`/`UNOBSERVABLE`, the readout, a **blank** ruling line. Default scope: every `submitted \| running` phase; `--phase REL` narrows. Exit **1** = a phase the human must look at | +```bash +bin/pyauto-brain cortex checkin --dry-run +``` + +Prints every project it would sweep, the exact `cd && +pull` it would run for each, the pull root each manifest would land in, and +every `submitted | running` phase it would score. It reaches no cluster and +writes nothing. Read it to the human — especially if a project they expect is +missing (it is missing because its `projects.yaml` row is not `status: active` +and it owns no live phase). + +### 2. Check in + +```bash +bin/pyauto-brain cortex checkin --apply # push per the rule below +bin/pyauto-brain cortex checkin --apply --no-push # never push +bin/pyauto-brain cortex checkin --apply --project subhalo_validation +bin/pyauto-brain cortex checkin --apply --skip-pull # re-score, offline +``` + +It runs, in order: + +1. **Sync** — each project's own ` pull`, output streamed as it + comes (a pull takes minutes; do not wrap this in anything that buffers). + A pull that exits non-zero is recorded against *that* project and the sweep + carries on. After a good pull it writes `/.cortex/pull.json`, + merging with whatever the project's CLI already wrote there. +2. **Score** — every `submitted | running` phase, six legs each, against the + phase's own pre-registered witness. +3. **Move** — `running → pulled → awaiting-ruling`, rehearsed on a throwaway + copy first and refused outright if `cortex.py check` would not pass after. +4. **Render** — `dashboard.md` + `dashboard.html`. +5. **Push** — see the rule below. +6. **Summarise by project** — printed last, so it is what the chat sees. + +### 3. Read the summary back, project by project + +The last block of the output is the deliverable: one section per project — its +`local_path` / `mirror` / RAL root, what its pull did, its phase counts, and +then every phase a human could act on today, each with the health verdict and +a fenced, copy-ready prompt. Read it to the human as *state of each project*, +then offer the prompts: + +- **awaiting a ruling** → the review prompt (read the witness, score it, draft + the ruling for approval, then `cortex.py rule`). +- **still out there** → the project's own `jobs` line. +- **ready** → the launch lines (the phase, the `submit`, the `move … submitted + --run `). +- **gated** → the refs to open. + +Never decide a ruling, never submit a job, never edit a phase file by hand: +every phase edit is `python3 scripts/cortex.py move`, every verdict is +`python3 scripts/cortex.py rule`, both in the PyAutoCortex checkout, and the +ruling body is the human's words verbatim. + +## The push rule + +`--push` is allowed only when **`gh auth status` succeeds** *and* **the Cortex +checkout is clean on `main`**. That `gh` call is a *probe*, not a GitHub +operation — a session without `gh` (a remote one, which reaches GitHub through +the `mcp__github__*` tools instead; the map is +[`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md)) simply gets `--no-push` and is +told so. Do not install `gh` to change that answer: the push wants a laptop +with a clean checkout, and a remote session has neither. That is the whole cloud/laptop split: on a +laptop it is the default and needs no asking; in a cloud session there is no +logged-in `gh`, so the default is `--no-push` and the check-in says so. + +When it pushes it cuts `claude/checkin-` from a fresh +`origin/main`, commits the changed paths **explicitly**, and pushes. If +`scripts/ledger_merge.py classify` calls the diff *code*, it stops before the +branch is cut and says why. `ledger_merge.yml` merges a ledger-only +`claude/**` push into `main` and deletes the branch — there is no PR to open +and nothing for the human to merge. **Never `main` directly, never `--force`.** ## The rules that do not bend - **The conductor never submits.** The human runs the project's own sync CLI - and then records the job id with - `cortex.py move submitted --run `. + and records the job id with `cortex.py move submitted --run `. - **A verdict recorded only outside the Cortex does not exist.** A decision reached in chat is not a ruling until `cortex.py rule` has written it. - **A phase is a question with a pre-registered witness.** If `Witness:` is - empty, the phase cannot be submitted — write the witness first. -- **Nothing grades a gate.** `gates` lists what a gated phase is waiting on - and the URL of each ref; the human opens them and, when they have closed, - types `cortex.py move ready`. The daily grading job was retired on - 2026-09-03 (2 gated refs, 0 flips ever). -- **`collect` never touches RAL, and never rules.** It reads only what the - human's own sync CLI already mirrored; `--pull` runs that CLI's `pull` and - nothing else; the `Ruling` line it emits is left blank for the human. -- **UNOBSERVABLE is not FAIL.** Two of the four `delivered:` legs are not - visible on the laptop (the checkpoint is never pulled; one project writes no - version stamp), so those members come back **SUSPECT** — read them, do not - treat them as broken runs. The checkpoint leg becomes scorable only where - the project's sync CLI writes `.cortex/pull.json` — its `checkpoints` table - is keyed by the run directory, its `runs` table by job id. -- **The evidence is on the laptop.** Every Cortex phase is reviewed there, so - a session that cannot reach the laptop tree reports what the ledger says and - stops rather than scoring an empty mirror. + empty the phase cannot be submitted — write the witness first. +- **The only thing that reaches a cluster is the project's own CLI.** The door + adds no SSH of its own, and `--dry-run` reaches nothing at all. +- **UNOBSERVABLE is not FAIL.** Some legs are not visible on the laptop (the + checkpoint is never pulled; one project writes no version stamp), so those + phases come back **SUSPECT** — read them, do not treat them as broken runs. + The checkpoint leg becomes scorable where `.cortex/pull.json` carries a + `checkpoints` table (keyed by run directory) or a `runs` table (by job id). +- **The door runs once and ends.** No timer, no subscription, no cron, no + loop — you check in, you report, you stop. +- **`rulings/` is append-only.** Never edit or delete one. + +## Appendix — the verbs it composes + +`checkin` is a composition; each part is still runnable on its own, and +`--cortex ` (a flag of the *subcommand*) points any of them at another +checkout. + +| Verb | Answers | +|------|---------| +| `checkin [--dry-run\|--apply] [--push\|--no-push] [--project KEY] [--skip-pull] [--refreshed ISO]` | Where is my science? The whole sequence above. Exit **1** = a pull failed or the tree needs a look | +| `census [--json]` | What is the Cortex holding? Phase counts by state, rulings, projects, epics | +| `dashboard --check` | Are the committed pages current? Exit **1** = stale (the refresh workflow's contract) | +| `dashboard --apply` | Regenerate `dashboard.md` + `dashboard.html` — never hand-edit those two | +| `gates` | What is each gated phase waiting on? Read-only and offline; a human types `move ready` | +| `collect [--phase REL] [--pull] [--refreshed ISO] [--apply] [--out F]` | The scorer `checkin` composes: one block per phase — six legs each `PASS`/`FAIL`/`UNOBSERVABLE`, the readout, a **blank** ruling line. Exit **1** = a phase the human must look at | diff --git a/tests/test_cortex_conductor.py b/tests/test_cortex_conductor.py index 7ee3d3b6..96142866 100644 --- a/tests/test_cortex_conductor.py +++ b/tests/test_cortex_conductor.py @@ -490,6 +490,196 @@ def test_the_default_scope_is_every_submitted_or_running_phase(board): assert rel.rsplit("/", 1)[1][:-3] in r.stdout +# --- the check-in door ----------------------------------------------------- +# `checkin` composes the primitives above; these tests are about the +# composition — what it sweeps, what it writes, what one project's failure +# does to the other's, and what it refuses to push. The scoring itself is +# covered by the `collect` block above and is not re-asserted here. + + +def _checkin(root, *args): + return _run(["checkin", "--cortex", str(root), *args]) + + +def _fake_cli(local: Path, rc: int, marker: Path) -> Path: + """A stand-in for a project's own `hpc/sync`. No test in this file runs a + real one: a real pull reaches RAL.""" + cli = local / "hpc" / "sync" + cli.parent.mkdir(parents=True, exist_ok=True) + cli.write_text(f'#!/bin/sh\necho "$1" > {marker}\nexit {rc}\n', + encoding="utf-8") + cli.chmod(0o755) + return cli + + +def test_the_dry_run_names_every_project_its_pull_and_the_phases_it_would_score(board): + """The dry run is the door's own contract: the exact command per project + and the exact phases, and nothing touched.""" + before = (board["mirror"] / ".cortex" / "pull.json").read_text() + r = _checkin(board["root"], "--dry-run") + assert r.returncode == _cortex.RC_OK, r.stdout + r.stderr + assert "Nothing is pulled, nothing is written" in r.stdout + for key in ("example", "subhalo"): + assert f"\n{key} [active]" in r.stdout + assert f"cd {board['local']} && hpc/sync pull" in r.stdout + assert f"cd {board['sub']} && hpc/sync pull" in r.stdout + for rel in BOARD: + assert rel in r.stdout + assert "push would be:" in r.stdout and "the rule:" in r.stdout + assert (board["mirror"] / ".cortex" / "pull.json").read_text() == before + + +def test_the_dry_run_is_the_default(board): + assert _checkin(board["root"]).stdout == _checkin(board["root"], + "--dry-run").stdout + + +def test_one_projects_failing_pull_does_not_stop_the_sweep(board): + """A mirror that will not sync is one project's problem. The other + projects still pull, every live phase is still scored, and the failure is + recorded against the project it belongs to.""" + ran_ok = board["sub"] / "pulled.txt" + ran_bad = board["local"] / "pulled.txt" + _fake_cli(board["local"], 1, ran_bad) # example: fails + _fake_cli(board["sub"], 0, ran_ok) # subhalo: fine + r = _checkin(board["root"], "--apply", "--no-push") + assert ran_bad.is_file() and ran_ok.is_file(), "both CLIs must be tried" + assert "example: pull exited 1" in r.stdout + assert "scored anyway" in r.stdout + assert "### example" in r.stdout and "### subhalo" in r.stdout + assert r.returncode == _cortex.RC_DRIFT, "a failed pull is not a clean run" + # the sweep still did its work on the project that did pull + mod = _cortex.load_cortex(board["root"]) + states = {p.rel: p.state for p in mod.load_phases(board["root"])[0]} + assert states["phases/subhalo/01_partial.md"] == "awaiting-ruling" + + +def test_a_pulled_project_gets_a_manifest_the_scorer_can_read(board): + _fake_cli(board["sub"], 0, board["sub"] / "pulled.txt") + r = _checkin(board["root"], "--apply", "--no-push") + manifest = json.loads((board["sub"] / ".cortex" / "pull.json").read_text()) + assert manifest["project"] == "subhalo" + assert manifest["rc"] == 0 + assert manifest["cmd"].endswith("hpc/sync pull") + assert manifest["pulled_at"] + assert "phases/subhalo/01_partial.md" in manifest["phases_live"] + assert "subhalo: pulled" in r.stdout + + +def test_the_manifest_merges_and_never_clobbers_a_richer_one(board): + """One project's own sync CLI writes the `runs`/`checkpoints` tables the + checkpoint leg reads. The check-in adds its keys beside them.""" + path = board["mirror"] / ".cortex" / "pull.json" + before = json.loads(path.read_text()) + assert "runs" in before, "the fixture's manifest is the richer shape" + _fake_cli(board["local"], 0, board["local"] / "pulled.txt") + _checkin(board["root"], "--apply", "--no-push") + after = json.loads(path.read_text()) + assert after["runs"] == before["runs"], "the CLI's own table is untouched" + assert after["project"] == "example" and after["rc"] == 0 + assert after["pulled_at"] != before["pulled_at"] + + +def test_skip_pull_scores_what_is_already_there_and_runs_no_cli(board): + marker = board["sub"] / "pulled.txt" + _fake_cli(board["sub"], 0, marker) + r = _checkin(board["root"], "--apply", "--skip-pull", "--no-push") + assert not marker.exists(), "--skip-pull runs no sync CLI" + assert r.returncode == _cortex.RC_OK, r.stdout + r.stderr + # the stamp came from the manifest the fixture already carries + assert "Refreshed: 2026-08-31T10:00Z" in r.stdout + + +def test_a_named_project_narrows_the_sweep(board): + r = _checkin(board["root"], "--dry-run", "--project", "subhalo") + assert "1 project(s)" in r.stdout + assert "\nsubhalo [active]" in r.stdout and "\nexample [active]" not in r.stdout + + +def test_the_summary_is_keyed_by_project_and_is_the_last_thing_printed(board): + """A chat reads the top of the output; the door prints the pull log first + and the summary last, so the summary is what it sees.""" + _fake_cli(board["sub"], 0, board["sub"] / "pulled.txt") + r = _checkin(board["root"], "--apply", "--no-push") + assert r.returncode == _cortex.RC_OK, r.stdout + r.stderr + out = r.stdout + assert "# Cortex check-in" in out + assert out.index("Wrote: dashboard.md") < out.index("# Cortex check-in") + assert out.index("### example") < out.index("### subhalo") + assert "Awaiting your ruling" in out + # the prompt each state already has, ready to paste + assert "help me rule on it" in out + assert f"local `{board['sub']}`" in out + + +def test_the_render_leg_leaves_the_board_current(board): + _fake_cli(board["sub"], 0, board["sub"] / "pulled.txt") + _checkin(board["root"], "--apply", "--no-push") + check = _run(["dashboard", "--cortex", str(board["root"]), "--check"]) + assert check.returncode == _cortex.RC_OK, check.stdout + check.stderr + + +def test_the_no_push_path_says_so_and_reaches_no_git(board): + _fake_cli(board["sub"], 0, board["sub"] / "pulled.txt") + r = _checkin(board["root"], "--apply", "--no-push") + assert "push: no — --no-push" in r.stdout + assert not (board["root"] / ".git").exists() + assert "claude/checkin-" not in r.stdout + + +def test_the_push_preflight_refuses_anything_that_is_not_a_clean_main(tmp_path): + ok, why = _cortex.push_preflight(tmp_path) + assert ok is False and why + + +def _with_classifier(root: Path) -> Path: + """The board fixture is a phases-and-rulings tree; the push gate lives in + the Cortex's `scripts/`, so lay the real one beside it.""" + (root / "scripts").mkdir(parents=True, exist_ok=True) + dst = root / "scripts" / "ledger_merge.py" + shutil.copy(cortex_root() / "scripts" / "ledger_merge.py", dst) + return dst + + +def _git_init(root: Path) -> None: + for args in (["init", "-q", "-b", "main"], ["add", "-A"], + ["-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-qm", "fixture"]): + subprocess.run(["git", "-C", str(root), *args], check=True) + + +def test_the_push_refuses_a_code_classified_diff(board): + """The Cortex's own classifier is the gate, asked before the branch is + cut: a diff holding code is a human's call, so nothing is committed and + no `claude/**` branch appears.""" + root = board["root"] + _with_classifier(root) + _git_init(root) + # `projects.yaml` is the science body map — paths the conductor executes + # under, so the classifier calls it code however small the diff is. + (root / "projects.yaml").write_text( + (root / "projects.yaml").read_text() + "\n# a new row\n", + encoding="utf-8") + ok, lines = _cortex.push_ledger(root, "2026-09-03", + ["projects.yaml", "dashboard.md"]) + text = "\n".join(lines) + assert ok is False + assert "REFUSED" in text and "code" in text + branches = subprocess.run(["git", "-C", str(root), "branch", "--list", + "claude/*"], capture_output=True, text=True) + assert branches.stdout.strip() == "", branches.stdout + + +def test_a_ledger_only_diff_passes_the_classifier_before_any_git_call(board): + """The other side of the same gate: the two generated pages classify as + ledger, so the refusal that follows is git's (no remote), not the + classifier's.""" + root = board["root"] + _with_classifier(root) + rc, text = _cortex.classify_paths(root, ["dashboard.md", "dashboard.html"]) + assert rc == 0, text + + # --- footing --------------------------------------------------------------- def test_the_root_resolves_by_flag_then_env_then_sibling(tmp_path, monkeypatch): monkeypatch.delenv("PYAUTO_CORTEX", raising=False)