diff --git a/AGENTS.md b/AGENTS.md index af539085..80bd926b 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: 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` | +| `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; its door never submits a run and never writes a ruling; the agent submits only on the human's ask, and the ruling is the human's word | `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/AUTONOMY.md b/AUTONOMY.md index d04ffef8..e190d45e 100644 --- a/AUTONOMY.md +++ b/AUTONOMY.md @@ -407,9 +407,10 @@ for a science one (`batch plan --kind cortex` writes it). The two surfaces hold separate records with separate `review-at:`s, so a member that read the other organ's would be reading another shift's grant. The "no record → solo `--auto`" rule above is unchanged for dev members and **inapplicable to a cortex member**: -a science member is never `--auto` — the human submits the run and rules on it, -so there is no autonomous acknowledgement for a grant to license. A Cortex record -therefore carries no `heart-ack:` and no `expected-effects:` at all (the Cortex's +a science member is never `--auto` — a run is submitted only on the human's +ask and the human rules on it, so there is no autonomous acknowledgement for a +grant to license. A Cortex record therefore carries no `heart-ack:` and no +`expected-effects:` at all (the Cortex's own `batches/AGENTS.md` drops both: the Heart gates releases, not runs), and **their absence there is never read as an unacknowledged RED** — the fields are not missing, they do not exist on that schema. diff --git a/agents/conductors/cortex/AGENTS.md b/agents/conductors/cortex/AGENTS.md index 0209a773..e4b1fdcc 100644 --- a/agents/conductors/cortex/AGENTS.md +++ b/agents/conductors/cortex/AGENTS.md @@ -2,13 +2,14 @@ > **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 +> PyAutoCortex (the science body map, the pre-registered tasks, the rulings > 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. +> Its door **never submits a run and never writes a ruling**: a run is +> submitted only on the human's ask (by the human, or by the agent in the +> session through the project's own sync CLI), 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 @@ -20,11 +21,11 @@ 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, the last check-in stamp (`--json` for the lot) | +| `census` | What is the Cortex holding? | task counts by state, the board's section counts, rulings, projects, the last check-in stamp (`--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` | -| `gates` | What is each gated phase waiting on? | every gated phase, its refs and the URL each resolves to — read-only and offline | -| `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 | +| `gates` | What is each gated task waiting on? | every gated task, its refs and the URL each resolves to — read-only and offline | +| `collect [--pull] [--refreshed ISO] [--apply] [--out F] [--task REL]` | What came back, and is it worth reviewing? | one block per task — six scored legs, the readout, a blank ruling line — and exit **1** when any is not HEALTHY. **Default scope: every `submitted \| running` task**; `--task REL` narrows | ``` pyauto-brain cortex checkin --dry-run # what it would pull and score @@ -46,8 +47,8 @@ Mind's: a session holding one organ and not the other still works. ## What it reads, and what it refuses to read - `/scripts/cortex.py` is imported at runtime and is the schema - API: `load_phases`, `load_rulings`, `load_projects`, `gates_report`, - `move_phase`. The conductor always reasons with the schema + API: `load_tasks`, `load_rulings`, `load_projects`, `gates_report`, + `move_task`. The conductor always reasons with the schema the checkout it is pointed at implements. - **Stdlib only, and Mind-free.** The renderer runs bare inside the Cortex's own `dashboard_refresh.yml`, which installs nothing and checks out no @@ -63,19 +64,30 @@ Mind's: a session holding one organ and not the other still works. The page opens on the door, not on the work: a counts table (what `board/_board.py` reads for the Brain board's Cortex strip), then the **check-in chip** — the paste for the laptop's science chat — and the **last -check-in** stamp under it. Then **Summary** (one row per active project), -then the sections in the reading order of a check-in: **Projects** (active → -planned → dormant; each card is the folders, the counts and the ONE phase to -act on next, with the rest of its open phases and its issue list behind a -fold) → **Awaiting ruling** (ordered failures → a ruling is required → clean) -→ **Running / submitted** → **Ready** (one visible row per project, the -queue behind it folded) → **Gated** → **Recent rulings**. - -No `## Where to look` bullets ride on the page — they stay in the phase file, +check-in** stamp under it. Then **Summary** (one row per active project — +its next task by that task's own summary), then **Projects** (active → +planned → dormant; each card is the folders, the counts and **every open +task of the project, one line each**: the ten-word `Summary:` as the link, a +state pill coloured by `STATE_TONES` — green planned/ready/accepted, yellow +submitted/running/pulled/awaiting-ruling, grey gated, **red** whenever a run +failed, timed out or was voided — and only the facets that state earns) → +**Awaiting ruling** (ordered failures → a ruling is required → clean) → +**Recent rulings**. + +The `Running / submitted`, `Ready` and `Gated` sections were dropped on +2026-09-07, with the "N more open phase(s)" fold: they said again, in three +more places, what the Projects block now says once, per project, in colour. +The census still cuts those buckets — they are `collect`'s and the door's +scopes — they are simply not sections of the page. Paths render as accent +chips (`.pathchip`, `--accent` fill with the theme's `--accent-ink`), not as +pink-on-tint inline code. + +No `## Where to look` bullets ride on the page — they stay in the task file, in `census --by-project` and in the check-in printout, which keep the full -per-phase tree. The Cortex has no epics of its own (retired 2026-09-04): a -science project *is* the long programme, and `Epic:` on a phase header is -only an optional join key to a **Mind** epic. +per-task tree. The Cortex has no epics of its own (retired 2026-09-04): a +science project *is* the long programme, and `Epic:` on a task header is +only an optional join key to a **Mind** epic (Mind epics really are +sequential; a Cortex task is not — that is why the number went). **`checkin.yaml`** is the one-key file (`refreshed: `) that `cmd_checkin` and `collect --apply` write from the stamp they scored against, @@ -94,11 +106,11 @@ rule of its own: it is stable between renders. ## Gates `gates` is a thin wrapper over the Cortex script's own `gates_report(root)`: -every gated phase, its refs and the URL each ref resolves to. Nothing polls +every gated task, its refs and the URL each ref resolves to. Nothing polls GitHub and nothing flips a state — gate grading was retired on 2026-09-03 (2 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`. +closed, types `python3 scripts/cortex.py move ready`. ## The door (`checkin`) @@ -106,29 +118,29 @@ 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 + project that owns a task 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 + (`project`, `pulled_at`, `cmd`, `rc`, `tasks_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 +2. **Score + move.** `collect`'s scorer over every live task, 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, + fold: each project's paths, what its pull did, its task counts, and every + task 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 +root, and every task 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. @@ -136,7 +148,7 @@ stamping the refresh from the newest manifest. `--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 +"clean" stops being true the moment the tasks 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 @@ -148,9 +160,9 @@ 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 -`--phase` it scopes to **every phase in `submitted | running`** — the runs the -Cortex believes are out there — and needs no record of any kind. `--phase REL` -(repeatable) narrows it to named phases. Each is scored against **six legs**, +`--task` it scopes to **every task in `submitted | running`** — the runs the +Cortex believes are out there — and needs no record of any kind. `--task REL` +(repeatable) narrows it to named tasks. Each is scored against **six legs**, each `PASS | FAIL | UNOBSERVABLE`: | Leg | Read from | @@ -160,7 +172,7 @@ each `PASS | FAIL | UNOBSERVABLE`: | `version` | a top-level `version` key in a JSON matching the project's `witness_file` | | `checkpoint` | `/.cortex/pull.json` only (`{"schema": 1, "pulled_at": ISO, "checkpoints": {"": {"bytes": N, "mtime": ISO}}, "runs": {"": {"checkpoint_bytes": N, "checkpoint_mtime": ISO}}}`; looked up `runs[]` → `runs[]` → `checkpoints[]`, and a manifest with no `schema` is the older `runs`-only shape) — `search_internal/checkpoint.hdf5` is never pulled, so without that manifest the leg is UNOBSERVABLE, tagged `RAL only` | | `resume` | `Fit Already Completed` / `Resuming … previous samples found` in the `.out` ⇒ FAIL: those are the previous fit's numbers | -| `witness` | a file matching `witness_file` newer than the phase's first submission | +| `witness` | a file matching `witness_file` newer than the task's first submission | HEALTH is **FAILED** if any leg failed, **SUSPECT** if any is unobservable, **HEALTHY** otherwise; `delivered:` counts the HEALTHY ones. Two of the four @@ -174,14 +186,14 @@ search roots are the project's `mirror` then `local_path`, from `projects.yaml`. **The only thing that reaches the cluster is `--pull`**, which runs that project's own ` pull` and prints the command first. `--apply` (which needs a refresh stamp, from `--pull` or `--refreshed `, -so a human cannot move phases on a stale laptop) moves each phase +so a human cannot move tasks on a stale laptop) moves each task `running → pulled → awaiting-ruling` — rehearsed on a throwaway copy of the tree first and refused outright if `cortex.py check` would not pass afterwards. -Nothing else is written. A phase whose run line is still live, or one still +Nothing else is written. A task whose run line is still live, or one still `submitted` (no such edge), is left where it is with a note. ## What it never does Submits a job · writes or edits a ruling · reads `sacct` as health · touches -RAL · consults an autonomy cap · edits a phase except through the Cortex +RAL · consults an autonomy cap · edits a task except through the Cortex script's own `move`. diff --git a/agents/conductors/cortex/_cortex.py b/agents/conductors/cortex/_cortex.py index 6d09a432..651b3331 100644 --- a/agents/conductors/cortex/_cortex.py +++ b/agents/conductors/cortex/_cortex.py @@ -3,12 +3,13 @@ The Cortex Agent is the *learning function* of PyAutoBrain: the conductor that reasons over PyAutoCortex, the organ where the organism learns what is true. -The Cortex holds the state — science phases, their pre-registered witnesses, +The Cortex holds the state — science tasks, their pre-registered witnesses, their runs and the rulings of record; this conductor holds the reasoning: it -renders the Cortex board, grades the gates, admits ready phases into a laptop -slot and scores what a pull brought back into a packet member. It **never -submits** and never edits a ruling: the run is the human's act, the verdict is -theirs, and the `Ruling` line this verb emits is left blank for them. +renders the Cortex board, grades the gates, admits ready tasks into a laptop +slot and scores what a pull brought back into a packet member. Its verbs +**never submit** and never edit a ruling: a run is submitted only on the +human's ask (through the project's own sync CLI), the verdict is theirs, and +the `Ruling` line this verb emits is left blank for them. The same split as Heart ↔ vitals and Gut ↔ hygiene — the organ keeps the state, the conductor reasons over it. @@ -24,7 +25,7 @@ renders with one repo checked out. - **The Cortex script is the API.** `/scripts/cortex.py` is stdlib-only, has no import-time side effects and exposes pure functions - (`load_phases`, `load_rulings`, `load_projects`, `gates_report`, …). It is + (`load_tasks`, `load_rulings`, `load_projects`, `gates_report`, …). It is imported at runtime from the resolved root, so this conductor always reasons with the schema the checkout it is pointed at actually implements. - **No path is named here.** Science projects live outside the workspace; the @@ -34,16 +35,16 @@ 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 [--pull] [--refreshed ISO] [--apply] [--out F] [--task REL]`. `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, +`submitted | running` task, 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 +the copy-ready prompt each task'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 +`collect` is the scorer it composes: with no `--task` it scopes to every task in `submitted | running`. It needs no batch record — the review-slot apparatus was retired 2026-09-03. @@ -218,9 +219,11 @@ def _home_from_git(root: Path) -> str: # ------------------------------------------------------------- the census --- -# Phase states, grouped into the sections the board shows. A state that is in -# no group renders in no section (planned phases are scope, not work; dropped -# and accepted phases are history the rulings section carries). +# Task states, grouped into the buckets the census keeps. `live`, `ready` and +# `gated` stopped being sections of their own on 2026-09-07 — every open task +# of a project is a line on that project's card, which is the one place the +# reader looks — but they are still the scopes `collect` and the check-in +# door work in, so the census still cuts them. AWAITING_STATES = ("pulled", "awaiting-ruling") LIVE_STATES = ("submitted", "running") FAILED_RUN_STATES = ("failed", "timeout", "void", "legacy_wrong") @@ -229,19 +232,10 @@ def _home_from_git(root: Path) -> str: SECTIONS = ( # No blurb: the section is the map, and the cards say what they are. ("projects", "Projects", "projects.yaml", ""), - ("awaiting", "Awaiting ruling", "phases/", + ("awaiting", "Awaiting ruling", "tasks/", "Results are in and nothing is running — the human's verdict is the only " - "thing outstanding. Ordered failures first, then the phases a ruling is " + "thing outstanding. Ordered failures first, then the tasks a ruling is " "required for, then the clean ones."), - ("live", "Running / submitted", "phases/", - "On the queue or on the machine. Wall is what the run lines record at the " - "last refresh, against the phase's own budget."), - ("ready", "Ready", "phases/", - "Gate cleared, witness registered — everything that could be submitted " - "today."), - ("gated", "Gated", "phases/", - "Waiting on development work. Open the references; when they have all " - "closed, `cortex.py move ready`."), ("rulings", "Recent rulings", "rulings/", "The ledger of record, newest first. A verdict recorded only outside the " "Cortex does not exist."), @@ -283,7 +277,7 @@ def read_checkin(root: Path) -> tuple[str, list[str]]: def write_checkin(root: Path, stamp: str) -> Path: """Persist the refresh stamp, before the pages are rendered: the board is meant to show the check-in that produced it, and `push_ledger` picks the - file up in the same commit as the phase moves.""" + file up in the same commit as the task moves.""" path = root / CHECKIN_FILE path.write_text(f"refreshed: {stamp}\n", encoding="utf-8") return path @@ -300,49 +294,52 @@ def _int(value: str) -> int | None: return int(v) if v.isdigit() else None -def _where_bullets(mod, ph) -> list[str]: - """The phase's own `## Where to look` bullets, `- ` stripped, verbatim. +def _where_bullets(mod, tk) -> list[str]: + """The task's own `## Where to look` bullets, `- ` stripped, verbatim. - A phase names where its results are. The by-project view renders this + A task names where its results are. The by-project view renders this text as the "which folder do I open" answer and `where_paths` resolves the same bullets to paths — one reading, two uses. """ getter = getattr(mod, "_where_to_look", None) if getter is not None: - bullets = getter(ph) + bullets = getter(tk) else: # a checkout whose script predates the helper - span = mod.sections(ph.text).get("Where to look") - bullets = ([ln for ln in ph.text.split("\n")[span[0]:span[1]] + span = mod.sections(tk.text).get("Where to look") + bullets = ([ln for ln in tk.text.split("\n")[span[0]:span[1]] if ln.startswith("- ") and ln.strip() != "-"] if span else []) return [b[2:].strip() for b in bullets if b[2:].strip()] -def _phase_row(mod, ph) -> dict: - refs, bad_refs = mod.gate_refs(ph.get("Gates")) +def _task_census_row(mod, tk) -> dict: + refs, bad_refs = mod.gate_refs(tk.get("Gates")) runs = [{"ident": r.ident, "state": r.state, "partition": r.partition, "date": r.date, "wall": r.wall, "note": r.note} - for r in ph.runs] + for r in tk.runs] walls = [_mins(r["wall"]) or 0 for r in runs] - budget = ph.get("Budget") + budget = tk.get("Budget") return { - "rel": ph.rel, - "slug": ph.slug, - "title": ph.title or ph.slug, - "project": ph.get("Project") or ph.project_dir, - "phase": _int(ph.get("Phase")), - "state": ph.state, + "rel": tk.rel, + "slug": tk.slug, + "title": tk.title or tk.slug, + # The board's line: the question this task answers, in at most ten + # words. Required by the Cortex's own `check`; the title is the + # fallback for a checkout that predates the header. + "summary": tk.get("Summary") or tk.title or tk.slug, + "project": tk.get("Project") or tk.project_dir, + "state": tk.state, "gates": refs, "bad_gates": bad_refs, - "witness": ph.get("Witness"), - "where": _where_bullets(mod, ph), + "witness": tk.get("Witness"), + "where": _where_bullets(mod, tk), "budget": budget, "budget_minutes": _mins(budget), "runs": runs, "wall_minutes": max(walls) if walls else 0, - "ruling": ph.get("Ruling"), - "epic": ph.get("Epic"), - "review_minutes": _int(ph.get("Review-minutes")), + "ruling": tk.get("Ruling"), + "epic": tk.get("Epic"), + "review_minutes": _int(tk.get("Review-minutes")), "failed_runs": [r["ident"] for r in runs if r["state"] in FAILED_RUN_STATES], } @@ -354,7 +351,7 @@ def _ruling_row(mod, r) -> dict: "rel": r.rel, "title": (r.title or r.id).split(" — ", 1)[-1] if r.title else r.id, "verb": r.get("Ruling"), - "phase": r.get("Phase"), + "task": r.get("Task"), "project": r.get("Project"), "batch": r.get("Batch"), "reviewed_at": r.get("Reviewed-at"), @@ -366,11 +363,11 @@ def census(root: Path) -> dict: """Everything the board, the plan and the counts need, in one read.""" mod = load_cortex(root) projects, project_problems = mod.load_projects(root) - phases, phase_problems = mod.load_phases(root) + tasks, task_problems = mod.load_tasks(root) rulings, ruling_problems = mod.load_rulings(root) checkin, checkin_problems = read_checkin(root) - rows = [_phase_row(mod, ph) for ph in phases] + rows = [_task_census_row(mod, tk) for tk in tasks] by_state: dict[str, int] = {} for r in rows: by_state[r["state"] or "?"] = by_state.get(r["state"] or "?", 0) + 1 @@ -386,25 +383,25 @@ def census(root: Path) -> dict: # failures → a ruling is required → clean, per the section's own promise. awaiting.sort(key=lambda r: (0 if r["failed_runs"] else 1 if r["state"] == "awaiting-ruling" else 2, - r["project"], r["phase"] or 0, r["rel"])) + r["project"], r["rel"])) live = sorted([r for r in rows if r["state"] in LIVE_STATES], - key=lambda r: (r["project"], r["phase"] or 0, r["rel"])) + key=lambda r: (r["project"], r["rel"])) ready = sorted([r for r in rows if r["state"] == "ready"], key=lambda r: (r["review_minutes"] or 999, r["project"], - r["phase"] or 0, r["rel"])) + r["rel"])) gated = sorted([r for r in rows if r["state"] == "gated"], - key=lambda r: (r["project"], r["phase"] or 0, r["rel"])) + key=lambda r: (r["project"], r["rel"])) # Not a board section — `planned` is the tail of the by-project tree, the # "and then what" a check-in reads after everything open. planned = sorted([r for r in rows if r["state"] == "planned"], - key=lambda r: (r["project"], r["phase"] or 0, r["rel"])) + key=lambda r: (r["project"], r["rel"])) home = _home(root) return { "root": str(root), "home": home, "generated": _dt.date.today().isoformat(), - "phases": rows, + "tasks": rows, "by_state": by_state, "awaiting": awaiting, "live": live, @@ -414,17 +411,15 @@ def census(root: Path) -> dict: "rulings": ruling_rows, "projects": projects, "checkin": checkin, - "problems": (project_problems + phase_problems + ruling_problems + "problems": (project_problems + task_problems + ruling_problems + checkin_problems), } def section_counts(c: dict) -> list[tuple[str, str, int]]: - """`(key, title, count)` for the counts table — the four live sections - plus the ruling ledger. `board/_board.py` reads this table.""" - sizes = {"awaiting": len(c["awaiting"]), "live": len(c["live"]), - "ready": len(c["ready"]), "gated": len(c["gated"]), - "rulings": len(c["rulings"])} + """`(key, title, count)` for the counts table — the verdicts owed and the + ruling ledger. `board/_board.py` reads this table.""" + sizes = {"awaiting": len(c["awaiting"]), "rulings": len(c["rulings"])} return [(key, title, sizes[key]) for key, title, _src, _blurb in SECTIONS if key in sizes] @@ -525,7 +520,7 @@ def _src_url(blob: str, src: str) -> str: # ------------------------------------------------------------- the payloads --- # Every row hands the reader the next command rather than a decision. The -# science verbs stay the Cortex script's and the run stays the human's. +# science verbs stay the Cortex script's and a run is only ever the human's ask. def _checkin_payload(c: dict) -> str: """The paste for the laptop's science chat — the whole door as one sentence, naming the stamp it is refreshing *from* so "since last time" @@ -535,7 +530,7 @@ def _checkin_payload(c: dict) -> str: f"last check-in ({since}): pull each project through its sync " "CLI, score every live run against its pre-registered witness, " "move what came back, re-render and push the board, then read me " - "the by-project summary with the prompt each phase needs next.") + "the by-project summary with the prompt each task needs next.") def _retire_payload(key: str) -> str: @@ -544,14 +539,14 @@ def _retire_payload(key: str) -> str: It names the project twice on purpose: once in the prose, so a chat that reads the sentence aloud says which project is being ended, and once inside the command, so the command is runnable without re-reading the - sentence. Retiring keeps the row, the phases and the rulings; only the + sentence. Retiring keeps the row, the tasks and the rulings; only the status and the note change, and the ledger has to be pushed afterwards or the board goes stale.""" return (f"/cortex — retire the science project {key}: confirm it is " f"holding nothing live, then in PyAutoCortex run `python3 " f"scripts/cortex.py retire {key} --why \"\"`, " f"`python3 scripts/cortex.py check`, `pyauto-brain cortex " - f"dashboard --apply`, and push the ledger. The row, its phases " + f"dashboard --apply`, and push the ledger. The row, its tasks " f"and its rulings all stay — only the status and the note change.") @@ -567,7 +562,7 @@ def _retire_cell_html(key: str, c: dict) -> str: def _ruling_payload(r: dict) -> str: - return (f"Review the PyAutoCortex phase {r['rel']} and help me rule on it: " + return (f"Review the PyAutoCortex task {r['rel']} and help me rule on it: " f"read its `## Witness` and the pulled evidence under its " f"`## Where to look`, score the witness, then draft the ruling " f"body for my approval and run `python3 scripts/cortex.py rule " @@ -579,13 +574,14 @@ def _live_payload(r: dict, projects: dict) -> str: cli, path = row.get("sync_cli", ""), row.get("local_path", "") if cli and path and "jobs" in (row.get("sync_verbs") or []): return f"cd {path} && {cli} jobs" - return (f"Report where the runs of the PyAutoCortex phase {r['rel']} " + return (f"Report where the runs of the PyAutoCortex task {r['rel']} " f"({', '.join(x['ident'] for x in r['runs']) or 'no runs'}) stand.") def _submit_line(project: str, projects: dict) -> str: - """The project's own submit verb, as a human would type it — or the note - that says the row has no such verb. Every path comes from the row.""" + """The project's own submit verb, as it would be typed at the laptop — or + the note that says the row has no such verb. Every path comes from the + row.""" row = projects.get(project, {}) cli, path = (row.get("sync_cli") or ""), (row.get("local_path") or "") if cli and path and "submit" in (row.get("sync_verbs") or []): @@ -595,9 +591,10 @@ def _submit_line(project: str, projects: dict) -> str: def launch_payload(r: dict, projects: dict) -> list[str]: - """The launch lines for a ready phase: the phase, the project's own - submit verb, and the move that records the job id. No decision rides - here — everything decided was decided when the plan was approved.""" + """The launch lines for a ready task: the task, the project's own submit + verb, and the move that records the job id. No decision rides here — + everything decided was decided when the plan was approved; the agent may + run these lines itself when the human says go.""" return [r["rel"], _submit_line(r["project"], projects), f"python3 scripts/cortex.py move {r['rel']} submitted " "--run "] @@ -612,63 +609,52 @@ def _gate_payload(r: dict) -> str: f"move {r['rel']} ready\n# gates: " + ", ".join(r["gates"])) -#: The states a phase already in the tree can still be opened from — the +#: The states a task already in the tree can still be opened from — the #: two the `move` table lets a human take to `ready`. OPENABLE_STATES = ("planned", "gated") -def _next_phase(r: dict, c: dict) -> dict | None: - """The phase numbered N+1 of this project, when the tree already holds it. +def _next_task(r: dict, c: dict) -> dict | None: + """A sibling of this project the tree already holds open, or None. - A planned sibling IS the ledger naming the next phase — `phases//` - is the programme, there is no separate list of it — so the prompt opens - the phase that exists rather than writing a second one beside it. + Science is unordered: there is no task N+1, so "the next one" is + whichever task of the same project is already written down and still + openable — a planned one first, then a gated one. `tasks//` IS + the programme, so the prompt opens the task that exists rather than + writing a second one beside it. """ - if r["phase"] is None: - return None - for row in c["phases"]: - if row["project"] == r["project"] and row["phase"] == r["phase"] + 1: - return row - return None - + siblings = [row for row in c["tasks"] + if row["project"] == r["project"] and row["rel"] != r["rel"] + and row["state"] in OPENABLE_STATES] + siblings.sort(key=lambda row: (OPENABLE_STATES.index(row["state"]), + row["rel"])) + return siblings[0] if siblings else None -def _next_free_phase(project: str, c: dict) -> int: - """The lowest phase number this project has not used. Phase numbers are - unique per project, so a `new` that reuses one is drift.""" - used = [r["phase"] for r in c["phases"] - if r["project"] == project and r["phase"] is not None] - return max(used) + 1 if used else 1 - -def _next_phase_payload(r: dict, c: dict) -> str: - """"The results are good" — file the ruling, then open phase N+1. +def _next_task_payload(r: dict, c: dict) -> str: + """"The results are good" — file the ruling, then open the next task. Two commands in one prompt because they are one decision: an accept that - opens nothing leaves the programme where it was. `cortex.py new` writes - `# — phase N: ` in front of `--title` itself, so the prompt - asks for the tail alone. + opens nothing leaves the programme where it was. `cortex.py new` requires + a `--summary` of at most ten words — the board's line — so the prompt asks + for that rather than a title. """ - nxt = _next_phase(r, c) - L = [f"The results for the PyAutoCortex phase {r['rel']} are good. Read " + nxt = _next_task(r, c) + L = [f"The results for the PyAutoCortex task {r['rel']} are good. Read " "its `## Witness` and the evidence under its `## Where to look`, " "draft the accept body for my approval, then file it and open the " - "next phase:", + "next task:", f"python3 scripts/cortex.py rule {r['rel']} accept --body "] - if nxt is not None and nxt["state"] in OPENABLE_STATES: - L += [f"# phase {nxt['phase']} is already in the tree ({nxt['state']})" - " — open that one, do not write a second:", + if nxt is not None: + L += [f"# {nxt['slug']} is already in the tree ({nxt['state']}) — " + "open that one, do not write a second:", f"python3 scripts/cortex.py move {nxt['rel']} ready", *launch_payload(nxt, c["projects"])[1:]] else: - num = _next_free_phase(r["project"], c) epic = f" --epic {r['epic']}" if r.get("epic") else "" - if nxt is not None: - L.append(f"# phase {nxt['phase']} is taken ({nxt['rel']}, " - f"{nxt['state']}) — the next free number is {num}:") - L += [f"python3 scripts/cortex.py new {r['project']} --phase " - f"{num}{epic} --title \" — phase {num}: ' itself>\"", - f"python3 scripts/cortex.py move phases/{r['project']}/" + L += [f"python3 scripts/cortex.py new {r['project']} " + f"--summary \"\"{epic}", + f"python3 scripts/cortex.py move tasks/{r['project']}/" ".md ready", _submit_line(r["project"], c["projects"])] return "\n".join(L) @@ -679,10 +665,10 @@ def _rerun_payload(r: dict, c: dict) -> str: A rerun is a ruling first: the verdict on what came back is what makes the next submission a rerun rather than a repeat, and `rule … rerun` - is the only edge that puts a phase back on the board. + is the only edge that puts a task back on the board. """ return "\n".join([ - f"The PyAutoCortex phase {r['rel']} needs running again. Draft the " + f"The PyAutoCortex task {r['rel']} needs running again. Draft the " "rerun body — what came back, and what changes — for my approval, " "then file it and relaunch:", f"python3 scripts/cortex.py rule {r['rel']} rerun --body ", @@ -698,19 +684,28 @@ def _planned_payload(r: dict) -> str: "`Ready when:` clause in its `## Question` is met") -def phase_chips(r: dict, c: dict) -> list[tuple[str, str]]: - """`(label, payload)` for every prompt this phase's state carries. +def _relaunch_payload(r: dict, projects: dict) -> str: + """A `rerun` task is one a ruling has already been written for: the + verdict said run it again, so what is left is the launch.""" + ruling = r["ruling"] or "see the file" + return "\n".join([f"# the rerun ruling is filed ({ruling}) — this is " + "the relaunch:", + f"python3 scripts/cortex.py move {r['rel']} ready", + *launch_payload(r, projects)[1:]]) + + +def task_chips(r: dict, c: dict) -> list[tuple[str, str]]: + """`(label, payload)` for every prompt this task's state carries. The one place the board, `census --by-project` and the check-in door - agree on what a human can do with a phase today — a state that grows a + agree on what a human can do with a task today — a state that grows a prompt grows it here and appears in all three. """ state, chips = r["state"], [] if state in AWAITING_STATES: chips.append(("rule on it", _ruling_payload(r))) - nth = "" if r["phase"] is None else f" {r['phase'] + 1}" - chips.append((f"the results are good — accept and open phase{nth}", - _next_phase_payload(r, c))) + chips.append(("the results are good — accept and open the next task", + _next_task_payload(r, c))) if state in LIVE_STATES: chips.append(("where the jobs stand", _live_payload(r, c["projects"]))) if state == "ready": @@ -719,7 +714,9 @@ def phase_chips(r: dict, c: dict) -> list[tuple[str, str]]: chips.append(("its gates", _gate_payload(r))) if state == "planned": chips.append(("open it", _planned_payload(r))) - # Only where a rerun would BE a verdict. On a running phase the job is + if state == "rerun": + chips.append(("relaunch it", _relaunch_payload(r, c["projects"]))) + # Only where a rerun would BE a verdict. On a running task the job is # still out there and "run it again" is a second submission, not a # ruling — it was noise on the row and a row taller than it needed. if state in AWAITING_STATES: @@ -756,10 +753,16 @@ def project_groups(key: str, c: dict) -> list[tuple[str, list[dict]]]: #: sorts last. STATUS_RANK = {"active": 0, "planned": 1, "dormant": 2, "retired": 3} -#: The buckets a project's open phases live in, in the order "what is the -#: next thing" is answered: a verdict owed beats a job out there beats a -#: launch beats a gate beats a plan. -OPEN_ORDER = ("awaiting", "live", "ready", "gated", "planned") +#: The order the open tasks of one project read on the board, by state. +#: Science is unordered — there is no number to sort by — so what a task +#: OWES the human is the order: a verdict, then a job out there, then a +#: launch, then a gate, then a plan. `rel` breaks the tie inside a state. +TASK_ORDER = ("awaiting-ruling", "running", "submitted", "pulled", "ready", + "gated", "planned", "rerun") + +#: The two states that are not open work: a task the ledger has finished +#: with. Everything else is on the project's card. +CLOSED_STATES = ("accepted", "dropped") def project_order(keys: list[str], c: dict) -> list[str]: @@ -775,36 +778,25 @@ def project_status(key: str, c: dict) -> str: return (c["projects"][key].get("status") or "?").strip() -def open_phases(key: str, c: dict) -> list[dict]: - """Every open phase of one project, in `OPEN_ORDER`, by phase number.""" - out = [] - for bucket in OPEN_ORDER: - out += sorted((r for r in c[bucket] if r["project"] == key), - key=lambda r: (10 ** 6 if r["phase"] is None - else r["phase"], r["rel"])) - return out +def open_tasks(key: str, c: dict) -> list[dict]: + """Every open task of one project, in `TASK_ORDER`. + All of them, not the front of a queue: the order tasks are run in is the + human's, decided by what the results say, so the board shows the whole + set and lets them pick. An unrecognised state sorts last. + """ + rows = [r for r in c["tasks"] + if r["project"] == key and r["state"] not in CLOSED_STATES] + return sorted(rows, key=lambda r: ( + TASK_ORDER.index(r["state"]) if r["state"] in TASK_ORDER + else len(TASK_ORDER), r["rel"])) -def next_open_phase(key: str, c: dict) -> dict | None: - """The one phase a project's card shows — the route from Projects to the - follow-up work. First non-empty bucket, lowest phase number in it.""" - phases = open_phases(key, c) - return phases[0] if phases else None - -def ready_groups(c: dict) -> list[tuple[dict, list[dict]]]: - """`(the visible row, the ones behind it)` per project, in the section's - own order. One project, one launch: four ready phases of one project are - a queue, and only the front of it can be submitted today.""" - rows: dict[str, list[dict]] = {} - for r in c["ready"]: - rows.setdefault(r["project"], []).append(r) - out = [] - for key in dict.fromkeys(r["project"] for r in c["ready"]): - queue = sorted(rows[key], key=lambda r: (10 ** 6 if r["phase"] is None - else r["phase"], r["rel"])) - out.append((queue[0], queue[1:])) - return out +def next_open_task(key: str, c: dict) -> dict | None: + """The one task the summary table names — the route from the table into + the project's card. First in `TASK_ORDER`.""" + tasks = open_tasks(key, c) + return tasks[0] if tasks else None def _last_ruling(key: str, c: dict) -> str: @@ -824,19 +816,18 @@ def _last_ruling(key: str, c: dict) -> str: def summary_rows(c: dict) -> list[dict]: """One row per `status: active` project — the clean summary the page - opens on, before any of it has to be read phase by phase.""" + opens on, before any of it has to be read task by task.""" out = [] for key in sorted(k for k, row in c["projects"].items() if (row.get("status") or "").strip() == "active"): - nxt = next_open_phase(key, c) + nxt = next_open_task(key, c) out.append({ "project": key, - "next": "-" if nxt is None else - (f"{nxt['phase']} {nxt['state']}" if nxt["phase"] is not None - else nxt["state"]), + # The next task by its own ten-word summary: a number and a + # state said nothing a reader could act on. + "next": "-" if nxt is None else nxt["summary"], "awaiting": sum(1 for r in c["awaiting"] if r["project"] == key), "live": sum(1 for r in c["live"] if r["project"] == key), - "ready": sum(1 for r in c["ready"] if r["project"] == key), "ruling": _last_ruling(key, c), }) return out @@ -844,7 +835,7 @@ def summary_rows(c: dict) -> list[dict]: def by_project_keys(c: dict) -> tuple[list[str], list[str]]: """`(shown, folded)` — a block each for every `status: active` project - plus any project still holding an open phase; everything else folds to + plus any project still holding an open task; everything else folds to one line, because a dormant project with nothing open is a fact, not a thing to read.""" shown = {key for key, row in c["projects"].items() @@ -883,7 +874,7 @@ def _issues_url(row: dict) -> str: def project_counts(key: str, c: dict) -> str: counts: dict[str, int] = {} - for r in c["phases"]: + for r in c["tasks"]: if r["project"] == key: counts[r["state"]] = counts.get(r["state"], 0) + 1 return " · ".join(f"{state} {n}" for state, n in sorted(counts.items())) \ @@ -894,34 +885,57 @@ def _project_facts(key: str, c: dict) -> str: """The one line of facts under a project's folders.""" row = c["projects"][key] return (f"{(row.get('status') or '?').strip()} · " - f"{(row.get('partition') or '?').strip()} partition · phases: " + f"{(row.get('partition') or '?').strip()} partition · tasks: " f"{project_counts(key, c)}") -def _fold_label(rest: list[dict]) -> str: - return f"{len(rest)} more open phase(s) · plans and issues" - - -def _phase_dir(key: str, blob: str) -> str: - """`phases//` on GitHub — where that project's plans actually +def _task_dir(key: str, blob: str) -> str: + """`tasks//` on GitHub — where that project's plans actually are. Bare backticks when the owner could not be derived.""" tree = blob.replace("/blob/main/", "/tree/main/") - return f"{tree}phases/{key}/" if blob else "" + return f"{tree}tasks/{key}/" if blob else "" + + +# ------------------------------------------------------------ the row head --- +#: The pill tone every state wears, wherever a state pill is drawn: green is +#: "nothing owed here" (a plan, a launch, a verdict already given), yellow is +#: "this is moving or it is waiting on you", grey is "waiting on someone +#: else". A state outside the table is neutral. +STATE_TONES = { + "planned": "g", "ready": "g", "accepted": "g", + "gated": "n", + "submitted": "y", "running": "y", "pulled": "y", "awaiting-ruling": "y", + # a ruling said run it again: the relaunch is owed, like a verdict is. + "rerun": "y", +} +#: The states whose row prints the job ids — "which run is this?" is only a +#: question once a run exists and before the ruling closes it. +RUN_ID_STATES = ("submitted", "running", "pulled") -# --------------------------------------------------------------- markdown --- -def _phase_head(r: dict) -> str: - """The shared head of a phase row: title, link, the facts that decide.""" - head = f"{_summary_label(r['title'])}" - facets = [f"{r['project']} phase {r['phase']}" if r["phase"] is not None - else r["project"]] - if r["budget"]: - facets.append(f"budget {r['budget']}") - if r["review_minutes"] is not None: - facets.append(f"{r['review_minutes']} review-min") - if r["runs"]: + +def state_tone(r: dict) -> str: + """The tone of one task's state pill. A failed, timed-out or voided run + overrides the state's own tone: whatever the header says, that task needs + a human before anything else does.""" + return "r" if r["failed_runs"] else STATE_TONES.get(r["state"], "n") + + +def task_facets(r: dict) -> list[str]: + """Only the facts this task's state earns — nothing a reader would have + to skip. The project is the heading above the row, the budget and the + review-minutes are in the file, and the number is gone. + """ + facets = [] + if r["state"] in RUN_ID_STATES and r["runs"]: facets.append("runs " + ", ".join(x["ident"] for x in r["runs"])) - return head + " — " + _summary_label(" · ".join(facets)) + if r["state"] in LIVE_STATES and (note := _live_note(r)): + facets.append(note) + if r["state"] == "gated": + facets.append(_gate_note(r)) + if r["failed_runs"]: + facets.append("⚠️ failed runs: " + ", ".join(r["failed_runs"])) + return facets def _live_note(r: dict) -> str: @@ -938,40 +952,26 @@ def _gate_note(r: dict) -> str: return ", ".join(r["gates"]) or "no refs" -def _project_head(r: dict) -> str: - """A by-project phase line: the phase head, its state, and the warning a - failed run earns. State is on the line because this section is not keyed - by it.""" +# --------------------------------------------------------------- markdown --- +def _task_head(r: dict) -> str: + """One task's line: its ten-word summary as the link, its state, and the + facets that state earns. One line, and short enough to read at a glance — + that is the whole point of the `Summary:` header. + """ # ``, not `**`: this head is rendered inside a ``, which is # HTML on both twins and where markdown emphasis would show as asterisks. - head = _phase_head(r) + f" — {r['state']}" - if r["failed_runs"]: - head += " — ⚠️ " + _summary_label( - "failed runs: " + ", ".join(r["failed_runs"])) - return head - - -def _project_phase_md(r: dict, c: dict) -> list[str]: - """One phase inside `## By project`: the line, the folders it names - verbatim, then a copy chip per prompt its state carries.""" - L = [_project_head(r), ""] - note = _live_note(r) if r["state"] in LIVE_STATES else "" - if note: - L += [f"- {_summary_label(note)}", ""] - if r["where"]: - L += [f"- where to look: {b}" for b in r["where"]] + [""] - L += _items([_task_row(label, payload) - for label, payload in phase_chips(r, c)]) or \ - ["- _(no prompt for this state)_"] - return L + [""] + head = (f"{_summary_label(r['summary'])}" + f" — {r['state']}") + facets = task_facets(r) + return head + (" — " + _summary_label(" · ".join(facets)) if facets else "") def render_dashboard(c: dict) -> str: """The Cortex board as `dashboard.md`. - Section order is the reading order of a slot: what needs a verdict, what - is in flight, what could be launched, what is waiting on development — - then the ledger, the programmes and the map. + Section order is the reading order of a morning: the summary, the map — + every project with every open task of it, one line each — then the + verdicts owed and the ledger of the ones already given. """ home = c.get("home", "") blob = f"{home}/blob/main/" if home else "" @@ -998,12 +998,12 @@ def h2(key: str) -> list: pages = _pages_url(home) if pages: L += [f"This is the markdown version of the " - f"[PyAutoCortex Dashboard]({pages}), which puts a phase's next " + f"[PyAutoCortex Dashboard]({pages}), which puts a task's next " "command on your clipboard with a single tap of 📋.", ""] L += [ - "Every science phase the Cortex is holding, on one page: what is " - "waiting on your verdict, what is running, what could be launched " - "next and what is still gated on development work. The verdict is " + "Every science task the Cortex is holding, on one page: one line each " + "under the project it belongs to, coloured by what it is doing — " + "planned, running, waiting on your verdict, or broken. The verdict is " "always yours — this page hands you the command, never the ruling.", "", f"> **Last updated {c['generated']}.**", @@ -1034,10 +1034,10 @@ def h2(key: str) -> list: "and when it last ruled.", ""] rows = summary_rows(c) if rows: - L += ["| Project | Next phase | Awaiting | Running | Ready | " - "Last ruling |", "|---|---|---:|---:|---:|---|"] + L += ["| Project | Next task | Awaiting | Running | Last ruling |", + "|---|---|---:|---:|---|"] L += [f"| {_cell(r['project'])} | {_cell(r['next'])} | " - f"{r['awaiting']} | {r['live']} | {r['ready']} | " + f"{r['awaiting']} | {r['live']} | " f"{_cell(r['ruling'])} |" for r in rows] L += [""] else: @@ -1052,7 +1052,7 @@ def h2(key: str) -> list: rest = [k for k in ordered if project_status(k, c) != "retired"] L += ["#### Nothing open", ""] if rest: - L += ["| Project | Status | Phases | Retire |", "|---|---|---|---|"] + L += ["| Project | Status | Tasks | Retire |", "|---|---|---|---|"] L += [f"| {_cell(k)} | {_cell(project_status(k, c))} | " f"{_cell(project_counts(k, c))} | " f"{'retire ↓' if project_status(k, c) == 'dormant' else ''} |" @@ -1071,8 +1071,9 @@ def h2(key: str) -> list: L += ["", "", ""] # The map: active projects, then planned, then dormant. Each card is - # where the project lives, what it holds and the ONE phase to act on - # next — the plans and the issues ride behind a fold, one click away. + # where the project lives, what it holds, and EVERY open task of it — + # one line each, in the order they owe the human something. Nothing + # folds: a task a reader has to click to see is a task they do not run. L += h2("projects") for key in project_order(shown, c): row = c["projects"][key] @@ -1080,83 +1081,46 @@ def h2(key: str) -> list: for label, path in project_paths(row): L += [f"**{label}** `{_cell(path)}`", ""] L += [_project_facts(key, c), ""] - rest = open_phases(key, c) - nxt = next_open_phase(key, c) - if nxt is None: - L += ["_Nothing open — every phase of this project is history._", + tasks = open_tasks(key, c) + if not tasks: + L += ["_Nothing open — every task of this project is history._", ""] continue - rest = rest[1:] - chips = phase_chips(nxt, c) - L += _items([_task_row(_project_head(nxt), chips[0][1])] - + [_task_row("↳ " + label, payload) - for label, payload in chips[1:]]) or \ - [f"- {_project_head(nxt)}"] - L += [""] - if rest: - L += [f"
{_fold_label(rest)}", ""] - L += [f"- [{_summary_label(r['title'])}]({r['rel']}) — " - f"{r['state']}" for r in rest] - links = [f"[phases/{key}/]({_phase_dir(key, blob)})" if blob - else f"`phases/{key}/`"] - if _issues_url(row): - links.append(f"[issues]({_issues_url(row)})") - L += ["", " · ".join(links), "", "
", ""] + rows_md = [] + for r in tasks: + chips = task_chips(r, c) + if chips: + rows_md.append(_task_row(_task_head(r), chips[0][1])) + rows_md += [_task_row("↳ " + label, payload) + for label, payload in chips[1:]] + else: # a state that carries no prompt — the line, and no chip + rows_md.append(f"- {_task_head(r)}") + L += _items(rows_md) + links = [f"[tasks/{key}/]({_task_dir(key, blob)})" if blob + else f"`tasks/{key}/`"] + if _issues_url(row): + links.append(f"[issues]({_issues_url(row)})") + L += ["", " · ".join(links), ""] + L += h2("awaiting") awaiting_rows = [] for r in c["awaiting"]: - head = _phase_head(r) + (" — ⚠️ " + _summary_label( - "failed runs: " + ", ".join(r["failed_runs"])) - if r["failed_runs"] else "") # chip 0 IS the rule prompt this row has always carried; the rest — # accept-and-open-the-next, run-it-again — ride beside it. - chips = phase_chips(r, c) - awaiting_rows.append(_task_row(head, chips[0][1])) + chips = task_chips(r, c) + awaiting_rows.append(_task_row(_task_head(r), chips[0][1])) awaiting_rows += [_task_row("↳ " + label, payload) for label, payload in chips[1:]] L += _items(awaiting_rows) or ["- _(nothing awaiting a ruling)_"] L += [""] - L += h2("live") - live_rows = [] - for r in c["live"]: - head = _phase_head(r) + (" — " + _summary_label(note) - if (note := _live_note(r)) else "") - chips = phase_chips(r, c) - live_rows.append(_task_row(head, chips[0][1])) - live_rows += [_task_row("↳ " + label, payload) - for label, payload in chips[1:]] - L += _items(live_rows) or ["- _(nothing on the queue)_"] - L += [""] - - L += h2("ready") - ready_rows = [] - for first, rest in ready_groups(c): - ready_rows.append(_task_row(_phase_head(first), - _ready_payload(first, c["projects"]))) - if rest: - ready_rows.append("\n".join( - [f"
{len(rest)} more ready", ""] - + _items([_task_row(_phase_head(r), - _ready_payload(r, c["projects"])) - for r in rest]) - + ["", "
"])) - L += _items(ready_rows) or ["- _(nothing ready to launch)_"] - L += [""] - - L += h2("gated") - L += _items([_task_row(_phase_head(r) + " — " + _summary_label(_gate_note(r)), - _gate_payload(r)) - for r in c["gated"]]) or ["- _(nothing gated)_"] - L += [""] - L += h2("rulings") if c["rulings"]: - L += ["| Ruling | Verb | Phase | Batch |", "|---|---|---|---|"] + L += ["| Ruling | Verb | Task | Batch |", "|---|---|---|---|"] for r in c["rulings"][:RECENT_RULINGS]: head = f"[{r['id']}]({r['rel']})" + ("" if r["head"] else " (superseded)") L.append(f"| {head} | {_cell(r['verb'] or '-')} | " - f"{_cell(r['phase'] or '-')} | {_cell(r['batch'] or '-')} |") + f"{_cell(r['task'] or '-')} | {_cell(r['batch'] or '-')} |") L += [""] else: L += ["- _(no rulings yet)_", ""] @@ -1204,6 +1168,19 @@ def h2(key: str) -> list: "letter-spacing:.01em;margin-top:1.6rem}" ".paths{margin:.2rem 0}.paths b{display:inline-block;" "min-width:3.6rem}" + # A path is the one thing on a card that is READ character by + # character, and pink-on-tint `code` was the complaint that + # started this: white on the organ's own accent, filled. The + # dark scheme's accent is too bright for white (2.6:1), so the + # chip darkens its fill there rather than swap the ink — the + # human asked for white, in both themes. + ".pathchip{display:inline-block;background:var(--accent);" + "color:#fff;font-family:ui-monospace," + "SFMono-Regular,Menlo,monospace;font-size:.88em;" + "padding:.12em .5em;border-radius:.3rem;" + "overflow-wrap:anywhere}" + "@media(prefers-color-scheme:dark){.pathchip{" + "background:color-mix(in srgb,var(--accent) 55%,#000)}}" ".stale{color:var(--bad);font-weight:600}") # The page is static, so a stamp is never stale *at render* — it is stale on @@ -1245,18 +1222,16 @@ def h2(key): return head return head + f'

{_md_inline(blurbs[key])}

' - def phase_head(r, tone_pills=()): - head = link(r["rel"], _summary_label(r["title"])) - head += pills(*tone_pills) if tone_pills else "" - facets = [f"{r['project']} phase {r['phase']}" if r["phase"] is not None - else r["project"]] - if r["budget"]: - facets.append(f"budget {r['budget']}") - if r["review_minutes"] is not None: - facets.append(f"{r['review_minutes']} review-min") - if r["runs"]: - facets.append("runs " + ", ".join(x["ident"] for x in r["runs"])) - return head + f' — {_summary_label(" · ".join(facets))}' + def task_head(r): + """The HTML twin of `_task_head`: the summary as the link, the state + as a toned pill, then only the facets that state earns.""" + head = link(r["rel"], _summary_label(r["summary"])) + head += pills((r["state"], state_tone(r))) + facets = task_facets(r) + if not facets: + return head + return head + (' — ' + f'{_summary_label(" · ".join(facets))}') counts = section_counts(c) H = [ @@ -1272,9 +1247,10 @@ def phase_head(r, tone_pills=()): "", "", hero(THEME_ORGAN, "Dashboard", - "Every science phase the Cortex is holding. Tap a phase's 📋 and " - "its next command is on your clipboard. The verdict is always " - "yours — this page hands you the command, never the ruling."), + "Every science task the Cortex is holding, one line each under " + "its project. Tap a task's 📋 and its next command is on your " + "clipboard. The verdict is always yours — this page hands you " + "the command, never the ruling."), stats(*[(n, title) for _key, title, n in counts]), ] # One line, deliberately: the `--check` normaliser drops it whole so a @@ -1314,11 +1290,11 @@ def phase_head(r, tone_pills=()): rows = summary_rows(c) if rows: H += ['', - "" - ""] + "" + ""] H += [f"" f"" - f"" + f"" f"" for r in rows] H.append("
ProjectNext phaseAwaitingRunningReadyLast ruling
ProjectNext taskAwaitingRunningLast ruling
{_summary_label(r['project'])}{_summary_label(r['next'])}{r['awaiting']}{r['live']}{r['ready']}{r['live']}{_summary_label(r['ruling'])}
") else: @@ -1330,7 +1306,7 @@ def phase_head(r, tone_pills=()): H += ["

Nothing open

"] if rest: H += ['', - "" + "" ""] H += [f"" @@ -1350,95 +1326,51 @@ def phase_head(r, tone_pills=()): H.append('
') H.append(f"

{_summary_label(key)}

") H += [f'

{label} ' - f"{_summary_label(path)}

" + f'{_summary_label(path)}

' for label, path in project_paths(row)] H.append(f'

{_summary_label(_project_facts(key, c))}' "

") - rest = open_phases(key, c) - nxt = next_open_phase(key, c) - if nxt is None: - H.append('

(nothing open — every phase of this ' + tasks = open_tasks(key, c) + if not tasks: + H.append('

(nothing open — every task of this ' "project is history)

") continue - rest = rest[1:] - text = phase_head(nxt, ((nxt["state"], "n"),)) - if nxt["failed_runs"]: - text += (' — ⚠️ failed runs: ' - + _summary_label(", ".join(nxt["failed_runs"])) - + "") - chips = phase_chips(nxt, c) - if chips: - H.append(_html_task(text, chips[0][1])) - H += [_html_task(f"↳ {_summary_label(label)}", payload) - for label, payload in chips[1:]] - else: - H.append(f"

{text}

") - if rest: - H.append(f"
{_fold_label(rest)}
    ") - H += [f"
  • {link(r['rel'], _summary_label(r['title']))}" - f' — {r["state"]}
  • ' - for r in rest] - links = [f'phases/' - f"{_summary_label(key)}/" if blob - else f"phases/{_summary_label(key)}/"] - if _issues_url(row): - links.append(f'issues') - H.append("

" + " · ".join(links) + "

") + for r in tasks: + chips = task_chips(r, c) + if chips: + H.append(_html_task(task_head(r), chips[0][1])) + H += [_html_task(f"↳ {_summary_label(label)}", payload) + for label, payload in chips[1:]] + else: + H.append(f"

{task_head(r)}

") + links = [f'tasks/' + f"{_summary_label(key)}/" if blob + else f"tasks/{_summary_label(key)}/"] + if _issues_url(row): + links.append(f'issues') + H.append('

' + " · ".join(links) + "

") H.append("") H.append(h2("awaiting")) for r in c["awaiting"]: - tone = ("failures", "r") if r["failed_runs"] else (r["state"], "y") - chips = phase_chips(r, c) - H.append(_html_task(phase_head(r, (tone,)), chips[0][1])) + chips = task_chips(r, c) + H.append(_html_task(task_head(r), chips[0][1])) H += [_html_task(f"↳ {_summary_label(label)}", payload) for label, payload in chips[1:]] if not c["awaiting"]: H.append('

(nothing awaiting a ruling)

') - H.append(h2("live")) - for r in c["live"]: - note = _live_note(r) - text = phase_head(r, ((r["state"], "n"),)) - if note: - text += f' — {_summary_label(note)}' - chips = phase_chips(r, c) - H.append(_html_task(text, chips[0][1])) - H += [_html_task(f"↳ {_summary_label(label)}", payload) - for label, payload in chips[1:]] - if not c["live"]: - H.append('

(nothing on the queue)

') - - H.append(h2("ready")) - for first, rest in ready_groups(c): - H.append(_html_task(phase_head(first, (("ready", "g"),)), - _ready_payload(first, c["projects"]))) - if rest: - H.append(f"
{len(rest)} more ready") - H += [_html_task(phase_head(r, (("ready", "g"),)), - _ready_payload(r, c["projects"])) for r in rest] - H.append("
") - if not c["ready"]: - H.append('

(nothing ready to launch)

') - - H.append(h2("gated")) - for r in c["gated"]: - text = phase_head(r, tuple((ref, "y") for ref in r["gates"])) - H.append(_html_task(text, _gate_payload(r))) - if not c["gated"]: - H.append('

(nothing gated)

') - H.append(h2("rulings")) if c["rulings"]: H += ['
ProjectStatusPhases
ProjectStatusTasksRetire
{_summary_label(k)}" f"{_summary_label(project_status(k, c))}
', - ""] + ""] for r in c["rulings"][:RECENT_RULINGS]: head = link(r["rel"], _summary_label(r["id"])) if not r["head"]: head += ' (superseded)' H.append(f"" f"" - f"" + f"" f"") H.append("
RulingVerbPhaseBatch
RulingVerbTaskBatch
{head}{_summary_label(r['verb'] or '-')}{_summary_label(r['phase'] or '-')}{_summary_label(r['task'] or '-')}{_summary_label(r['batch'] or '-')}
") else: @@ -1474,7 +1406,7 @@ def render_pages(c: dict) -> dict: def emit_census(c: dict) -> None: print("== Cortex census ==") print(f"Root: {c['root']}") - print(f"Phases: {len(c['phases'])} " + print(f"Tasks: {len(c['tasks'])} " + " · ".join(f"{k} {n}" for k, n in sorted(c["by_state"].items()))) print(f"Board: " + " · ".join( f"{title.lower()} {n}" for _k, title, n in section_counts(c))) @@ -1512,7 +1444,7 @@ def emit_by_project(c: dict) -> None: # ---------------------------------------------------------------- collect --- -# What a pull brought back, scored against the phase's own pre-registered +# What a pull brought back, scored against the task's own pre-registered # witness. Two facts shape every rule below. # # **The laptop is the whole world.** This verb reads only what the human's own @@ -1591,15 +1523,15 @@ def _stamp_dt(value: str) -> _dt.datetime | None: return None -def _since(ph) -> _dt.datetime: +def _since(tk) -> _dt.datetime: """The earliest date at which an artefact could belong to this campaign. The *first* submission, not the last: an array resubmitted on Tuesday does not make Monday's outputs stale, and a witness written by the run that - preceded a failed resubmit is still this phase's witness. Freshness here - means "not left over from before the phase started". + preceded a failed resubmit is still this task's witness. Freshness here + means "not left over from before the task started". """ - days = sorted(r.date for r in ph.runs if r.date) + days = sorted(r.date for r in tk.runs if r.date) if not days: return _dt.datetime.min try: @@ -1666,15 +1598,15 @@ def _under(path: Path, root: Path) -> bool: return path == root or root in path.parents -def where_paths(mod, ph, roots: list[Path]) -> list[Path]: - """The phase's own `## Where to look` bullets, intersected with the roots. +def where_paths(mod, tk, roots: list[Path]) -> list[Path]: + """The task's own `## Where to look` bullets, intersected with the roots. - A phase names where its results are; that is the first place to look. A + A task names where its results are; that is the first place to look. A bullet pointing outside every root is not this project's tree (a RAL path, say) and is dropped rather than followed. """ out = [] - for bullet in _where_bullets(mod, ph): + for bullet in _where_bullets(mod, tk): token = bullet.split()[0].strip("`,.") if not token: continue @@ -1750,14 +1682,14 @@ def _time_to_run(text: str) -> tuple | None: def witness_matches(roots: list[Path], pattern: str, since: _dt.datetime, tokens: set | None = None) -> list[Path]: - """Every file matching the project's `witness_file` glob, this phase's own + """Every file matching the project's `witness_file` glob, this task's own first. - `witness_file` is a *project-wide* glob and a project's phases share one - output tree, so the glob alone would hand a phase its neighbour's numbers. - A file whose path names this phase's run stem, or the directory its results - were pulled into, is this phase's witness; everything else sorts behind it, - newest first, rather than being hidden — a phase whose witness landed + `witness_file` is a *project-wide* glob and a project's tasks share one + output tree, so the glob alone would hand a task its neighbour's numbers. + A file whose path names this task's run stem, or the directory its results + were pulled into, is this task's witness; everything else sorts behind it, + newest first, rather than being hidden — a task whose witness landed somewhere unexpected still has a witness. """ pattern = (pattern or "").strip() @@ -1870,7 +1802,7 @@ def _manifest_run_dir_key(roots: list[Path], run_dir) -> str: return "" -def leg_checkpoint(manifest: dict, roots: list[Path], run_dir, ph) -> tuple: +def leg_checkpoint(manifest: dict, roots: list[Path], run_dir, tk) -> tuple: """Three lookups, in order: the job id, its bare stem, then the run directory. The third is the one the profiling project can answer — its pull carries no job id at all, so `runs` is empty there and `checkpoints` @@ -1879,7 +1811,7 @@ def leg_checkpoint(manifest: dict, roots: list[Path], run_dir, ph) -> tuple: ckpts = (manifest.get("checkpoints") if isinstance(manifest.get("checkpoints"), dict) else {}) rows: list[tuple] = [] - for r in ph.runs: + for r in tk.runs: row = runs.get(r.ident) or runs.get(r.stem) if isinstance(row, dict): rows.append((r.ident, int(row.get("checkpoint_bytes") or 0))) @@ -1945,26 +1877,26 @@ def _readout(hits: list[Path]) -> list[tuple]: return [] -def score_phase(mod, ph, projects: dict) -> dict: - """Every leg of one phase, plus what the packet block needs to print it.""" - key = ph.get("Project") or ph.project_dir +def score_task(mod, tk, projects: dict) -> dict: + """Every leg of one task, plus what the packet block needs to print it.""" + key = tk.get("Project") or tk.project_dir row = projects.get(key, {}) roots = project_roots(row) - stems = sorted({r.stem for r in ph.runs}) + stems = sorted({r.stem for r in tk.runs}) logs = find_logs(roots, stems) - since = _since(ph) - where = where_paths(mod, ph, roots) + since = _since(tk) + where = where_paths(mod, tk, roots) run_dir, zip_path = run_artifacts(roots, where, since) pattern = (row.get("witness_file") or "").strip() hits = witness_matches(roots, pattern, since, - tokens={r.stem for r in ph.runs} + tokens={r.stem for r in tk.runs} | {p.name for p in where}) - budget = ph.get("Budget") + budget = tk.get("Budget") legs = { "err": leg_err(logs["err"]), "wall": leg_wall(logs["out"], run_dir, zip_path, _mins(budget), budget), "version": leg_version(hits), - "checkpoint": leg_checkpoint(pull_manifest(roots), roots, run_dir, ph), + "checkpoint": leg_checkpoint(pull_manifest(roots), roots, run_dir, tk), "resume": leg_resume(logs["out"]), "witness": leg_witness(hits, roots, pattern), } @@ -1972,12 +1904,12 @@ def score_phase(mod, ph, projects: dict) -> dict: str(logs["out"][0].parent) if logs["out"] else str(roots[0]) if roots else "") return { - "slug": ph.slug, - "rel": ph.rel, - "phase": ph, + "slug": tk.slug, + "rel": tk.rel, + "task": tk, "project": key, - "state": ph.state, - "live_run": any(r.state in mod.LIVE_RUN_STATES for r in ph.runs), + "state": tk.state, + "live_run": any(r.state in mod.LIVE_RUN_STATES for r in tk.runs), "legs": legs, "health": health_of(legs), "roots": roots, @@ -1991,32 +1923,32 @@ def score_phase(mod, ph, projects: dict) -> dict: # ------------------------------------------------------ the packet member --- -def _section_text(mod, ph, name: str) -> str: - span = mod.sections(ph.text).get(name) +def _section_text(mod, tk, name: str) -> str: + span = mod.sections(tk.text).get(name) if span is None: return "" - body = "\n".join(ph.text.split("\n")[span[0]:span[1]]).strip() + body = "\n".join(tk.text.split("\n")[span[0]:span[1]]).strip() return body def member_block(mod, s: dict) -> list[str]: - """One collected phase, as the report reads it.""" - ph = s["phase"] + """One collected task, as the report reads it.""" + tk = s["task"] facets = [s["project"]] - if ph.get("Phase"): - facets.append(f"phase {ph.get('Phase')}") - if ph.get("Budget"): - facets.append(f"budget {ph.get('Budget')}") - if ph.runs: - facets.append("runs " + ", ".join(r.ident for r in ph.runs)) + if tk.get("Summary"): + facets.append(tk.get("Summary")) + if tk.get("Budget"): + facets.append(f"budget {tk.get('Budget')}") + if tk.runs: + facets.append("runs " + ", ".join(r.ident for r in tk.runs)) L = [f"## {s['slug']} — {s['health']}", "", f"`{s['rel']}` — " + " · ".join(facets), "", - "**Question**", "", _section_text(mod, ph, "Question") or "_(none)_", + "**Question**", "", _section_text(mod, tk, "Question") or "_(none)_", "", "**Witness**", ""] - registered = ph.get("Witness") + registered = tk.get("Witness") if registered: L += [f"Registered: {registered}", ""] - L += [_section_text(mod, ph, "Witness") or "_(none)_", "", + L += [_section_text(mod, tk, "Witness") or "_(none)_", "", "**Health evidence**", ""] L += [f"- {LEG_TITLES[k]} — {s['legs'][k][0]} — {s['legs'][k][1]}" for k in LEGS] @@ -2030,10 +1962,10 @@ def member_block(mod, s: dict) -> list[str]: # of it here is the conductor deciding. L += ["", "**Ruling**", "", "_(one line — yours to write)_", "", "**Your review**", ""] - L += (["Leave to finish — a run of this phase is still live"] + L += (["Leave to finish — a run of this task is still live"] if s["live_run"] else ["Accept / Rerun / Drop / Leave to finish"]) L += ["", "**Follow-ups**", ""] - refs = mod.gate_refs(ph.get("Gates"))[0] + refs = mod.gate_refs(tk.get("Gates"))[0] L += ([f"- [{ref.split('#')[0]}] {ref}" for ref in refs] if refs else ["_(none yet — add them as you rule)_"]) L += ["", "**Where to look yourself**", ""] @@ -2050,7 +1982,7 @@ def member_block(mod, s: dict) -> list[str]: "mirrored to the laptop") if len(L) and L[-1] == "": L.append("_(nothing found on the laptop)_") - L += ["", f"**Est. review-minutes** — {ph.get('Review-minutes') or '?'}", ""] + L += ["", f"**Est. review-minutes** — {tk.get('Review-minutes') or '?'}", ""] return L @@ -2067,25 +1999,25 @@ def collect_report(mod, scope: str, scored: list, notes: list, def apply_ops(root: Path, mod, scored: list) -> list[str]: - """Move every scored phase along the state table. Returns the notes. + """Move every scored task along the state table. Returns the notes. `submitted → pulled` is not an edge in the Cortex's transition table and a - phase whose run line is still live has not finished, so both are left where + task whose run line is still live has not finished, so both are left where they are with a note rather than forced. Nothing else is written: the batch record this once rewrote is closed history (retired 2026-09-03). """ notes: list[str] = [] - by_rel = {ph.rel: ph for ph in mod.load_phases(root)[0]} + by_rel = {tk.rel: tk for tk in mod.load_tasks(root)[0]} for s in scored: - ph = by_rel.get(s["rel"]) - if ph is None: + tk = by_rel.get(s["rel"]) + if tk is None: notes.append(f"{s['slug']}: {s['rel']} is gone — not moved") continue - state = ph.state + state = tk.state try: if state == "running" and not any(r.state in mod.LIVE_RUN_STATES - for r in ph.runs): - mod.move_phase(root, ph.rel, "pulled", + for r in tk.runs): + mod.move_task(root, tk.rel, "pulled", pulled_to=s["pulled_to"] or None) state = "pulled" elif state == "running": @@ -2093,9 +2025,9 @@ def apply_ops(root: Path, mod, scored: list) -> list[str]: "submitted | running") elif state == "submitted": notes.append(f"{s['slug']}: left submitted — submitted → pulled " - "is not an edge; `move running` first") + "is not an edge; `move running` first") if state == "pulled": - mod.move_phase(root, ph.rel, "awaiting-ruling") + mod.move_task(root, tk.rel, "awaiting-ruling") state = "awaiting-ruling" except mod.CortexError as e: notes.append(f"{s['slug']}: {e}") @@ -2129,33 +2061,33 @@ def run_pull(projects: dict, keys: list[str]) -> list[str]: def cmd_collect(root: Path, mod, a) -> int: - """The check-in. With no `--phase` the scope is every phase the Cortex + """The check-in. With no `--task` the scope is every task the Cortex believes is out there — `submitted | running` — because "what came back?" is a question about the runs, not about a slot somebody opened.""" projects = mod.load_projects(root)[0] - by_rel = {ph.rel: ph for ph in mod.load_phases(root)[0]} + by_rel = {tk.rel: tk for tk in mod.load_tasks(root)[0]} notes: list[str] = [] - phases = [] - if a.phase: - for rel in a.phase: - ph = by_rel.get(rel) - if ph is None: - notes.append(f"{rel}: no such phase — skipped") + tasks = [] + if a.task: + for rel in a.task: + tk = by_rel.get(rel) + if tk is None: + notes.append(f"{rel}: no such task — skipped") else: - phases.append(ph) + tasks.append(tk) else: - phases = [ph for ph in by_rel.values() if ph.state in LIVE_STATES] + tasks = [tk for tk in by_rel.values() if tk.state in LIVE_STATES] if a.pull: - notes += run_pull(projects, sorted({ph.get("Project") or ph.project_dir - for ph in phases})) + notes += run_pull(projects, sorted({tk.get("Project") or tk.project_dir + for tk in tasks})) stamp = a.refreshed.strip() or (_utc_now() if a.pull else "") - scored = [score_phase(mod, ph, projects) for ph in phases] + scored = [score_task(mod, tk, projects) for tk in tasks] if a.apply: if not stamp: - # `--apply` moves phases on the strength of what is on the laptop, + # `--apply` moves tasks on the strength of what is on the laptop, # so the human has to say the laptop is current: either this run # pulled, or they pulled by hand and stamped it. print("cortex: --apply needs a refresh stamp — run it with --pull, " @@ -2172,14 +2104,14 @@ def cmd_collect(root: Path, mod, a) -> int: for problem in problems[:10]: print(f" {problem}", file=sys.stderr) return RC_DRIFT - # The same stamp the door writes: an `--apply` here moved phases on + # The same stamp the door writes: an `--apply` here moved tasks on # the strength of this pull, so the board must say so too. write_checkin(root, stamp) delivered = sum(1 for s in scored if s["health"] == "HEALTHY") - scope = " ".join(a.phase) if a.phase else "submitted | running" + scope = " ".join(a.task) if a.task else "submitted | running" body = collect_report(mod, scope, scored, notes, stamp) - print(f"collect [{scope}]: {len(scored)} phase(s), delivered " + print(f"collect [{scope}]: {len(scored)} task(s), delivered " f"{delivered}/{len(scored)}") if a.out: Path(a.out).write_text(body, encoding="utf-8") @@ -2192,7 +2124,7 @@ def cmd_collect(root: Path, mod, a) -> int: def _apply_checked(root: Path, mod, scored: list) -> tuple: """`(problems, notes, wrote)` — rehearse the writes, then make them. - `move_phase` writes phase by phase; a rejection halfway through would leave + `move_task` writes task by task; a rejection halfway through would leave the tree in a state `check` fails on and no way back. So the whole apply is run against a throwaway copy first and only replayed on the real tree when `check_problems` comes back clean — and checked again afterwards, because @@ -2216,10 +2148,10 @@ def _apply_checked(root: Path, mod, scored: list) -> tuple: # --------------------------------------------------------------- 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, +# "where is my science?" — sync every active project, score every live task, # 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 +# own reasoning; a task edit is still `cortex.py move`'s and a verdict is # still the human's. # # Three rules it does not bend: @@ -2240,22 +2172,22 @@ def _apply_checked(root: Path, mod, scored: list) -> tuple: "checkout to be clean on `main`") -def checkin_keys(projects: dict, phases: list, only: list) -> tuple: +def checkin_keys(projects: dict, tasks: list, only: list) -> tuple: """`(keys, notes)` — the projects one check-in sweeps. - Every `status: active` row, plus any project that owns a phase in + Every `status: active` row, plus any project that owns a task 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 tk in tasks: + if tk.state in LIVE_STATES: + keys.add(tk.get("Project") or tk.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 " + notes.append(f"{key}: a live task names a project with no row in " "projects.yaml — not pulled") keys &= set(projects) if only: @@ -2324,7 +2256,7 @@ def pull_root(row: dict) -> Path | None: def write_pull_manifest(root_dir: Path, key: str, cmd: str, rc: int, - phases_live: list) -> Path | None: + tasks_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 @@ -2343,7 +2275,7 @@ def write_pull_manifest(root_dir: Path, key: str, cmd: str, rc: int, if isinstance(loaded, dict): data = loaded data.update({"project": key, "pulled_at": _utc_now(), "cmd": cmd, - "rc": rc, "phases_live": list(phases_live)}) + "rc": rc, "tasks_live": list(tasks_live)}) try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", @@ -2363,7 +2295,7 @@ 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 + being true the moment the tasks 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 @@ -2457,7 +2389,7 @@ def push_ledger(root: Path, date: str, paths: list) -> tuple: 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 " + "Task moves and the re-rendered board from " "`pyauto-brain cortex checkin --apply`.\n") commit = _git(root, "commit", "-m", msg) if commit.returncode != 0: @@ -2483,27 +2415,27 @@ def _payload_block(payload: str) -> list[str]: def project_digest(key: str, row: dict, c: dict, scored_by_rel: dict, pull_line: str = "") -> list[str]: """One project's block of the by-project tree — where it lives, what came - of its pull, and every phase of it a human could act on today: its health + of its pull, and every task of it a human could act on today: its health from the last scoring, the folders its `## Where to look` names verbatim, - and a prompt for each thing that phase could become. + and a prompt for each thing that task could become. Keyed by project because that is the axis the human checks in along — - they ask about a project, never about a phase id. The same tree is what + they ask about a project, never about a task id. The same tree is what `census --by-project` prints (no pull line, nothing scored) and what the board renders with copy buttons; this is the text rendering of it. """ L = [f"### {key}", "", "- " + project_paths_line(row)] if pull_line: L.append(f"- {pull_line}") - L.append(f"- phases: {project_counts(key, c)}") + L.append(f"- tasks: {project_counts(key, c)}") groups = project_groups(key, c) if not groups: - L += ["", "_Nothing open — every phase of this project is history._"] - for title, phase_rows in groups: + L += ["", "_Nothing open — every task of this project is history._"] + for title, task_rows in groups: L += ["", f"**{title}**", ""] - for r in phase_rows: + for r in task_rows: s = scored_by_rel.get(r["rel"]) - head = f"- `{r['rel']}` — {r['title']} — {r['state']}" + head = f"- `{r['rel']}` — {r['summary']} — {r['state']}" if s: head += f" — {s['health']}" if r["failed_runs"]: @@ -2524,7 +2456,7 @@ def project_digest(key: str, row: dict, c: dict, scored_by_rel: dict, L.append(f" - `.{kind}`: `{path}`") for bullet in r["where"]: L.append(f" - where to look: {bullet}") - for label, payload in phase_chips(r, c): + for label, payload in task_chips(r, c): L.append(f" - **{label}**") L += _payload_block(payload) L += [""] @@ -2537,7 +2469,7 @@ def checkin_summary(c: dict, keys: list, scored: list, pulls: dict, 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(keys)} project(s) swept · {len(scored)} live task(s) scored · " f"{len(c['awaiting'])} awaiting a ruling · {len(c['ready'])} ready", ""] for key in keys: @@ -2559,18 +2491,18 @@ def cmd_checkin(root: Path, mod, a) -> int: "— 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] + tasks_all = mod.load_tasks(root)[0] + keys, notes = checkin_keys(projects, tasks_all, a.project) + live = [tk for tk in tasks_all if tk.state in LIVE_STATES + and (tk.get("Project") or tk.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) + for tk in live: + live_by_key.setdefault(tk.get("Project") or tk.project_dir, + []).append(tk.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 " + f"{len(live)} live task(s). Nothing is pulled, nothing is " "written, no cluster is reached.") for key in keys: row = projects.get(key, {}) @@ -2579,7 +2511,7 @@ def cmd_checkin(root: Path, mod, a) -> int: 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)")) + else "(no submitted | running task)")) for note in notes: print(f"\nnote: {note}") ok, why = push_preflight(root) @@ -2588,7 +2520,7 @@ def cmd_checkin(root: Path, mod, a) -> int: return RC_OK # --- the push question is asked first: "clean on main" stops being true - # the moment the phases move. + # the moment the tasks move. if a.push is False: push_ok, push_why = False, "--no-push" else: @@ -2628,7 +2560,7 @@ def cmd_checkin(root: Path, mod, a) -> int: return RC_USAGE # --- 2. score + move ------------------------------------------------- - scored = [score_phase(mod, ph, projects) for ph in live] + scored = [score_task(mod, tk, projects) for tk in live] problems, applied, wrote = _apply_checked(root, mod, scored) notes += applied if problems: @@ -2642,13 +2574,13 @@ def cmd_checkin(root: Path, mod, a) -> int: # --- 3. stamp + render ----------------------------------------------- # The stamp is persisted BEFORE the render, so the board this check-in # writes shows the check-in that produced it — and so `push_ledger` picks - # `checkin.yaml` up in the same commit as the phase moves. + # `checkin.yaml` up in the same commit as the task moves. write_checkin(root, stamp) 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), " + print(f"Wrote: {' + '.join(pages)} ({len(c['tasks'])} task(s), " f"{len(c['rulings'])} ruling(s))") # --- 4. push --------------------------------------------------------- @@ -2690,8 +2622,9 @@ def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser( prog="cortex", description="The Cortex Agent — reason over PyAutoCortex: the board, " - "the gates, the check-in. It never submits and never " - "rules.") + "the gates, the check-in. Its verbs never submit and " + "never rule; a run is submitted only on the human's " + "ask.") sub = ap.add_subparsers(dest="verb") def common(p): @@ -2704,7 +2637,7 @@ def common(p): c.add_argument("--json", dest="as_json", action="store_true") c.add_argument("--by-project", dest="by_project", action="store_true", help="the check-in tree instead of the counts: every " - "project's folders, its open phases, the locations " + "project's folders, its open tasks, the locations " "they name and the prompt for each") d = common(sub.add_parser("dashboard", help="render dashboard.md/.html")) @@ -2712,13 +2645,13 @@ def common(p): help="exit 1 if the committed pages are stale") d.add_argument("--apply", action="store_true", help="write both pages") - common(sub.add_parser("gates", help="every gated phase and its refs")) + common(sub.add_parser("gates", help="every gated task and its refs")) k = common(sub.add_parser( "collect", help="score what came back; default scope is every " - "submitted | running phase")) - k.add_argument("--phase", action="append", default=[], metavar="REL", - help="score these phases instead of every submitted | " + "submitted | running task")) + k.add_argument("--task", action="append", default=[], metavar="REL", + help="score these tasks instead of every submitted | " "running one (repeatable)") k.add_argument("--pull", action="store_true", help="run each project's own ` pull` first, then " @@ -2727,14 +2660,14 @@ def common(p): help="stamp the refresh at this time — for a pull you ran " "by hand") k.add_argument("--apply", action="store_true", - help="move the scored phases to awaiting-ruling and write " + help="move the scored tasks to awaiting-ruling and write " "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 " + "every live task, 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, " @@ -2778,14 +2711,14 @@ def main(argv=None) -> int: if verb == "gates": # A thin wrapper over the Cortex script's own read-only listing: a - # gated phase is moved on by a human typing `move ready`. + # gated task is moved on by a human typing `move ready`. lines, rc = mod.gates_report(root) 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 + # task by task 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) @@ -2797,7 +2730,7 @@ def main(argv=None) -> int: return RC_UNREADABLE if verb == "collect": - # Scoring reads the tree phase by phase rather than through `census`: + # Scoring reads the tree task by task rather than through `census`: # a collect must work on a tree that does not fully check, because a # tree that does not check is exactly when the human needs the packet. try: @@ -2820,7 +2753,7 @@ def main(argv=None) -> int: if verb == "census": if a.as_json: - print(json.dumps({k: v for k, v in c.items() if k != "phases"}, + print(json.dumps({k: v for k, v in c.items() if k != "tasks"}, indent=2)) elif a.by_project: emit_by_project(c) @@ -2848,7 +2781,7 @@ def main(argv=None) -> int: for name, want in pages.items(): (root / name).write_text(want, encoding="utf-8") print(f"Wrote: {' + '.join(pages)} " - f"({len(c['phases'])} phase(s), {len(c['rulings'])} ruling(s))") + f"({len(c['tasks'])} task(s), {len(c['rulings'])} ruling(s))") return RC_OK print(pages["dashboard.md"], end="") return RC_OK diff --git a/agents/conductors/cortex/cortex.sh b/agents/conductors/cortex/cortex.sh index f023a162..944e71ad 100755 --- a/agents/conductors/cortex/cortex.sh +++ b/agents/conductors/cortex/cortex.sh @@ -4,18 +4,18 @@ # what is true. # # Reasons over PyAutoCortex, the organ that holds the science body map, the -# pre-registered phases and the rulings of record: renders the Cortex board +# pre-registered tasks and the rulings of record: renders the Cortex board # (dashboard.md + dashboard.html, published to Pages), lists what each gated -# phase is waiting on, and scores what a pull brought back. It never submits a -# run and never writes a ruling — the run is the human's act and the verdict -# is theirs. +# task is waiting on, and scores what a pull brought back. Its verbs never +# submit a run and never write a ruling — a run is submitted only on the +# human's ask, and the verdict is theirs. # # Usage: # cortex.sh # census (default) # cortex.sh census --json # machine-readable # cortex.sh dashboard --check # exit 1 if the pages are stale # cortex.sh dashboard --apply # write dashboard.md + dashboard.html -# cortex.sh gates # every gated phase and its refs +# cortex.sh gates # every gated task and its refs # cortex.sh collect --pull --apply # the check-in: pull, score, move on # cortex.sh --cortex # point at another PyAutoCortex checkout diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 624bedff..d977f4d5 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -1848,14 +1848,14 @@ def _batch_status(mind: Path) -> dict | None: # --------------------------------------------------------- the Cortex badge --- -# A development task that a science phase is gated on has a second reader +# A development task that a science task is gated on has a second reader # waiting on it, and nothing on this page said so: the Cortex's own board knows # which issues it is waiting for, but the person choosing what to work on reads -# the Mind's. So the in-flight rows carry a badge naming the phase they gate. +# the Mind's. So the in-flight rows carry a badge naming the task they gate. # # Render-only, and deliberately cheap: no new import (`_intake` already # hard-fails without a Mind checkout; it must not also require a Cortex one), -# no schema of the Cortex's beyond two lines of its phase header, and an absent +# no schema of the Cortex's beyond two lines of its task header, and an absent # or unreadable Cortex is silence rather than an error. CORTEX_REPO = "PyAutoCortex" CORTEX_HEADER_LINES = 30 # PyAutoCortex/scripts/cortex.py HEADER_LINES @@ -1878,7 +1878,7 @@ def _cortex_root(mind: Path): env = os.environ.get("PYAUTO_CORTEX", "").strip() for candidate in ([Path(env).expanduser()] if env else []) + [mind.resolve().parent / CORTEX_REPO]: - if (candidate / "phases").is_dir(): + if (candidate / "tasks").is_dir(): return candidate return None @@ -1889,7 +1889,7 @@ def _issue_url(url: str) -> str: def cortex_gates(mind: Path) -> dict: - """`{issue url: [phase rel, …]}` — every Cortex phase gated on an issue. + """`{issue url: [task rel, …]}` — every Cortex task gated on an issue. The short `Repo#N` form takes its owner from the Mind's own `repos.yaml` (through `_mind_home`), never from a literal: a fork's Mind and its Cortex @@ -1902,7 +1902,7 @@ def cortex_gates(mind: Path) -> dict: owner = home.split("/")[3] if home.count("/") >= 4 else "" out: dict = {} try: - files = sorted((root / "phases").rglob("*.md")) + files = sorted((root / "tasks").rglob("*.md")) except OSError: return {} for f in files: @@ -1934,8 +1934,8 @@ def cortex_gates(mind: Path) -> dict: return out -def _gated_phases(c: dict, row: dict) -> list: - """The Cortex phases this in-flight row gates, in file order.""" +def _gated_tasks(c: dict, row: dict) -> list: + """The Cortex tasks this in-flight row gates, in file order.""" issue = _issue_url(row.get("issue", "")) return (c.get("cortex_gates") or {}).get(issue, []) if issue else [] @@ -2356,8 +2356,8 @@ def render_dashboard(c: dict) -> str: if r["status"]: head += f" — {_summary_label(_clip(r['status']))}" head += _pr_column(r) - for rel in _gated_phases(c, r): - head += f" — ⚠️ gates a Cortex phase → {rel}" + for rel in _gated_tasks(c, r): + head += f" — ⚠️ gates a Cortex task → {rel}" flight.append(_task_row(head, f"/start_dev {r['path']}")) L += _items(flight) or ["- _(nothing in flight)_"] L += [""] @@ -2718,9 +2718,9 @@ def h2(title, src): text += (f' — ' f'{_summary_label(_clip(r["status"]))}') text += _pr_column(r) - gated = _gated_phases(c, r) + gated = _gated_tasks(c, r) if gated: - text += pills(*[(f"gates a Cortex phase → {rel}", "y") + text += pills(*[(f"gates a Cortex task → {rel}", "y") for rel in gated]) H.append(_html_task(text, f"/start_dev {r['path']}")) if not c["in_flight"]: diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 668e218c..5ff353f1 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -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: 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" + [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; its door never submits a run and never writes a ruling; the agent submits only on the human's ask, and the ruling is the human's word" [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/board/_board.py b/board/_board.py index 5b11d6be..e4b14d63 100755 --- a/board/_board.py +++ b/board/_board.py @@ -1508,7 +1508,7 @@ def community_row(e, note, tone): if data.get("cortex"): cortex_url = data["boards"].get("cortex", "") H.append(_plain( - f'Science phases on the Cortex ' + f'Science tasks on the Cortex ' "board \u2197" + pills(*[(f"{k.lower()} {n}", "" if i == 0 else "n") for i, (k, n) in enumerate(data["cortex"].items())]))) diff --git a/board/_theme.py b/board/_theme.py index 866091f6..e40d282a 100644 --- a/board/_theme.py +++ b/board/_theme.py @@ -332,12 +332,16 @@ def mark(key): :root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e; --line:#d8dee4;--btn:#f6f8fa;--ok:#1a7f37;--warn:#9a6700;--bad:#d1242f; --accent:%(ink_light)s;--tint:%(ink_light)s14;--edge:%(ink_light)s3d; + /* the ink that READS on a solid --accent fill: the accent is a dark ink on + the light scheme, so white sits on it; on the dark scheme the accent is + the bright end of the pair and the page's own ground is what reads. */ + --accent-ink:#fff; --hero-lift:%(hero_lift)s;--hero-base:%(hero_base)s;--glow:%(glow)s; --glow2:%(glow2)s} @media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#f0f6fc; --muted:#9198a1;--line:#2c333c;--btn:#151b23;--ok:#3fb950;--warn:#d29922; --bad:#f85149;--accent:%(ink_dark)s;--tint:%(ink_dark)s1f; - --edge:%(ink_dark)s47}} + --edge:%(ink_dark)s47;--accent-ink:#0d1117}} *{box-sizing:border-box} /* Wrapping is the page DEFAULT, not a per-component opt-in. These boards are read on phones, and every one of them prints run URLs, dotted test ids and diff --git a/docs/organs/cortex.md b/docs/organs/cortex.md index 857a9ad7..ae14c205 100644 --- a/docs/organs/cortex.md +++ b/docs/organs/cortex.md @@ -13,20 +13,23 @@ routes the work and executes nothing, the Cortex learns what is true.* ## The defining function: learning -A science phase begins as a **pre-registered question with a witness** — the +A science task begins as a **pre-registered question with a witness** — the file or number that will settle it — before anything is submitted. The run happens on RAL or the laptop; its evidence (`.err`, checkpoints, witness JSONs, figures) is **pulled** to the laptop; the human inspects it and records a **ruling**: *accept*, *rerun* or *drop*. That ruling is the organism learning something. -The unit is a **phase** of a **project** — one markdown file under -`phases//` that spawns **runs** (SLURM job ids) and ends in a ruling. -Development work a phase waits on stays in the Mind, named here only as a gate. +The unit is a **task** of a **project** — one markdown file under +`tasks//` that spawns **runs** (SLURM job ids) and ends in a ruling. +A task carries a ten-word `Summary:` — the question it answers — and its slug +is its identity; there is no number, because the order tasks are run in is the +human's, decided by what the results say (2026-09-07). Development work a task +waits on stays in the Mind, named here only as a gate. ## The state model -A phase moves through ten states, and the sentence is the lifecycle: +A task moves through ten states, and the sentence is the lifecycle: **planned** until its gates are written, **gated** while they are open, **ready** once they clear, **submitted** when the human launches the run, **running** while it is on the cluster, **pulled** when the evidence reaches @@ -36,14 +39,14 @@ model worth having: - **`scripts/cortex.py move` owns every edge except the last three.** `accepted`, `rerun` and `dropped` are reachable **only** through - `scripts/cortex.py rule`, which writes the ruling file and the phase's - `Ruling:` in one act — a phase cannot be marked accepted with no ruling of + `scripts/cortex.py rule`, which writes the ruling file and the task's + `Ruling:` in one act — a task cannot be marked accepted with no ruling of record to point at. - **`accepted` is not terminal; `dropped` is.** A later ruling may supersede an acceptance (that is what a rewind is), and a `rerun` returns to `ready` - keeping its run history. Reviving a dropped phase means a new phase number. + keeping its run history. Reviving a dropped task means a new slug. -`legacy` and `legacy_wrong` are states of a **run**, never of a phase — the +`legacy` and `legacy_wrong` are states of a **run**, never of a task — the quarantine for results produced before a rule the project has since adopted. `scripts/cortex.py check` enforces the table and its invariants: a witness before `submitted`, a cleared or overridden gate before `ready`, a ruling @@ -60,18 +63,18 @@ deletion under `rulings/` as code, which is a human's turn. ## Gates -A science phase declares what it waits on as GitHub issue and PR references in +A science task declares what it waits on as GitHub issue and PR references in its `Gates:` header — `Repo#N` under the default owner, or a full issue/PR URL — the same grammar the Mind's `Blocked-by:` uses, sharing its regular expression verbatim. A **daily grading job** (`gates_grade.yml`, 06:47 UTC) -polls those refs and flips a phase `gated → ready` when every one has closed, +polls those refs and flips a task `gated → ready` when every one has closed, stamping `Gates-cleared:`; a cleared gate that reopens flips it back. It is the one scheduled job in the repo that mutates the ledger, it may make no other -move, and an unreadable ref fails closed — the phase is skipped and the run +move, and an unreadable ref fails closed — the task is skipped and the run goes red so a human sees the ref. The Mind learns nothing about the Cortex beyond a **render-time badge** — -"gates a Cortex phase" on the development issue in question. One grammar, one +"gates a Cortex task" on the development issue in question. One grammar, one direction. ## What it never does @@ -82,7 +85,7 @@ direction. - **It never holds data.** The science project trees, their outputs, mirrors and checkpoints stay where they are; `projects.yaml` points at them. What is committed here is the question, the run ids, the verdict. -- **It never runs under an autonomy level.** Every phase is `Lane: local-dev`; +- **It never runs under an autonomy level.** Every task is `Lane: local-dev`; the review happens at the laptop, on evidence in the human's hands. The autonomy cap is not consulted when a science member is admitted to a slot: a science run is supervised by definition, and no autonomous ship gate can @@ -99,14 +102,14 @@ 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, the last check-in stamp | +| `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 task, 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 task's state already has. `--dry-run` (the default) says what it would do and reaches nothing | +| `census` | What the Cortex is holding — tasks by state, rulings, projects, the last check-in stamp | | `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 | +| `gates` | The refs each gated task 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] [--task REL]` | **The check-in.** What came back, one block per task: 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` task; `--pull` runs each project's own sync CLI; `--apply` moves each task `running → pulled → awaiting-ruling`. Exit **1** = a task the human must look at | **Checking in is one command.** `pyauto-brain cortex checkin --apply` pulls -every active project through its own sync CLI, scores every phase the Cortex +every active project through its own sync CLI, scores every task 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 @@ -125,10 +128,12 @@ cite its words. ![The Cortex board](../_static/cortex_board.png) -Every phase the Cortex is holding on one page — the check-in paste and the +Every task the Cortex is holding on one page — the check-in paste and the last-check-in stamp, a one-row-per-project summary, the project map (active, -then planned, then dormant, each card routing to its next phase), then -awaiting ruling, running, ready, gated and the ruling ledger — published at +then planned, then dormant, each card carrying **every open task of that +project, one line each** — its ten-word summary, a state pill coloured by +what it is doing, and only the facts that state earns), then awaiting ruling +and the ruling ledger — published at . It hands out the next command; the verdict is never on the page. @@ -136,7 +141,7 @@ the verdict is never on the page. Like Mind, Memory and Gut, the Cortex is an **instance organ** — inherently yours. You do not fork this repo's contents; you create your own Cortex with -the same shape — a body map of your projects, a phase file per question, a +the same shape — a body map of your projects, a task file per question, a place to write what you decided — and hold your own rulings in it. The birth of this organ is tracked in the diff --git a/skills/COMMANDS.md b/skills/COMMANDS.md index 1ee83934..a6bc4328 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 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 | +| `/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); a run is submitted only on the human's ask; the ruling stays 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 ebc660f7..df1cac4c 100644 --- a/skills/cortex/SKILL.md +++ b/skills/cortex/SKILL.md @@ -1,11 +1,13 @@ --- name: cortex -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. +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 task needs next. Use for science runs, tasks, gates and rulings; never for development tasks, which are the Mind's. --- # Cortex 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. +individual verbs are its appendix. The conductor reasons; its check-in door +never submits a run and never writes a ruling. A run is submitted only when the +human asks for it in the session — then the agent runs the project's own sync +CLI and records the job id with `cortex.py move`. The verdict stays the +human's word. diff --git a/skills/cortex/cortex.md b/skills/cortex/cortex.md index b33a62f8..405da57f 100644 --- a/skills/cortex/cortex.md +++ b/skills/cortex/cortex.md @@ -2,7 +2,7 @@ **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 +CLI, scores every live task 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. @@ -21,10 +21,10 @@ 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 +every `submitted | running` task 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). +and it owns no live task). ### 2. Check in @@ -42,8 +42,8 @@ It runs, in order: 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. +2. **Score** — every `submitted | running` task, six legs each, against the + task'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** — the refresh stamp into `checkin.yaml` (the board's "Last @@ -55,22 +55,22 @@ It runs, in order: ### 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 +`local_path` / `mirror` / RAL root, what its pull did, its task counts, and +then every task 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 `). +- **ready** → the launch lines (the task, the `submit`, the `move … submitted + --run `). Run them yourself when the human says go. - **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. +Never decide a ruling, never submit a job the human did not ask for, never edit +a task file by hand: every task 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 @@ -93,17 +93,22 @@ 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 records the job id with `cortex.py move submitted --run `. +- **A run is the human's call, not the human's keystroke.** The check-in door + never submits. When the human asks for a run in the session, the agent + submits it with the project's own sync CLI (its `submit` / `push-submit` + verb) and records the job id at once with `cortex.py move submitted + --run `. Nothing is submitted unasked, and a run that has no task + gets one before the session ends. (Changed 2026-09-05 by the human's ruling; + before that the human typed the submit.) - **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. +- **A task is a question with a pre-registered witness.** If `Witness:` is + empty the task 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. + tasks 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 @@ -112,8 +117,8 @@ and nothing for the human to merge. **Never `main` directly, never `--force`.** - **Retiring ends a project, it does not erase one.** `cortex.py retire` changes a `projects.yaml` row's `status:` and `note:` and nothing else: the row stays (it is the only record of where that project's data - lives), its phases stay, its rulings stay. It refuses while the project - holds a phase outside `accepted | rerun | dropped | planned` — rule or + lives), its tasks stay, its rulings stay. It refuses while the project + holds a task outside `accepted | rerun | dropped | planned` — rule or drop the live questions first. ## Appendix — the verbs it composes @@ -125,9 +130,9 @@ 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, the last check-in stamp | +| `census [--json]` | What is the Cortex holding? Task counts by state, rulings, projects, the last check-in stamp | | `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` | -| `retire --why "…"` (`scripts/cortex.py`) | Is this project over? Flips the row to `status: retired` and stamps `note: retired : `. The 📋 chip on the board's **Nothing open** table is this command. Exit **1** = an unknown key, an already-retired row, or a phase still live | -| `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 | +| `gates` | What is each gated task waiting on? Read-only and offline; a human types `move ready` | +| `retire --why "…"` (`scripts/cortex.py`) | Is this project over? Flips the row to `status: retired` and stamps `note: retired : `. The 📋 chip on the board's **Nothing open** table is this command. Exit **1** = an unknown key, an already-retired row, or a task still live | +| `collect [--task REL] [--pull] [--refreshed ISO] [--apply] [--out F]` | The scorer `checkin` composes: one block per task — six legs each `PASS`/`FAIL`/`UNOBSERVABLE`, the readout, a **blank** ruling line. Exit **1** = a task the human must look at | diff --git a/tests/_cortex_board.py b/tests/_cortex_board.py index 2e4b890e..8b19247d 100644 --- a/tests/_cortex_board.py +++ b/tests/_cortex_board.py @@ -47,12 +47,12 @@ def _zip_summary(path: Path, hours: str) -> Path: return path -def _phase(project, number, slug, budget, runs, where, witness_prose, - minutes=5, state="running"): +def _task(project, slug, summary, budget, runs, where, witness_prose, + minutes=5, state="running"): stems = ", ".join(r.split(":")[0].split("_")[0] for r in runs) body = "\n".join(f"- {r}" for r in runs) - return (f"# {project.title()} — phase {number}: {slug}\n\n" - f"Project: {project}\nPhase: {number}\nState: {state}\n" + return (f"# {project.title()} — {slug}\n\n" + f"Project: {project}\nSummary: {summary}\nState: {state}\n" f"Witness: {witness_prose}\nBudget: {budget}\n" f"Runs: {stems}\nReview-minutes: {minutes}\n" f"Filed: 2026-08-30\n\n" @@ -91,7 +91,7 @@ def _phase(project, number, slug, budget, runs, where, witness_prose, def build_board(tmp_path: Path, skeleton: Path) -> dict: - """A tmp Cortex with three live phases, and the two laptop trees they + """A tmp Cortex with three live tasks, and the two laptop trees they were pulled into. The fixture's `projects.yaml` points at fictional absolute paths (it is the @@ -121,8 +121,9 @@ def build_board(tmp_path: Path, skeleton: Path) -> dict: {"pulled_at": "2026-08-31T10:00Z", "runs": {"400100": {"checkpoint_bytes": 40960, "checkpoint_mtime": "2026-08-30T09:51Z"}}})) - _write(root / "phases/example/11_healthy.md", _phase( - "example", 11, "the bright lens", "6:00", + _write(root / "tasks/example/11_healthy.md", _task( + "example", "the bright lens", + "Does the bright lens converge on the mirrored pull", "6:00", ["400100: done — gpu — submitted 2026-08-30 — wall 0:51"], mirror / "output/searches/bright", "a result JSON carrying the installed stack's version stamp")) @@ -137,8 +138,9 @@ def build_board(tmp_path: Path, skeleton: Path) -> dict: (run2 / ".completed").write_text("", encoding="utf-8") _write(mirror / "results/searches/faint/bbbb2222.json", json.dumps({"version": "2026.8.17.1", "log_likelihood": 1200.0})) - _write(root / "phases/example/12_resumed.md", _phase( - "example", 12, "the faint lens", "6:00", + _write(root / "tasks/example/12_resumed.md", _task( + "example", "the faint lens", + "Does the faint lens converge on the mirrored pull", "6:00", ["400200: done — gpu — submitted 2026-08-30 — wall 0:44"], mirror / "output/searches/faint", "a result JSON carrying the installed stack's version stamp")) @@ -153,8 +155,9 @@ def build_board(tmp_path: Path, skeleton: Path) -> dict: _zip_summary(sub / "output/lens_a/cccc3333.zip", "0:51:28.387009") _write(sub / "results/pipeline/lens_a.json", json.dumps({"log_likelihood": 999.0, "n_live": 200})) - _write(root / "phases/subhalo/01_partial.md", _phase( - "subhalo", 1, "the partial extraction", "1:00", + _write(root / "tasks/subhalo/01_partial.md", _task( + "subhalo", "the partial extraction", + "Does the pipeline JSON land for the partial extraction", "1:00", ["400300_0: done — ral — submitted 2026-08-30 — wall 0:51"], sub / "output/lens_a", "the pipeline JSON lands for the lens")) diff --git a/tests/test_cortex_conductor.py b/tests/test_cortex_conductor.py index f7acfd42..1a6448ba 100644 --- a/tests/test_cortex_conductor.py +++ b/tests/test_cortex_conductor.py @@ -1,7 +1,7 @@ """Contract tests for the Cortex conductor — the Brain's science door. These run against the **real** Cortex checkout's `tests/fixtures/skeleton`, -not a copy: that fixture is the phase-1 witness (one project, one phase per +not a copy: that fixture is the phase-1 witness (one project, one task per state, five rulings including a superseded chain, one batch record), and a copy here would drift from the schema it claims to exercise. When no Cortex is checked out (a laptop that never cloned it) every test skips cleanly — @@ -71,10 +71,11 @@ def _run(args, cwd=None): # --- the census ------------------------------------------------------------ -def test_the_census_sorts_every_fixture_state_into_its_section(skeleton): +def test_the_census_sorts_every_fixture_state_into_its_bucket(skeleton): + """The buckets outlived their sections: `live`, `ready` and `gated` are + still what `collect` and the check-in door scope by, they are simply no + longer headings on the page.""" c = _cortex.census(skeleton) - # The fixture holds one phase per state; the board shows the four that - # are live work and leaves planned/accepted/rerun/dropped to the ledger. assert {r["state"] for r in c["awaiting"]} == {"pulled", "awaiting-ruling"} assert {r["state"] for r in c["live"]} == {"submitted", "running"} assert [r["state"] for r in c["ready"]] == ["ready"] @@ -92,13 +93,13 @@ def test_awaiting_orders_failures_before_the_clean_ones(skeleton): def test_a_superseded_ruling_is_not_a_standing_verdict(skeleton): c = _cortex.census(skeleton) by_id = {r["id"]: r for r in c["rulings"]} - # The fixture's R-…-02 supersedes R-…-01 over the same phase. + # The fixture's R-…-02 supersedes R-…-01 over the same task. assert by_id["R-20260901-01"]["head"] is False assert by_id["R-20260901-02"]["head"] is True assert [r["id"] for r in c["rulings"]] == sorted(by_id, reverse=True) -def test_a_running_phase_reads_its_wall_against_its_budget(skeleton): +def test_a_running_task_reads_its_wall_against_its_budget(skeleton): c = _cortex.census(skeleton) running = next(r for r in c["live"] if r["state"] == "running") assert "of 8:00" in _cortex._live_note(running) @@ -113,27 +114,114 @@ def test_every_section_renders_in_order_with_its_source_link(skeleton): assert page.count("[markdown version]") == len(titles) -def test_every_live_phase_of_the_fixture_appears_in_its_section(skeleton): +def test_every_open_task_of_the_fixture_appears_on_the_page(skeleton): + """Not "the four live sections" any more — every open task of every + project, on its project's card, once.""" c = _cortex.census(skeleton) page = _cortex.render_dashboard(c) html = _cortex.render_dashboard_html(c) - for key in ("awaiting", "live", "ready", "gated"): - for r in c[key]: - assert r["rel"] in page, (key, r["rel"]) - assert r["rel"] in html, (key, r["rel"]) + for r in c["tasks"]: + if r["state"] in _cortex.CLOSED_STATES: + continue + assert r["rel"] in page, r["rel"] + assert r["rel"] in html, r["rel"] + # …by its ten-word summary, which is what makes the line readable. + assert r["summary"] in page, r["rel"] # …and the gated row shows what it is actually waiting on. for ref in c["gated"][0]["gates"]: assert ref in page and ref in html +def test_every_open_task_is_one_row_and_the_fold_is_gone(skeleton): + """The complaint that started this: "4 more open phase(s)" hid the work + behind a click. There is no fold on the Projects block on either twin, + and the number of task rows is the number of open tasks.""" + c = _cortex.census(skeleton) + md = _cortex.render_dashboard(c) + html = _cortex.render_dashboard_html(c) + for twin in (md, html): + assert "more open" not in twin + assert "plans and issues" not in twin + assert not hasattr(_cortex, "_fold_label") + projects_html = html.split("

Projects")[1].split("

Awaiting")[0] + assert "{r["summary"]}' in projects_html, r["rel"] + + +def test_the_state_pill_is_toned_and_a_failed_run_reddens_it(skeleton): + """Green is "nothing owed", yellow is "moving or waiting on you", grey is + "waiting on someone else" — and a failed run overrides all three.""" + assert _cortex.STATE_TONES == { + "planned": "g", "ready": "g", "accepted": "g", + "gated": "n", + "submitted": "y", "running": "y", "pulled": "y", + "awaiting-ruling": "y", "rerun": "y"} + c = _cortex.census(skeleton) + clean = {r["state"]: _cortex.state_tone(r) for r in c["tasks"] + if not r["failed_runs"]} + for state, tone in clean.items(): + assert tone == _cortex.STATE_TONES.get(state, "n"), state + assert clean["planned"] == "g" and clean["ready"] == "g" + assert clean["gated"] == "n" and clean["pulled"] == "y" + failed = next(r for r in c["tasks"] if r["failed_runs"]) + assert _cortex.STATE_TONES[failed["state"]] != "r", "the override matters" + assert _cortex.state_tone(failed) == "r" + html = _cortex.render_dashboard_html(c) + assert f'{failed["state"]}' in html + assert 'planned' in html + assert 'gated' in html + assert 'pulled' in html + + +def test_the_three_state_sections_are_gone_from_both_twins(skeleton): + """The Projects block carries what they carried, per project and in + colour, so they said the same thing three more times.""" + c = _cortex.census(skeleton) + assert [key for key, _t, _s, _b in _cortex.SECTIONS] == \ + ["projects", "awaiting", "rulings"] + for twin in (_cortex.render_dashboard(c), + _cortex.render_dashboard_html(c)): + for heading in ("Running / submitted", ">Ready<", "## Ready", + ">Gated<", "## Gated"): + assert heading not in twin, heading + assert not hasattr(_cortex, "ready_groups") + + +def test_the_paths_render_as_accent_chips_on_the_html_twin(skeleton): + """Pink-on-tint `code` was unreadable against the Cortex accent; the + chip fills with the accent and carries white ink in both schemes.""" + c = _cortex.census(skeleton) + html = _cortex.render_dashboard_html(c) + row = c["projects"]["example"] + assert (f'

Local ' + f'{row["local_path"]}

') in html + assert ".pathchip{" in html + # White ink in BOTH schemes (the human's ask); the dark scheme darkens + # the fill instead of swapping the ink. + assert "background:var(--accent);color:#fff" in html + assert ("@media(prefers-color-scheme:dark){.pathchip{" + "background:color-mix(in srgb,var(--accent) 55%,#000)}}") in html + sys.path.insert(0, str(BRAIN_HOME / "board")) + import _theme + sheet = _theme.css("cortex") + assert sheet.count("--accent-ink:") == 2, "light and dark both define it" + # the markdown twin is unchanged — no HTML span in a bold path line + assert "**Local** `" in _cortex.render_dashboard(c) + + def test_the_counts_table_is_the_one_the_brain_board_reads(skeleton): import re page = _cortex.render_dashboard(_cortex.census(skeleton)) # board/_board.py's regex, verbatim. found = dict(re.findall( r"^\|\s*\[([^\]]+)\]\([^)]*\)[^|]*\|\s*(\d+)\s*\|", page, re.M)) - assert found == {"Awaiting ruling": "2", "Running / submitted": "2", - "Ready": "1", "Gated": "1", "Recent rulings": "5"} + assert found == {"Awaiting ruling": "2", "Recent rulings": "5"} def test_the_pages_wear_the_cortex_and_nothing_of_the_mind(skeleton): @@ -168,7 +256,7 @@ def test_the_check_compare_ignores_the_date_but_not_the_content(skeleton): assert (_cortex.dashboard_body(today[name]) == _cortex.dashboard_body(tomorrow[name])), name # A real content change is still drift. - changed = dict(c, ready=[]) + changed = dict(c, awaiting=[]) assert (_cortex.dashboard_body(_cortex.render_pages(changed)["dashboard.md"]) != _cortex.dashboard_body(today["dashboard.md"])) @@ -192,9 +280,9 @@ def test_apply_then_check_is_clean_and_check_alone_is_drift(tmp_skeleton): def test_a_stale_page_by_one_row_is_drift(tmp_skeleton): _run(["dashboard", "--apply", "--cortex", str(tmp_skeleton)]) page = tmp_skeleton / "dashboard.md" - page.write_text(page.read_text().replace("[Ready](#ready) | 1 |", - "[Ready](#ready) | 9 |"), - encoding="utf-8") + page.write_text(page.read_text().replace( + "[Awaiting ruling](#awaiting-ruling) | 2 |", + "[Awaiting ruling](#awaiting-ruling) | 9 |"), encoding="utf-8") assert _run(["dashboard", "--check", "--cortex", str(tmp_skeleton)]).returncode == _cortex.RC_DRIFT @@ -202,10 +290,10 @@ def test_a_stale_page_by_one_row_is_drift(tmp_skeleton): # --- gates ----------------------------------------------------------------- def test_the_gates_verb_is_a_read_only_wrapper_over_the_scripts_listing(tmp_skeleton): """Gate grading was retired 2026-09-03: the verb lists, exits 0, fetches - nothing and flips nothing. A gated phase moves on when a human types - `move ready`.""" + nothing and flips nothing. A gated task moves on when a human types + `move ready`.""" before = {p.rel: p.state for p in - _cortex.load_cortex(tmp_skeleton).load_phases(tmp_skeleton)[0]} + _cortex.load_cortex(tmp_skeleton).load_tasks(tmp_skeleton)[0]} r = _run(["gates", "--cortex", str(tmp_skeleton)]) assert r.returncode == 0, r.stderr gated = _cortex.census(tmp_skeleton)["gated"][0] @@ -217,7 +305,7 @@ def test_the_gates_verb_is_a_read_only_wrapper_over_the_scripts_listing(tmp_skel # what this asserts is that the wrapper fetches nothing and writes nothing. assert "--grade" not in r.stdout after = {p.rel: p.state for p in - _cortex.load_cortex(tmp_skeleton).load_phases(tmp_skeleton)[0]} + _cortex.load_cortex(tmp_skeleton).load_tasks(tmp_skeleton)[0]} assert after == before @@ -238,7 +326,7 @@ def test_the_cortex_holds_no_epics_of_its_own(tmp_skeleton): # --- the check-in chip and the freshness stamp ----------------------------- -def _first_task(page: str) -> str: +def _first_row(page: str) -> str: """The first copy row on either twin — markdown ``, HTML button.""" m = re.search(r"📋 ([^<\n]+)", page) or \ re.search(r'

([^<]+)', page) @@ -250,7 +338,7 @@ def test_the_checkin_chip_is_the_first_task_row_on_both_twins(skeleton): refreshes everything below it — before any section.""" c = _cortex.census(skeleton) page = _cortex.render_dashboard(c) - assert _first_task(page) == _cortex.CHECKIN_LABEL + assert _first_row(page) == _cortex.CHECKIN_LABEL assert page.index("📋 " + _cortex.CHECKIN_LABEL) < page.index("## Summary") payload = _cortex._checkin_payload(c) assert payload.startswith("/cortex — check in on every active science") @@ -339,26 +427,26 @@ def test_the_map_is_a_map_not_a_paragraph(skeleton): # `tests/_cortex_board.py` (which says what real trees they imitate); the # builder lives there because `test_batch_kinds.py` raises the same tree. from _cortex_board import ( # noqa: E402 - tests/ is on sys.path - BENIGN_ERR, HEALTHY_OUT, PROJECTS, SUMMARY, _phase, _write, + BENIGN_ERR, HEALTHY_OUT, PROJECTS, SUMMARY, _task, _write, _zip_summary, build_board, ) -#: The three phases `build_board` adds on top of the skeleton, and the only -#: ones the scoring tests assert about. The skeleton's own live phases are in +#: The three tasks `build_board` adds on top of the skeleton, and the only +#: ones the scoring tests assert about. The skeleton's own live tasks are in #: `collect`'s default scope too (that IS the check-in), so a test about one -#: member names it with `--phase` rather than counting the whole scope. -BOARD = ("phases/example/11_healthy.md", "phases/example/12_resumed.md", - "phases/subhalo/01_partial.md") +#: member names it with `--task` rather than counting the whole scope. +BOARD = ("tasks/example/11_healthy.md", "tasks/example/12_resumed.md", + "tasks/subhalo/01_partial.md") def _collect(root, *args): return _run(["collect", "--cortex", str(root), - *[x for rel in BOARD for x in ("--phase", rel)], *args]) + *[x for rel in BOARD for x in ("--task", rel)], *args]) @pytest.fixture() def board(tmp_path, skeleton): - """A tmp Cortex with three live phases — built by + """A tmp Cortex with three live tasks — built by `tests/_cortex_board.py`.""" return build_board(tmp_path, skeleton) @@ -368,9 +456,9 @@ def _score(board_) -> dict: root = board_["root"] mod = _cortex.load_cortex(root) projects = mod.load_projects(root)[0] - return {ph.slug: _cortex.score_phase(mod, ph, projects) - for ph in mod.load_phases(root)[0] - if ph.state in _cortex.LIVE_STATES and ph.slug in + return {tk.slug: _cortex.score_task(mod, tk, projects) + for tk in mod.load_tasks(root)[0] + if tk.state in _cortex.LIVE_STATES and tk.slug in ("11_healthy", "12_resumed", "01_partial")} @@ -384,8 +472,7 @@ def test_the_projects_section_leads_the_state_sections(skeleton): page = _cortex.render_dashboard(_cortex.census(skeleton)) order = ["| Where | Count |", "📋 " + _cortex.CHECKIN_LABEL, "### Last check-in:", "## Summary", "## Projects", - "## Awaiting ruling", "## Running / submitted", "## Ready", - "## Gated", "## Recent rulings"] + "## Awaiting ruling", "## Recent rulings"] assert [page.index(x) for x in order] == sorted(page.index(x) for x in order) html = _cortex.render_dashboard_html(_cortex.census(skeleton)) assert html.index("

Summary

") < html.index("

Projects") \ @@ -402,13 +489,20 @@ def test_the_summary_table_holds_one_row_per_active_project(tmp_skeleton): rows = _cortex.summary_rows(c) assert [r["project"] for r in rows] == ["example"] row = rows[0] - # the next thing is the awaiting phase, and the ledger date is its own - assert row["next"] == "6 pulled" - assert (row["awaiting"], row["live"], row["ready"]) == (2, 2, 1) + # the next thing is the task owed a verdict, named by its own summary — + # a number and a state said nothing a reader could act on. + nxt = "Do the five faint lenses converge once resumed" + assert row["next"] == nxt + assert (row["awaiting"], row["live"]) == (2, 2) + assert "ready" not in row assert row["ruling"] == "2026-09-01" page = _cortex.render_dashboard(c) - assert "| example | 6 pulled | 2 | 2 | 1 | 2026-09-01 |" in page - assert "6 pulled" in _cortex.render_dashboard_html(c) + html = _cortex.render_dashboard_html(c) + assert f"| example | {nxt} | 2 | 2 | 2026-09-01 |" in page + assert "| Project | Next task | Awaiting | Running | Last ruling |" in page + assert f"{nxt}" in html + # the Ready column went with the Ready section + assert "Ready" not in html and "| Ready |" not in page def test_a_project_card_puts_each_folder_on_its_own_bold_line(skeleton): @@ -419,18 +513,18 @@ def test_a_project_card_puts_each_folder_on_its_own_bold_line(skeleton): ("RAL", row["ral_root"])): assert f"**{label}** `{path}`" in lines, label html = _cortex.render_dashboard_html(c) - assert (f'

RAL {row["ral_root"]}

' - in html) + assert (f'

RAL ' + f'{row["ral_root"]}

') in html # the counts and the partition, one line under them - assert "active · gpu partition · phases: accepted 1" in "\n".join(lines) + assert "active · gpu partition · tasks: accepted 1" in "\n".join(lines) def test_the_where_to_look_bullets_leave_the_page_but_not_the_door(skeleton): """They are the "which folder do I open" answer, and they belong where a - human is already reading a phase — not on a board they scroll past.""" + human is already reading a task — not on a board they scroll past.""" c = _cortex.census(skeleton) - where = "`/mnt/c/Users/Jammy/Science/example/output/phase_07/`" - awaiting = [r for r in c["phases"] + where = "`/mnt/c/Users/Jammy/Science/example/output/task_07/`" + awaiting = [r for r in c["tasks"] if r["rel"].endswith("07_awaiting_ruling.md")][0] assert awaiting["where"] == [where] for page in (_cortex.render_dashboard(c), _cortex.render_dashboard_html(c)): @@ -455,7 +549,7 @@ def test_census_by_project_prints_the_same_tree(skeleton): assert r.returncode == _cortex.RC_OK, r.stdout + r.stderr assert "# The Cortex by project" in r.stdout assert "### example" in r.stdout - assert "- where to look: `/mnt/c/Users/Jammy/Science/example/output/phase_07/`" in r.stdout + assert "- where to look: `/mnt/c/Users/Jammy/Science/example/output/task_07/`" in r.stdout assert "**rule on it**" in r.stdout # the counts census is still the default assert "== Cortex census ==" in _run(["census", "--cortex", str(skeleton)]).stdout @@ -593,67 +687,61 @@ def test_census_by_project_lists_retired_on_its_own_line(tmp_skeleton): assert "Retired: retired_one (none)" in r.stdout -def _bucketed(**buckets) -> dict: - """A census-shaped dict holding only the open buckets.""" - c = {k: [] for k in _cortex.OPEN_ORDER} - c.update(buckets) - return c - - -def test_the_next_phase_is_the_first_open_bucket_and_its_lowest_number(skeleton): - """awaiting > live > ready > gated > planned, then by phase number: the - route from a project card to the follow-up work.""" - def r(number, bucket): - return {"project": "p", "phase": number, "rel": f"phases/p/{number}.md", - "state": bucket} - c = _bucketed(ready=[r(3, "ready"), r(2, "ready")], - gated=[r(1, "gated")], planned=[r(9, "planned")]) - assert _cortex.next_open_phase("p", c)["phase"] == 2 - c = _bucketed(live=[r(7, "running")], ready=[r(2, "ready")]) - assert _cortex.next_open_phase("p", c)["phase"] == 7 - c = _bucketed(awaiting=[r(8, "pulled")], live=[r(7, "running")]) - assert _cortex.next_open_phase("p", c)["phase"] == 8 - assert _cortex.next_open_phase("p", _bucketed()) is None - # every open phase, in that same order — the fold lists the tail of it - c = _bucketed(awaiting=[r(8, "pulled")], ready=[r(2, "ready")]) - assert [x["phase"] for x in _cortex.open_phases("p", c)] == [8, 2] - - -def test_the_rest_of_a_projects_open_phases_fold_behind_links(tmp_skeleton): - """The plans live behind a click: a link per phase file, the phase folder - and — when the row declares a remote — that repo's issue list.""" +def _tasks(*rows) -> dict: + """A census-shaped dict holding only the task rows.""" + return {"tasks": list(rows)} + + +def _open(slug, state) -> dict: + return {"project": "p", "rel": f"tasks/p/{slug}.md", "slug": slug, + "summary": f"summary of {slug}", "state": state} + + +def test_the_open_tasks_of_a_project_read_in_the_order_they_owe_you(skeleton): + """awaiting-ruling > running > submitted > pulled > ready > gated > + planned. No number to sort by — science is unordered — so what a task + owes the human is the order, and `rel` breaks the tie inside a state.""" + c = _tasks(_open("e", "planned"), _open("d", "gated"), _open("c", "ready"), + _open("b", "running"), _open("a", "awaiting-ruling"), + _open("z", "accepted"), _open("y", "dropped")) + assert [r["slug"] for r in _cortex.open_tasks("p", c)] == \ + ["a", "b", "c", "d", "e"], "accepted and dropped are history" + assert _cortex.next_open_task("p", c)["slug"] == "a" + assert _cortex.next_open_task("p", _tasks()) is None + # inside one state, the file path is the tie-break + c = _tasks(_open("m", "ready"), _open("b", "ready")) + assert [r["slug"] for r in _cortex.open_tasks("p", c)] == ["b", "m"] + + +def test_every_open_task_of_a_project_is_a_row_with_its_own_links(tmp_skeleton): + """No fold and no queue: all seven open tasks of the fixture project are + rows, and the task folder and the repo's issue list ride under them.""" yaml = (tmp_skeleton / "projects.yaml").read_text(encoding="utf-8") (tmp_skeleton / "projects.yaml").write_text( yaml.replace("remote: none", "remote: exampleorg/widgets"), encoding="utf-8") c = _cortex.census(tmp_skeleton) page = _cortex.render_dashboard(c) - rest = _cortex.open_phases("example", c)[1:] - assert len(rest) == 6 - assert f"{len(rest)} more open phase(s) · plans and issues" in page - for r in rest: - assert f"]({r['rel']}) — {r['state']}" in page + html = _cortex.render_dashboard_html(c) + tasks = _cortex.open_tasks("example", c) + assert len(tasks) == 8, [r["rel"] for r in tasks] + for r in tasks: + assert f'{r["summary"]}' in page, r["rel"] + assert f'>{r["summary"]}' in html, r["rel"] + # the fixture copy is outside any repo, so no owner is derivable and + # the folder is bare backticks rather than a link + assert "`tasks/example/`" in page assert "https://github.com/exampleorg/widgets/issues" in page - assert "https://github.com/exampleorg/widgets/issues" in \ - _cortex.render_dashboard_html(c) + assert "https://github.com/exampleorg/widgets/issues" in html + # the rows read in the order `open_tasks` put them in + order = [page.index(r["summary"]) for r in tasks] + assert order == sorted(order), [r["state"] for r in tasks] -def test_a_project_with_one_open_phase_grows_no_fold(tmp_skeleton): - """The fold is the tail, so a project whose next phase IS its only open - one shows the row and nothing else.""" - for name in ("01_scope", "02_gated_on_dev", "04_submitted_override", - "05_running_array", "06_pulled", "07_awaiting_ruling"): - (tmp_skeleton / "phases" / "example" / f"{name}.md").unlink() - c = _cortex.census(tmp_skeleton) - assert len(_cortex.open_phases("example", c)) == 1 - for page in (_cortex.render_dashboard(c), _cortex.render_dashboard_html(c)): - assert "more open phase(s)" not in page - - -def test_a_phase_that_names_nowhere_still_renders(tmp_skeleton): - """A `planned` phase may still be carrying the template placeholder — +def test_a_task_that_names_nowhere_still_renders(tmp_skeleton): + """A `planned` task may still be carrying the template placeholder — `check` exempts it — so every rendering has to survive an empty list.""" - scope = tmp_skeleton / "phases" / "example" / "01_scope.md" + scope = tmp_skeleton / "tasks" / "example" / "01_scope.md" text = scope.read_text(encoding="utf-8") scope.write_text(re.sub(r"## Where to look\n\n(?:- .*\n)+", "## Where to look\n\n", text, count=1), @@ -661,7 +749,7 @@ def test_a_phase_that_names_nowhere_still_renders(tmp_skeleton): mod = _cortex.load_cortex(tmp_skeleton) assert mod.check_problems(tmp_skeleton) == [] c = _cortex.census(tmp_skeleton) - row = [r for r in c["phases"] if r["rel"].endswith("01_scope.md")][0] + row = [r for r in c["tasks"] if r["rel"].endswith("01_scope.md")][0] assert row["where"] == [] page = _cortex.render_dashboard(c) assert "### example" in page and "where to look: \n" not in page @@ -673,77 +761,81 @@ def test_a_phase_that_names_nowhere_still_renders(tmp_skeleton): # --- the two missing prompts ---------------------------------------------- def _synthetic(*rows) -> dict: - """A census-shaped dict — the payloads read only `phases` + `projects`.""" - return {"phases": list(rows), + """A census-shaped dict — the payloads read only `tasks` + `projects`.""" + return {"tasks": list(rows), "projects": {"proj": {"local_path": "/s/proj", "sync_cli": "hpc/sync", "sync_verbs": ["pull", "submit"]}}} -def _row(number, state, slug, epic=None): - return {"rel": f"phases/proj/{slug}.md", "slug": slug, "title": slug, - "project": "proj", "phase": number, "state": state, "epic": epic, +def _row(state, slug, epic=None): + return {"rel": f"tasks/proj/{slug}.md", "slug": slug, "title": slug, + "summary": f"the {slug} question", "project": "proj", + "state": state, "epic": epic, "runs": [], "budget": None, "budget_minutes": None, "wall_minutes": 0, "review_minutes": None, "gates": [], "failed_runs": [], "where": []} -def test_accept_and_open_opens_the_planned_sibling(skeleton): - """A planned phase N+1 IS the ledger naming the next phase — the prompt - opens it rather than writing a second one beside it.""" - c = _synthetic(_row(4, "awaiting-ruling", "four"), _row(5, "planned", "five")) - payload = _cortex._next_phase_payload(c["phases"][0], c) - assert "rule phases/proj/four.md accept --body " in payload - assert "move phases/proj/five.md ready" in payload +def test_accept_and_open_opens_the_sibling_the_tree_already_holds(skeleton): + """A planned sibling IS the ledger naming another question — the prompt + opens it rather than writing a second one beside it. There is no "next + number": the slug is the identity and the order is the human's.""" + c = _synthetic(_row("awaiting-ruling", "four"), _row("planned", "five")) + payload = _cortex._next_task_payload(c["tasks"][0], c) + assert "rule tasks/proj/four.md accept --body " in payload + assert "move tasks/proj/five.md ready" in payload assert "cd /s/proj && hpc/sync submit