From 64b18b9c1c250b6b15997aebf336c4d46787b0ee Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 4 Sep 2026 13:34:46 -0400 Subject: [PATCH 1/2] feat(cortex): board leads with the check-in prompt, a freshness stamp and projects; epics discarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cortex board opened with every phase of every active project, so a reader scrolled past the whole Euclid plan before reaching what needed a verdict, and nothing said when the science state was last refreshed. - A πŸ“‹ "check in since last time" chip is the first task row: the paste for the laptop science chat, naming the last check-in. - "Last check-in" is read from PyAutoCortex/checkin.yaml, which `cortex checkin` / `collect --apply` now write before rendering so push_ledger carries it; the HTML twin turns it red on view when the viewer's clock is more than 60 min past it. Age is computed on view, never at render, and the file is stable between renders so --check needs no new normaliser rule. - Summary table: one row per active project (next phase, awaiting / running / ready, last ruling). - Projects replaces both the leading By-project wall and the trailing Projects table: active β†’ planned β†’ dormant, Local / Mirror / RAL on their own bold lines, the next phase with its chip, the remaining open phases folded behind links to the phase files, phases// and the project's issues. No where-to-look bullets on the page; the printout and census --by-project keep the full tree. - Awaiting ruling is the first state section; Running / submitted drops the "run it again" chip; Ready shows one phase per project and folds the rest; Gated and Recent rulings unchanged. - Epics: section, parse_epics and the epics.md dependency removed. The optional `Epic:` phase header stays as a join key to a Mind epic. Closes #354 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FDMikgtrzGV799S5WXPpFq --- agents/conductors/cortex/AGENTS.md | 36 +- agents/conductors/cortex/_cortex.py | 548 ++++++++++++++++++---------- docs/organs/cortex.md | 9 +- skills/cortex/cortex.md | 6 +- tests/test_cortex_conductor.py | 316 ++++++++++++++-- 5 files changed, 675 insertions(+), 240 deletions(-) diff --git a/agents/conductors/cortex/AGENTS.md b/agents/conductors/cortex/AGENTS.md index 873fa453..0209a773 100644 --- a/agents/conductors/cortex/AGENTS.md +++ b/agents/conductors/cortex/AGENTS.md @@ -20,7 +20,7 @@ the Cortex's intake-and-dashboard conductor. | Verb | Question | Emits | |------|----------|-------| | `checkin [--dry-run \| --apply] [--push \| --no-push] [--project KEY] [--skip-pull] [--refreshed ISO]` | **Where is my science?** | the door β€” the sequence below, ending in a summary keyed by project; exit **1** when a pull failed or the tree needs a look | -| `census` | What is the Cortex holding? | phase counts by state, the board's section counts, rulings, projects, epics (`--json` for the lot) | +| `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) | | `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 | @@ -60,18 +60,36 @@ Mind's: a session holding one organ and not the other still works. ## The board -Sections, in the reading order of a check-in: **Awaiting ruling** (pulled and -awaiting-ruling phases, ordered failures β†’ a ruling is required β†’ clean) β†’ -**Running / submitted** (job ids, wall against the phase's budget) β†’ -**Ready** β†’ **Gated** (the open refs) β†’ **Recent -rulings** β†’ **Epics** (each card links its Mind half) β†’ **Projects** (the -where-to-look table straight from `projects.yaml`). A counts table near the -top is what `board/_board.py` reads for the Brain board's Cortex strip. +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, +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. + +**`checkin.yaml`** is the one-key file (`refreshed: `) that +`cmd_checkin` and `collect --apply` write from the stamp they scored against, +before rendering. `census()` reads it into `c["checkin"]`; missing means +"never checked in", unparseable is a `problems` line. The stamp means *last +check-in*, never last render, so a doc-only push cannot fake freshness, and +the HTML twin computes its age on the **viewer's** clock (a static page +cannot know when it is read) and reddens it past sixty minutes. `--check` compares the pages with the generation comment **and** the visible `Last updated` banner stripped, so a re-render on a new date is not drift β€” the Mind's normaliser strips only the comment, which is why its refresh -workflow self-heals with an empty commit most nights. +workflow self-heals with an empty commit most nights. `checkin.yaml` needs no +rule of its own: it is stable between renders. ## Gates diff --git a/agents/conductors/cortex/_cortex.py b/agents/conductors/cortex/_cortex.py index da4202e0..c2d320cd 100644 --- a/agents/conductors/cortex/_cortex.py +++ b/agents/conductors/cortex/_cortex.py @@ -227,12 +227,12 @@ def _home_from_git(root: Path) -> str: # One section: (key, title, source path in the repo, blurb). SECTIONS = ( - ("by_project", "By project", "projects.yaml", - "Where every project is: the folders it lives in, every phase of it that " - "is still open, the locations its `## Where to look` names, and the " - "prompt for each thing that phase could become. This is the check-in " - "view β€” `pyauto-brain cortex checkin` and `census --by-project` print " - "the same tree."), + ("projects", "Projects", "projects.yaml", + "The science body map, active first: where each project lives, what it " + "is holding, and the one phase of it to act on next. The plans and the " + "issues are one click away β€” this page routes to them rather than " + "reprinting them; `census --by-project` and the check-in printout keep " + "the full per-phase tree."), ("awaiting", "Awaiting ruling", "phases/", "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 " @@ -249,20 +249,50 @@ def _home_from_git(root: Path) -> str: ("rulings", "Recent rulings", "rulings/", "The ledger of record, newest first. A verdict recorded only outside the " "Cortex does not exist."), - ("epics", "Epics", "epics.md", - "Long-running programmes whose science half lives here; each card links " - "its development half in the Mind."), - ("projects", "Projects", "projects.yaml", - "The science body map β€” where each project lives, what syncs it and " - "where its witness lands."), ) REFRESH_BLURB = ( - "generated from `phases/`, `rulings/`, `epics.md` and `projects.yaml`, so " - "it is only as current as they are. `dashboard_refresh.yml` re-renders it " - "on every push to `main`." + "generated from `phases/`, `rulings/` and `projects.yaml`, so it is only " + "as current as they are. `dashboard_refresh.yml` re-renders it on every " + "push to `main`." ) +#: The one-key file the check-in door writes and this page reads back. It +#: means *last check-in*, not last render: a doc-only push re-renders the +#: board and must not be able to fake freshness. +CHECKIN_FILE = "checkin.yaml" +CHECKIN_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$") +CHECKIN_LABEL = "check in since last time" +CHECKIN_NEVER = "never checked in" + + +def read_checkin(root: Path) -> tuple[str, list[str]]: + """`(stamp, problems)` from `/checkin.yaml`. + + Missing is not a problem: a Cortex nobody has checked in yet is a state + the page can say out loud. A file that IS there but carries no readable + `refreshed:` is β€” something wrote it, and a stamp that cannot be parsed + cannot be trusted to mean anything. + """ + path = root / CHECKIN_FILE + if not path.is_file(): + return "", [] + m = re.search(r"^refreshed:\s*(\S+)\s*$", _read(path), re.M) + value = (m.group(1) if m else "").strip().strip('"') + if not CHECKIN_STAMP.match(value): + return "", [f"{CHECKIN_FILE}: no readable `refreshed: ` β€” " + "read as never checked in"] + return value, [] + + +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.""" + path = root / CHECKIN_FILE + path.write_text(f"refreshed: {stamp}\n", encoding="utf-8") + return path + def _mins(value: str) -> int | None: """`H+:MM` β†’ minutes, or None.""" @@ -337,44 +367,13 @@ def _ruling_row(mod, r) -> dict: } -EPIC_HEAD = re.compile(r"^## (\S+)\s*$") -EPIC_FIELD = re.compile(r"^- ([a-z-]+):\s*(.*?)\s*$") - - -def parse_epics(path: Path) -> list[dict]: - """`epics.md` entries β€” `## ` then `- key: value` lines. The Cortex - half additionally carries `- mind-half:`, the slug of the Mind's entry.""" - if not path.is_file(): - return [] - entries: list[dict] = [] - cur: dict | None = None - fenced = False - for line in path.read_text(encoding="utf-8").splitlines(): - # `epics.md` documents its own schema in a fenced block whose body is - # a `## ` template β€” reading that as an entry would put a ghost - # card on the board of an empty Cortex. - if line.startswith("```"): - fenced = not fenced - continue - if fenced: - continue - head = EPIC_HEAD.match(line) - if head: - cur = {"slug": head.group(1)} - entries.append(cur) - continue - field = EPIC_FIELD.match(line) - if field and cur is not None: - cur[field.group(1)] = field.group(2) - return entries - - 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) rulings, ruling_problems = mod.load_rulings(root) + checkin, checkin_problems = read_checkin(root) rows = [_phase_row(mod, ph) for ph in phases] by_state: dict[str, int] = {} @@ -419,8 +418,9 @@ def census(root: Path) -> dict: "planned": planned, "rulings": ruling_rows, "projects": projects, - "epics": parse_epics(root / "epics.md"), - "problems": project_problems + phase_problems + ruling_problems, + "checkin": checkin, + "problems": (project_problems + phase_problems + ruling_problems + + checkin_problems), } @@ -531,6 +531,18 @@ 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. +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" + needs no second explanation.""" + since = c.get("checkin") or "never" + return (f"/cortex β€” check in on every active science project since the " + 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.") + + def _ruling_payload(r: dict) -> str: return (f"Review the PyAutoCortex phase {r['rel']} and help me rule on it: " f"read its `## Witness` and the pulled evidence under its " @@ -585,9 +597,9 @@ def _gate_payload(r: dict) -> str: def _next_phase(r: dict, c: dict) -> dict | None: """The phase numbered N+1 of this project, when the tree already holds it. - A planned sibling IS the ledger naming the next phase β€” `epics.md` points - at `phases//` for exactly this reason β€” so the prompt opens the - phase that exists rather than writing a second one beside it. + 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. """ if r["phase"] is None: return None @@ -684,7 +696,10 @@ 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))) - if state in AWAITING_STATES or state == "running": + # Only where a rerun would BE a verdict. On a running phase 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: chips.append(("run it again", _rerun_payload(r, c))) return chips @@ -713,6 +728,89 @@ def project_groups(key: str, c: dict) -> list[tuple[str, list[dict]]]: return out +#: `status` in the order the reader wants it: what is running, then what is +#: coming, then what is asleep. An unrecognised status sorts last. +STATUS_RANK = {"active": 0, "planned": 1, "dormant": 2} + +#: 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") + + +def project_order(keys: list[str], c: dict) -> list[str]: + """Active, then planned, then dormant; alphabetical inside each rank.""" + return sorted(keys, key=lambda k: ( + STATUS_RANK.get((c["projects"][k].get("status") or "").strip(), 3), k)) + + +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 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 _last_ruling(key: str, c: dict) -> str: + """The date of this project's newest ruling, `-` when it has none.""" + dates = [] + for r in c["rulings"]: + if r["project"] != key: + continue + stamp = (r["reviewed_at"] or "")[:10] + if not stamp: + m = re.match(r"R-(\d{4})(\d{2})(\d{2})-", r["id"] or "") + stamp = "-".join(m.groups()) if m else "" + if stamp: + dates.append(stamp) + return max(dates) if dates else "-" + + +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.""" + 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) + out.append({ + "project": key, + "next": "-" if nxt is None else + (f"{nxt['phase']} {nxt['state']}" if nxt["phase"] is not None + else nxt["state"]), + "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 + + 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 @@ -725,15 +823,33 @@ def by_project_keys(c: dict) -> tuple[list[str], list[str]]: return sorted(shown), sorted(set(c["projects"]) - shown) -def project_paths(row: dict) -> list[str]: - """The three folders a project lives in β€” the answer to "where is it".""" - where = [f"local `{row.get('local_path') or '?'}`"] +def project_paths(row: dict) -> list[tuple[str, str]]: + """`(label, path)` for the folders a project lives in. One per line on the + board with the label in bold: "where is it" is read, not scanned, and the + three run together on one line was the complaint that started this.""" + where = [("Local", row.get("local_path") or "?")] if (row.get("mirror") or "none") != "none": - where.append(f"mirror `{row['mirror']}`") - where.append(f"RAL `{row.get('ral_root') or '?'}`") + where.append(("Mirror", row["mirror"])) + where.append(("RAL", row.get("ral_root") or "?")) return where +def project_paths_line(row: dict) -> str: + """The same folders on one line β€” what the check-in printout and + `census --by-project` read, where a paragraph each would be noise.""" + return " Β· ".join(f"{label if label == 'RAL' else label.lower()} `{path}`" + for label, path in project_paths(row)) + + +def _issues_url(row: dict) -> str: + """The project repo's issue list, from the row's own `remote:`. Empty for + a project with no remote β€” most dormant rows, by design.""" + remote = (row.get("remote") or "").strip() + if not remote or remote == "none" or "/" not in remote: + return "" + return f"https://github.com/{remote}/issues" + + def project_counts(key: str, c: dict) -> str: counts: dict[str, int] = {} for r in c["phases"]: @@ -743,15 +859,23 @@ def project_counts(key: str, c: dict) -> str: or "none" -def _project_payload(key: str, row: dict) -> str: - path = row.get("mirror") if row.get("mirror") not in ("", "none") else row.get("local_path", "") - return f"Show me what is under {path} for the science project {key}." +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"{project_counts(key, c)}") + +def _fold_label(rest: list[dict]) -> str: + return f"{len(rest)} more open phase(s) Β· plans and issues" -def _epic_payload(e: dict) -> str: - return (f"Work out where the Cortex half of the epic {e['slug']} stands " - f"from {e.get('ledger') or 'phases/'} and tell me what its next " - "phase is.") + +def _phase_dir(key: str, blob: str) -> str: + """`phases//` 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 "" # --------------------------------------------------------------- markdown --- @@ -787,7 +911,9 @@ 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.""" - head = _phase_head(r) + f" β€” **{r['state']}**" + # ``, 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"])) @@ -861,31 +987,65 @@ def h2(key: str) -> list: L += [f"> - `{_cell(p)}`" for p in c["problems"][:10]] L += [""] + # The first row on the page, before any section: the paste that refreshes + # everything below it, and the stamp saying when that last happened. + L += [_task_row(CHECKIN_LABEL, _checkin_payload(c)), "", + f"**Last check-in:** {c.get('checkin') or CHECKIN_NEVER}", ""] + + L += ["## Summary", "", + "One row per active project β€” the next thing, what it is holding, " + "and when it last ruled.", ""] + rows = summary_rows(c) + if rows: + L += ["| Project | Next phase | Awaiting | Running | Ready | " + "Last ruling |", "|---|---|---:|---:|---:|---|"] + L += [f"| {_cell(r['project'])} | {_cell(r['next'])} | " + f"{r['awaiting']} | {r['live']} | {r['ready']} | " + f"{_cell(r['ruling'])} |" for r in rows] + L += [""] + else: + L += ["- _(no active project)_", ""] - # First section, above the state lists: on a phone the state lists are - # cross-project and every row has to be read to find out whose it is, - # while this one answers "where is everything" a screen per project and - # opens with the folders. The counts table sits above it, so the urgency - # sections stay one tap away. - L += h2("by_project") + + # The map, first: 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. + L += h2("projects") shown, folded = by_project_keys(c) - for key in shown: - L += [f"### {_summary_label(key)}", "", - "- " + " Β· ".join(project_paths(c["projects"][key])), - f"- phases: {project_counts(key, c)}", ""] - groups = project_groups(key, c) - if not groups: + for key in project_order(shown, c): + row = c["projects"][key] + L += [f"### {_summary_label(key)}", ""] + 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._", ""] - for title, rows in groups: - L += [f"**{title}**", ""] - for r in rows: - L += _project_phase_md(r, c) + 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), "", "
", ""] if folded: - L += [f"
{len(folded)} dormant project(s) with " - "nothing open", "", - " Β· ".join(f"`{_cell(k)}` ({project_counts(k, c)})" - for k in folded), + L += [f"
{len(folded)} project(s) with nothing " + "open", "", + " Β· ".join( + f"`{_cell(k)}` ({_cell((c['projects'][k].get('status') or '?').strip())}" + f" Β· {project_counts(k, c)})" for k in project_order(folded, c)), "", "
", ""] L += h2("awaiting") @@ -916,8 +1076,18 @@ def h2(key: str) -> list: L += [""] L += h2("ready") - L += _items([_task_row(_phase_head(r), _ready_payload(r, c["projects"])) - for r in c["ready"]]) or ["- _(nothing ready to launch)_"] + 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") @@ -937,35 +1107,6 @@ def h2(key: str) -> list: else: L += ["- _(no rulings yet)_", ""] - L += h2("epics") - epic_rows = [] - for e in c["epics"]: - head = f"{_summary_label(e.get('title') or e['slug'])}" - half = e.get("mind-half") - if half and half != "none": - head += f" β€” Mind half: {_summary_label(half)}" - if e.get("ledger"): - head += f" β€” ledger: {_summary_label(e['ledger'])}" - if e.get("status"): - head += f" β€” {_summary_label(e['status'])}" - epic_rows.append(_task_row(head, _epic_payload(e))) - L += _items(epic_rows) or ["- _(no epics yet)_"] - L += [""] - - L += h2("projects") - if c["projects"]: - L += ["| Project | Status | Partition | Sync | Ledger | Witness |", - "|---|---|---|---|---|---|"] - for key, row in sorted(c["projects"].items()): - L.append(f"| {_cell(key)} | {_cell(row.get('status', '-'))} | " - f"{_cell(row.get('partition', '-'))} | " - f"`{_cell(row.get('sync_cli', '-'))}` | " - f"`{_cell(row.get('ledger', '-'))}` | " - f"`{_cell(row.get('witness_file', '-'))}` |") - L += [""] - else: - L += ["- _(the science body map is empty)_", ""] - links = _board_links(home, THEME_ORGAN) if links: L += ["---", "", @@ -982,7 +1123,21 @@ def h2(key: str) -> list: "table.map th{text-align:left;color:var(--muted);" "font-weight:600}" "table.map td,table.map th{border-bottom:1px solid var(--line);" - "padding:.4rem .5rem .4rem 0;vertical-align:top}") + "padding:.4rem .5rem .4rem 0;vertical-align:top}" + ".checkin{margin:.2rem 0 1.4rem}" + ".paths{margin:.2rem 0}.paths b{display:inline-block;" + "min-width:3.6rem}" + ".stale{color:var(--bad);font-weight:600}") + +# The page is static, so a stamp is never stale *at render* β€” it is stale on +# the reader's clock, an hour later, on a different day. So the age is +# computed on load, here, and nowhere else. +_CHECKIN_JS = ( + "(function(){var e=document.getElementById('checkin');if(!e)return;" + "var t=e.getAttribute('datetime'),d=t?new Date(t):null;" + "if(!d||isNaN(d.getTime())||(Date.now()-d.getTime())/60000>60){" + "e.classList.add('stale');" + "e.textContent+=' \\u00b7 stale, paste the check-in';}})();") def render_dashboard_html(c: dict) -> str: @@ -1048,41 +1203,81 @@ def phase_head(r, tone_pills=()): H += [f"
  • {_attr(p)}
  • " for p in c["problems"][:10]] H += [""] + # The first button on the page, before any section: the paste that + # refreshes everything below it, then the stamp saying when that last + # happened. The script at the bottom reddens the stamp on the viewer's + # own clock. + stamp = c.get("checkin") or "" + H += [_html_task(_summary_label(CHECKIN_LABEL), _checkin_payload(c)), + f'

    Last check-in: ' + f'

    '] + + H += ['

    Summary

    ', + '

    One row per active project β€” the next thing, what ' + "it is holding, and when it last ruled.

    "] + rows = summary_rows(c) + if rows: + H += ['', + "" + ""] + H += [f"" + f"" + f"" + f"" for r in rows] + H.append("
    ProjectNext phaseAwaitingRunningReadyLast ruling
    {_summary_label(r['project'])}{_summary_label(r['next'])}{r['awaiting']}{r['live']}{r['ready']}{_summary_label(r['ruling'])}
    ") + else: + H.append('

    (no active project)

    ') + - H.append(h2("by_project")) + H.append(h2("projects")) shown, folded = by_project_keys(c) - for key in shown: - H += [f"

    {_summary_label(key)}

    ", - '

    ' - + _md_inline(" Β· ".join(project_paths(c["projects"][key]))) - + f' β€” phases: {_summary_label(project_counts(key, c))}

    '] - groups = project_groups(key, c) - if not groups: + for key in project_order(shown, c): + row = c["projects"][key] + H.append(f"

    {_summary_label(key)}

    ") + H += [f'

    {label} ' + 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 ' "project is history)

    ") - for title, rows in groups: - H.append(f"

    {title}

    ") - for r in rows: - text = phase_head(r, ((r["state"], "n"),)) - if r["failed_runs"]: - text += (' β€” ⚠️ failed runs: ' - + _summary_label(", ".join(r["failed_runs"])) - + "") - note = _live_note(r) if r["state"] in LIVE_STATES else "" - if note: - text += f' β€” {_summary_label(note)}' - H.append(f"

    {text}

    ") - if r["where"]: - H += ["
      "] + [f"
    • {_md_inline(_summary_label(b))}
    • " - for b in r["where"]] + ["
    "] - for label, payload in phase_chips(r, c): - H.append(_html_task(_summary_label(label), payload)) + 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) + "

    ") if folded: H.append('

    ' - + f"{len(folded)} dormant project(s) with nothing open: " - + " Β· ".join(f"{_summary_label(k)} " - f"({_summary_label(project_counts(k, c))})" - for k in folded) + "

    ") + + f"{len(folded)} project(s) with nothing open: " + + " Β· ".join( + f"{_summary_label(k)} " + f"({_summary_label((c['projects'][k].get('status') or '?').strip())}" + f" Β· {_summary_label(project_counts(k, c))})" + for k in project_order(folded, c)) + "

    ") H.append(h2("awaiting")) for r in c["awaiting"]: @@ -1108,9 +1303,14 @@ def phase_head(r, tone_pills=()): H.append('

    (nothing on the queue)

    ') H.append(h2("ready")) - for r in c["ready"]: - H.append(_html_task(phase_head(r, (("ready", "g"),)), - _ready_payload(r, c["projects"]))) + 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)

    ') @@ -1137,42 +1337,11 @@ def phase_head(r, tone_pills=()): else: H.append('

    (no rulings yet)

    ') - H.append(h2("epics")) - for e in c["epics"]: - text = f"{_summary_label(e.get('title') or e['slug'])}" - half = e.get("mind-half") - if half and half != "none": - # The epic's development half lives in the Mind; the card links it - # so the two dashboards read as one programme. - text += f' β€” Mind half: {_summary_label(half)}' - if e.get("ledger"): - text += f' β€” ledger: {_summary_label(e["ledger"])}' - if e.get("status"): - text += f' β€” {_summary_label(e["status"])}' - H.append(_html_task(text, _epic_payload(e))) - if not c["epics"]: - H.append('

    (no epics yet)

    ') - - H.append(h2("projects")) - if c["projects"]: - H += ['', - "" - ""] - for key, row in sorted(c["projects"].items()): - H.append(f"" - f"" - f"" - f"" - f"" - "") - H.append("
    ProjectStatusPartitionSyncWitness
    {_summary_label(key)}{_summary_label(row.get('status', '-'))}{_summary_label(row.get('partition', '-'))}{_summary_label(row.get('sync_cli', '-'))}{_summary_label(row.get('witness_file', '-'))}
    ") - else: - H.append('

    (the science body map is empty)

    ') - footer = boards_footer(dict(_board_links(home, THEME_ORGAN)), THEME_ORGAN) if footer: H.append(footer) - H += [f"", "", ""] + H += [f"", + f"", "", ""] return "\n".join(H) + "\n" @@ -1206,7 +1375,7 @@ def emit_census(c: dict) -> None: f"(heads {sum(1 for r in c['rulings'] if r['head'])})") print(f"Projects: {len(c['projects'])} " + " Β· ".join(sorted(c["projects"]))) - print(f"Epics: {len(c['epics'])}") + print(f"Last check-in: {c['checkin'] or CHECKIN_NEVER}") if c["problems"]: print(f"Problems: {len(c['problems'])} β€” run " "`python3 scripts/cortex.py check`") @@ -1890,6 +2059,9 @@ 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 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" @@ -2207,7 +2379,7 @@ def project_digest(key: str, row: dict, c: dict, scored_by_rel: dict, `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}", "", "- " + " Β· ".join(project_paths(row))] + L = [f"### {key}", "", "- " + project_paths_line(row)] if pull_line: L.append(f"- {pull_line}") L.append(f"- phases: {project_counts(key, c)}") @@ -2354,7 +2526,11 @@ def cmd_checkin(root: Path, mod, a) -> int: print(f" {problem}", file=sys.stderr) return RC_DRIFT - # --- 3. render ------------------------------------------------------- + # --- 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. + write_checkin(root, stamp) c = census(root) pages = render_pages(c) for name, want in pages.items(): diff --git a/docs/organs/cortex.md b/docs/organs/cortex.md index 2fa5fdb9..857a9ad7 100644 --- a/docs/organs/cortex.md +++ b/docs/organs/cortex.md @@ -100,7 +100,7 @@ same split as **Heart ↔ vitals** and **Gut ↔ hygiene**. | Verb | What it does | |------|--------------| | `checkin [--dry-run \| --apply] [--push \| --no-push] [--project KEY] [--skip-pull]` | **The door.** Pull every active project through its own sync CLI (streamed; one project's failure does not stop the sweep), score every live phase, move what came back, re-render the board, push the ledger where the rule allows, and print a summary **by project** with the copy-ready prompt each phase's state already has. `--dry-run` (the default) says what it would do and reaches nothing | -| `census` | What the Cortex is holding β€” phases by state, rulings, projects, epics | +| `census` | What the Cortex is holding β€” phases 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 | @@ -125,9 +125,10 @@ cite its words. ![The Cortex board](../_static/cortex_board.png) -Every phase the Cortex is holding on one page β€” awaiting ruling first, then -running, ready, gated, the ruling ledger, the epics (each linking its Mind -half) and the project map β€” published at +Every phase 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 . It hands out the next command; the verdict is never on the page. diff --git a/skills/cortex/cortex.md b/skills/cortex/cortex.md index e56c4d38..0a8999f6 100644 --- a/skills/cortex/cortex.md +++ b/skills/cortex/cortex.md @@ -46,7 +46,9 @@ It runs, in order: phase's own pre-registered witness. 3. **Move** β€” `running β†’ pulled β†’ awaiting-ruling`, rehearsed on a throwaway copy first and refused outright if `cortex.py check` would not pass after. -4. **Render** β€” `dashboard.md` + `dashboard.html`. +4. **Render** β€” the refresh stamp into `checkin.yaml` (the board's "Last + check-in", red on the Pages twin once an hour old), then `dashboard.md` + + `dashboard.html`. 5. **Push** β€” see the rule below. 6. **Summarise by project** β€” printed last, so it is what the chat sees. @@ -117,7 +119,7 @@ checkout. | Verb | Answers | |------|---------| | `checkin [--dry-run\|--apply] [--push\|--no-push] [--project KEY] [--skip-pull] [--refreshed ISO]` | Where is my science? The whole sequence above. Exit **1** = a pull failed or the tree needs a look | -| `census [--json]` | What is the Cortex holding? Phase counts by state, rulings, projects, epics | +| `census [--json]` | What is the Cortex holding? Phase 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` | diff --git a/tests/test_cortex_conductor.py b/tests/test_cortex_conductor.py index 0cd14d41..bc6f85fe 100644 --- a/tests/test_cortex_conductor.py +++ b/tests/test_cortex_conductor.py @@ -221,16 +221,84 @@ def test_the_gates_verb_is_a_read_only_wrapper_over_the_scripts_listing(tmp_skel assert after == before -def test_the_epics_schema_block_is_not_read_as_an_epic(tmp_path): - """`epics.md` documents its own schema in a fenced block whose body is a - `## ` template β€” an empty Cortex must show no epic card.""" - (tmp_path / "epics.md").write_text( - "# Epics\n\n```markdown\n## \n- title: \n```\n" - "\n## real-epic\n- title: A real one\n- mind-half: dev-half\n", - encoding="utf-8") - entries = _cortex.parse_epics(tmp_path / "epics.md") - assert [e["slug"] for e in entries] == ["real-epic"] - assert entries[0]["mind-half"] == "dev-half" +def test_the_cortex_holds_no_epics_of_its_own(tmp_skeleton): + """Retired 2026-09-04: a science project IS the long programme, so the + Cortex keeps no epics file, no census key and no section. A tree with an + `epics.md` left lying in it is simply not read.""" + assert not hasattr(_cortex, "parse_epics") + (tmp_skeleton / "epics.md").write_text( + "# Epics\n\n## a-leftover\n- title: not read\n", encoding="utf-8") + c = _cortex.census(tmp_skeleton) + assert "epics" not in c + for page in (_cortex.render_dashboard(c), _cortex.render_dashboard_html(c)): + assert "a-leftover" not in page + assert "Epics" not in page + # …and the section list no longer names one. + assert "epics" not in [key for key, _t, _s, _b in _cortex.SECTIONS] + + +# --- the check-in chip and the freshness stamp ----------------------------- +def _first_task(page: str) -> str: + """The first copy row on either twin β€” markdown ``, HTML button.""" + m = re.search(r"πŸ“‹ ([^<\n]+)", page) or \ + re.search(r'

    ([^<]+)', page) + return m.group(1) if m else "" + + +def test_the_checkin_chip_is_the_first_task_row_on_both_twins(skeleton): + """The reader arrives, and the first thing on the page is the paste that + refreshes everything below it β€” before any section.""" + c = _cortex.census(skeleton) + page = _cortex.render_dashboard(c) + assert _first_task(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") + assert "read me the by-project summary" in payload + + html = _cortex.render_dashboard_html(c) + first = html.index('

    ') + assert _cortex.CHECKIN_LABEL in html[first:first + 900] + assert first < html.index("

    Summary

    ") + + +def test_the_stamp_is_read_from_checkin_yaml_and_says_never_without_it(tmp_skeleton): + c = _cortex.census(tmp_skeleton) + assert c["checkin"] == "" and c["problems"] == [] + assert f"**Last check-in:** {_cortex.CHECKIN_NEVER}" in \ + _cortex.render_dashboard(c) + assert _cortex.CHECKIN_NEVER in _cortex.render_dashboard_html(c) + + _cortex.write_checkin(tmp_skeleton, "2026-09-04T14:03:00Z") + c = _cortex.census(tmp_skeleton) + assert c["checkin"] == "2026-09-04T14:03:00Z" + assert "**Last check-in:** 2026-09-04T14:03:00Z" in _cortex.render_dashboard(c) + assert ('