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..11a175b0 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 ('