A plan/execute/verify loop for a single developer in Claude Code, with adversarial review at every gate: plans and diffs get refuted by a fresh Claude subagent…

Appearance is cheap. Verification is the work.
Cadence is for developers using Claude Code on software they will still own after the session ends.
Claude can write a convincing plan, produce working code, and tell you the job is finished. The harder part is keeping the decisions that led there, stopping a long session from becoming the project record, and establishing that what you got is what you asked for.
Cadence keeps the project in the repository. Decisions, plans, progress, review findings and verification live under .planning/, where a new session reads them off disk. A planner, an executor, reviewers and a verifier each work in fresh context, and nothing is certified by the thing that wrote it. You are the engineer of record: you approve the plan, triage what the reviewers find, and authorize every push, except the unattended close, which runs only when both the repository's config and your own user-global config set git.auto_close.

Cadence rebuilds Verbatim's state from the repo, finds phase 1 executed and awaiting UAT, and offers /cad-verify 1 as the next step.
Cadence is deliberately not an autopilot. If you want to describe a feature and come back to a merged PR, this is the wrong tool.
The methodology ships as controls. Each step of the loop has named checks around it, each check records that it ran, and a check that did not run is not a check that passed. The record is a file in your repo, not a claim in a chat window.
Cadence is a Claude Code plugin. Add the marketplace, then install:
/plugin marketplace add https://github.com/crenshawdev/cadence.git
/plugin install cadence@cadence
Update with /plugin update cadence@cadence, remove with /plugin uninstall cadence@cadence. Requires Claude Code with plugin support, plus node, git and one forge CLI - tea, gh or glab - on your PATH, because Cadence resolves a forge and an issue tracker when it sets a project up. Those are host prerequisites: the scripts inside are zero-dependency, and there is no npm install, ever.
On Claude Code 2.1.284 and later, when managed settings set allowManagedPermissionRulesOnly, a plugin from a marketplace no longer pre-approves its own tools through allowed-tools unless managed settings vouch for its source, so Cadence's commands ask before using their tools on those machines. Nothing changes anywhere else.
Cadence runs as slash commands namespaced /cadence:cad-* (for example /cadence:cad-new-project). They are written below without the cadence: prefix for brevity. A project moves through five steps, each its own command:
/cad-new-project define the project through deep questioning: what, why, who, done./cad-context <phase> gather locked decisions and acceptance criteria before planning./cad-plan <phase> turn a phase into an executable, checkable plan./cad-execute <phase> build it, one atomic commit per task./cad-verify <phase> confirm the phase delivered what it promised.Step 1 has a second door. /cad-adopt is the entrance for a project that already exists: it reads the repo, the manifests and the git history, writes what the code already does into PROJECT.md as shipped work and what is left into a remaining-work ROADMAP.md, and asks only what the repo cannot answer. Same .planning/ on disk either way, so step 2 onward is identical.
Step 1 also takes a shortcut when the questioning already happened somewhere else: /cad-new-project --brief <file> reads the design brief that conversation produced, treats what it settles as answered, and asks only about what it leaves open. docs/DISCOVERY.md is how you get there from a freeform conversation.
/cad-progress tells you where you stand and what's next at any point: it finds incomplete or paused work and offers to resume it.
That is five commands out of twenty-eight. /cad-help prints the full reference inside a session, and cadence-core/references/COMMANDS.md is that same reference in the repo, readable before you install anything.
On a Claude Code version with mods support (2.1.287 or later), Cadence also loads a small module that shows where the loop stands. On an older version the module never loads and everything else works exactly as before.
The band is off until you turn it on: /cad-panel on, or the "Cadence band and token capture" row in /config. /cad-panel off turns it off again. With it on, a one-line band sits above the prompt in any repo with a .planning/ directory: the phase you are on, its status, any Cadence agent running right now with its rung, and the next command. Once a request in this session reads back less of the prompt cache than the request before it left, a cache break, the band counts them after the status. It redraws when an agent starts or returns and after every tool call, so a cursor set shows up as soon as it lands. In a repo without .planning/ there is no band.
/cad-panel, or p on the band, opens the pane, band on or off: the current phase's plans and which are done, each running agent with its role, rung and model, the UAT counts, the open captures, the phase's token spend (the same figure /cad-report prints, with its exclusions named), the prompt-cache hit rate and cache breaks for the main loop and each running agent, and the next command. The cache figures are this session's, live, and not part of the phase's spend. n puts that command in the prompt. When the cursor and the files disagree about which phase is open, the pane says so instead of picking one.

Mid-execute: one of phase 9's three plans is done, the executor is running at the xhigh rung, and the spend line counts the dispatches it has no figure for and names what it excludes.
The module does more than draw. The module below lists everything it does and the one file it writes.
Eight of them, and every one hands its decision to you rather than deciding for you.
| Control | Where it fires | What it does |
|---|---|---|
| Plan review | before any code is written | an adversarial reviewer tries to break the plan, findings come back as a numbered list you triage |
| Risk surface | on each plan's completed commit range | checks the diff against eight named surfaces, and blocks on a match by default |
| Push rail | every git push a workflow attempts | a PreToolUse hook, cadence-core/bin/git-guard.mjs, stops and asks you. The one exemption is the unattended close: cadence-core/bin/git-publish.mjs publishes the integration branch as a subprocess the hook never sees, and only when both the repository's config and your user-global config set git.auto_close |
| Protected branch | a commit on main or master | asks, refuses, or allows, per git.on_protected |
| Verification | after a phase is built | conversational UAT plus a goal-backward pass, claims scored verified, failed, or uncertain |
| Traceability audit | before a release ships | /cad-audit traces every requirement to a phase, a plan and a verification, both directions |
| Coverage audit | on a completed phase | /cad-coverage reads the assertions rather than counting test files |
| The record | every dispatch, always | .planning/trace.jsonl prices each subagent, /cad-report reads it back as receipts |
Two of those rows, at work:

The goal-backward pass scores 6 of 8, and both failures are specific: an acceptance criterion no developer-run test actually asserts, and a performance cost that belongs to an earlier version rather than this phase. It asks how to resolve the failure rather than deciding.

With verification resolved, the audit traces Verbatim's active requirement to its phase, its plan and a checked verification box, and reports the criteria coverage behind it.
/cad-report renders one phase's record as a narrative, and /cad-suggest reads the same record back the other way: it turns what the dispatches actually cost and what the gates actually caught into retune suggestions, each carrying its config key, the value in force and a direction, plus a target where the record can price one, and it offers to route the ones you accept to /cad-config. The controls generate the evidence, and that is what the evidence is for.
The reviewers are adversarial by construction, because you cannot personally re-derive everything the model wrote and neither can I. The default reviewer is a fresh-context Claude subagent and needs no API key. An OpenAI, Gemini, or DeepSeek key runs the identical job as a direct API call, which lets you put up to four independent voices on one plan and have your main session adjudicate against the cited code. Every backend returns the same shape on purpose, so each finding is ruled on against the code it cites and never on which reviewer raised it. Each ruling still records its voice, which is what lets you count every reviewer's hit rate. The one signal treated as strong is convergence, two reviewers landing on the same defect independently. What survives comes back as a multi-select prompt whose default is none of it, never a queue the model starts working through.
/cad-minimalism-review points the same posture at code that works and should not exist, an abstraction with one implementation, flexibility nothing exercises, config nobody sets, and hands back a ranked delete-list. It applies none of it.
Cadence assumes the model will fail. Not that it is bad at the job, that it will now and then hand you something that looks finished and is not, and that you will not always catch it by reading. Everything else follows: the state stays durable, the workers stay disposable, and the rails sit where a worker cannot argue with them.
Nothing important lives in the conversation. The roadmap, the plans, the summaries, the verification checklist and the four-line state cursor all sit in .planning/ and in git history, and every command rebuilds what it needs from disk. Clear the window at any phase boundary and you lose nothing. There is no resume, a continuation is a fresh spawn that reads the prior artifact off disk, and every one of those spawns lands in the run record where you can read what it cost.
A check that could not run never passes a gate. A reviewer that failed says why out loud instead of quietly dropping out of the set. The verifier scores every claim as verified, failed, or uncertain, and uncertain counts toward neither side. A test that would still pass if the behavior were wrong is not coverage, which is why the coverage audit reads assertions.
The git rails are a PreToolUse hook rather than a paragraph of instructions, because a model will talk itself around a paragraph and it will not talk itself around a hook.
That shape was expensive to learn, and I paid for it twice. First a predicate called isPlainPush that would recognize a safe push and wave it through, very clever, and four rounds of adversarial review found four ways around it. Then a shell tokenizer, which took two milestones and the 2,251 lines v2.2.0 deleted before I admitted it could be switched off entirely by a long enough command line, in a hook that fails open. Both are gone. The one sanctioned push runs through a subprocess the hook never sees, built from an argument vector rather than a shell string, and what the guard reads now is eighty-five lines: a command counts if it starts with the word git. bash -c "git push" is invisible to it, and that is written down rather than left to be discovered.
METHOD.md is the full account of what the planner, executor, verifier and reviewers do and where each rule is enforced. INTERNALS.md is the mechanism underneath: routing, the publish seam, and why the decision cores are pure functions. docs/WORKFLOW.md is the same material as a diagram, five figures and the four tables behind them. docs/EVIDENCE.md defines the three weight terms and gives the weight.mjs commands that print the current numbers for any tree. docs/COST.md is what a run costs on my own account. docs/EXAMPLE.md walks one small project through the whole cycle.
Cadence sends no telemetry, makes no network call of its own except to a cross-model reviewer you turned on, and keeps its record in your repository. Everything else it touches outside the project is listed here, so you don't have to find it by reading the scripts.
They're off by default. The shipped reviewer is the Claude subagent, which needs no key and sends nothing your session isn't already sending. A provider runs only when your user-global config's review.reviewers names it as well as the project's, so a repository's committed review.reviewers can't send your code to a provider on your key by itself, and cloning a repo that lists openai changes nothing until you've named openai yourself. Naming a provider there authorizes it for every repository whose own config names it too, and /cad-config --review asks before it writes that.
| Provider | Host | Key |
|---|---|---|
| OpenAI | https://api.openai.com | OPENAI_API_KEY |
| Gemini | https://generativelanguage.googleapis.com | GEMINI_API_KEY |
| DeepSeek | https://api.deepseek.com | DEEPSEEK_API_KEY |
The key comes from the environment first. When the variable isn't set, cadence-core/bin/review-provider.mjs reads it from ~/.config/cadence/providers.env (under $XDG_CONFIG_HOME when that's set), or from the file review.key_file names in your user-global config. That's a script reading a credential off your machine, which is what the plugin directory flags, and it stays that way on purpose: a plugin userConfig secret only reaches hooks and MCP servers, and the review calls run through Bash. The key is never written to a config file or printed, and each key goes only to its own provider's host.
review-provider.mjs makes three calls, each to that host with that key:
review sends the review instruction and the artifact under review, a plan or a diff, after credential redaction. It runs when a review gate fires with that provider in the reviewer set, and at /cad-decision-review.consult sends a short description of a dead end, the goal, what was tried and the exact failing signal, after the same redaction. It runs only with review.consult.enabled true, and only after you say yes to an offer that names the provider and model. /cad-debug, /cad-execute and /cad-plan make that offer.detect-models sends no project content, only the key as the request's credential, to list the provider's models when you run /cad-config --review.Redaction catches a credential by its shape, a credential-shaped name beside its value, a URL's userinfo, an Authorization header, and not by a list of known prefixes. A bare key sitting in a diff with nothing naming it goes out as written, so don't point a reviewer at a secrets file.
Every forge write goes through your own tea, gh or glab, signed in as you:
repo create --private, once, at /cad-new-project, after you confirm the owner and name;issue create, with an issue list first to find a duplicate, when you send a review finding to the tracker instead of fixing it now;/cad-land, when you pick that arm, or unattended when git.auto_close is true in both the repository's config and your user-global config.Context7 and excerpt are used when they're installed and skipped when they're not, and Cadence installs neither. Without Context7, /cad-decision-review checks library and API claims against the installed package source, the lockfile or vendored docs, and lists every claim it couldn't check. Without excerpt, every agent reads and searches with the built-in Read and Grep.
On a host with mods, the module does five things:
/cad-panel pane from planning.mjs reads, the files under .planning/ and the token usage the host reports for each request;subagent_type from a bare Cadence agent name to the plugin-prefixed one, and leaves your own agents' names alone;--agent-id <id> to a Cadence subagent's own planning.mjs trace close command, so the record joins it to the right dispatch;.planning/trace.jsonl through planning.mjs trace append, pricing a dispatch whose return carried no token count from the host's own usage for that agent, so /cad-report has fewer gaps.The band and the token-count facts run only with the panel setting on, and it's off by default. With it off, /cad-report's figures come from the returns and the subagent-trace hook alone.
That trace is the only file it writes. /cad-panel on and off change the panel setting through Claude Code, which saves it in settings.json like any other /config row. It never runs a slash command, never touches STATE.md, and never takes over the git rail: git-guard stays a command hook.
subagent-trace reads the stopped subagent's own transcript, the file the host keeps for that agent, to price the dispatch in .planning/trace.jsonl.read-trace logs the path of each project file a tool call opens to .planning/reads.jsonl. Paths only, never contents, and a path outside the project is never recorded.What Cadence writes outside your repository:
~/.claude/cadence/config.json, or the file CADENCE_GLOBAL_CONFIG names, when a setup interview or /cad-config saves a machine-wide answer;CAPTURE.md beside that config, from /cad-capture --cadence;worktree.baseRef key, merged into .claude/settings.json or ~/.claude/settings.json, only after you pick the file at /cad-config;mktemp under TMPDIR, or /tmp when it's unset.What it reads outside it: your user-global config, providers.env or the review.key_file file during a provider call, and ~/.claude/settings.json and the platform's managed-settings.json to learn worktree.baseRef before running plans in parallel worktrees.
Every Cadence command except /cad-help lists Bash in allowed-tools with no command pattern, so it doesn't ask before running a shell command. Narrowing that looks safer and isn't:
${CLAUDE_PLUGIN_ROOT} path one time, an exported variable or a relative path the next. A permission rule matches only the exact form it names, so a narrowed command would stop and ask about its own scripts.node -e read-backs and case ... esac guards. The only rule that covers those allows arbitrary code, which narrows nothing./cad-execute, /cad-task and /cad-coverage run workflow.test_command or a detected test runner, /cad-spike runs experiment code, and /cad-debug reruns reproductions./cad-land runs forge merge commands, and the unattended /cad-milestone into /cad-land chain can't stop to ask about them. A prompt that shows up mid-run stalls that chain where nobody is watching.Pushes are still guarded by git-guard, a hook rather than a permission rule, and the one push it never sees is the unattended close above.
No telemetry, no analytics, no phoning home. The only network calls Cadence makes itself are the three provider calls above, each on the terms stated there. Forge and push traffic goes through your own tea, gh, glab and git. The planning record, the run trace and the reads log stay in .planning/ in your repository and go wherever you push it. Cadence runs inside Claude Code, so what your session sees goes wherever Claude Code sends it, which is the host's policy and not this plugin's.
Cadence used to ask how much you wanted a dispatch to cost, and then it asked what a break in the project would cost. Both were one word standing in for twelve decisions somebody else had already made for you. It asks you the twelve now, one role at a time:
/cad-config --roles
Thirteen questions in four prompts. Six name a role and ask which model it runs on, six ask which effort rung it starts at, and the last asks what a detected risk surface should be allowed to do. Every question says what that role does in the phase loop and what a stronger or weaker answer buys you there, so the interview is the documentation and there is nothing to read first. /cad-new-project and /cad-adopt ask them once, machine-wide; /cad-config --roles re-opens them for one project, and --roles --global re-opens the machine-wide answers.
Your answers are twelve keys, roles.<role>.model and roles.<role>.effort, and nothing derives one role's answer from another's. A model left unset sends NO model parameter at all, so that dispatch runs on your own session's model, and a model name this host does not accept is named in the resolve's warnings with the parameter drop
hooks/cadence-mod.mjs 669 lines1// @ts-check
2// cadence-mod.mjs - the Cadence module: the in-process half of the plugin, for
3// hosts that load mods. hooks/hooks.json names it under `modules`, beside the
4// command hooks. A host without mods ignores that key and keeps the hooks.
5//
6// It stays thin on purpose. A hooks module may import only relative files and
7// `claude-code`: no `node:` module, no Node globals. So every rule it applies
8// lives in a dependency-free file under ../cadence-core/bin/lib/, where CI's
9// typecheck and test.mjs cover it and the command hooks can share it (the
10// `.planning/` walk in lib/git-segments.mjs is the first). This file only wires
11// those rules to host events and does its I/O through `$`.
12//
13// What it does: the band above the prompt, naming the running Cadence agents
14// it tracks from subagent start and stop, and redrawn on those and on every
15// tool call (Plan 2 of phase 3).
16//
17// The band and token capture run only with the plugin's `panel` userConfig
18// field on, which is off by default. Off, the band draws nothing, so the
19// drawing beneath it shows as it was, and no window is kept, no fact written
20// and no close rewritten. The pane, the listing filter, the prefix safety net
21// and the cache meter run either way. `/cad-panel on` and `/cad-panel off`
22// write the field through `$.config.set`, as the `/config` row does, and the
23// host reloads the module with the new value.
24//
25// And token capture (Plan 3, D-10/D-11). It keeps each subagent's last
26// `turn.step` window, never `turn.complete`'s sum, and writes it as one
27// `step_window` fact through `planning.mjs trace append` for the figureless
28// `trace close` that names that agent id. The fact takes that close's own
29// `--phase` and `--anchor`, never the STATE.md cursor's, which often names
30// another phase. A subagent's own close (an advisory reviewer's tail) runs
31// before its last step, so its fact is written at its stop; a coordinator's
32// close runs after the stop, so its fact is written just before the close is
33// passed on. A window whose close never comes is never written, and at most
34// HELD_MAX of them wait at once (PNL-07). A subagent's
35// own `trace close` gains the `--agent-id` the host sees, so an advisory
36// reviewer's bracket and its fact join. The trace reader folds the fact only
37// into a bracket whose return carried no figure, so a return's own figure
38// always wins. A write that fails is silent: a record may not change a
39// decision.
40//
41// And the Cadence pane (phase 4), beside the band and the token capture: the
42// full picture of the current phase, its rows built by lib/pane-view.mjs. The
43// `/cad-panel` command this module registers opens it. Like everything here
44// it exists only on hosts with mods, so it adds no skill, no resident
45// description and no byte budget (D-01). The pane draws from the snapshot its
46// last fetch left and does no I/O while drawing. `/cad-panel` calls `next`
47// and then answers its own result in that one's place: a registered command
48// has no core for `next` to run, and its own text, an empty one included,
49// replaces the line the host prints for a command nobody answered. The band's
50// `[ pane ]` button, `p` while the band holds the focus, opens the same pane.
51// Docked beside the transcript it draws its own round frame, since the host
52// draws only a separator there; inline the host borders it, so it draws none.
53// It draws a title row, because the host shows the title only as a tab once
54// two panes are open. The next command is a button: `n` while the pane holds
55// the focus puts it in the prompt.
56//
57// And the listing filter (phase 5, D-01). The 30 rung agents' and the six
58// contract skills' entries come out of the agent and skill listings the model
59// reads, in every session and every repo (D-02): route.mjs picks each agent
60// and the skill dispatching it names it, so the descriptions bought nothing.
61// It edits the `prompt.attachment` text by lib/listing-filter.mjs's line rule.
62// The module never withholds through `agent.offer`, it only watches there:
63// withholding would take the type out of dispatch too (`Agent type
64// 'cadence:cad-reviewer-low' not found`), and `command.describe` `isHidden`
65// only hides the command menu entry.
66//
67// And the prefix safety net (phase 5, D-07; MOD-04). Cadence commands dispatch
68// route.mjs's `agent_type`, already `<plugin name>:<stem>`, since a bare name
69// belongs to whoever owns it. An Agent call that still names exactly one of
70// Cadence's bare stems gets `<plugin name>:` added here, read from
71// `$.plugin.name`, unless an `agent.offer` from a non-`plugin` source named
72// that bare agent: the offer observer records those, and a user's own
73// `cad-reviewer` goes through as sent. The rule is lib/agent-prefix.mjs; every
74// other call goes through as sent.
75//
76// And the cache meter. The same `turn.step` hands every request's usage, the
77// main loop's and each agent's, to lib/cache-meter.mjs. The band counts the
78// session's cache breaks once there is one, and the pane shows each loop's hit
79// rate and breaks. Live and in memory only; the trace keeps none of it (#309).
80//
81// Every handler calls `next` exactly once and swallows its own errors (D-12).
82//
83// git-guard, read-trace and subagent-trace stay command hooks on every host, so
84// this module makes no git decision and stands no hook down.
85
86import { planningRootAsync } from '../cadence-core/bin/lib/git-segments.mjs';
87import { parseCursor } from '../cadence-core/bin/lib/state-cursor.mjs';
88import { bandLine, rosterReconcile, rosterStart, rosterStop } from '../cadence-core/bin/lib/band.mjs';
89import { roleOfAgent } from '../cadence-core/bin/lib/rung-agent.mjs';
90import { ownedAgent, prefixedAgent } from '../cadence-core/bin/lib/agent-prefix.mjs';
91import { filterListing, LISTING_TYPES } from '../cadence-core/bin/lib/listing-filter.mjs';
92import { closeArgs, stepWindow, stepWindowArgv, withAgentId } from '../cadence-core/bin/lib/token-capture.mjs';
93import { NO_PROJECT_TEXT, PANEL_FIELD, PANEL_OFF_TEXT, PANEL_ON_TEXT, PANEL_USAGE, panelArg, panelOn,
94 panelUnchanged, parseResolves, RUN_FAILED, SEAM_TIMEOUT_MS, seamAnswer, seamArgv, sightDraw, sightStart, sightStop,
95 singleFlight } from '../cadence-core/bin/lib/pane.mjs';
96import { paneView } from '../cadence-core/bin/lib/pane-view.mjs';
97import { EMPTY_METER, MAIN, meterDrop, meterStep } from '../cadence-core/bin/lib/cache-meter.mjs';
98
99/**
100 * The pane's id and title, held once: `/cad-panel` and anything else that
101 * opens the pane open this one.
102 */
103const PANE_ID = 'cadence';
104const PANE = Object.freeze({ id: PANE_ID, title: 'Cadence' });
105
106/**
107 * One view segment as a host Text. A style the segment leaves unset is left
108 * off the props, never passed as undefined. Box and Text take the same props
109 * on every surface, so there is nothing to drop on a desktop.
110 * @param {any} Text
111 * @param {import('../cadence-core/bin/lib/pane-view.mjs').Segment} s
112 */
113const segmentText = (Text, s) => Text({ children: s.text, ...(s.color ? { color: s.color } : {}),
114 ...(s.bg ? { backgroundColor: s.bg } : {}), ...(s.bold ? { bold: true } : {}), ...(s.dim ? { dimColor: true } : {}) });
115
116/** The pane's button that puts the next command in the prompt: `n` while the pane has the keys. */
117const NEXT_KEY = 'n';
118/** Cells a frame takes from the body: the border and one cell of padding, each side. */
119const FRAME_CELLS = 4;
120
121/** The command that opens the pane. User-facing, so the name is locked (D-01). */
122const PANEL_COMMAND = 'cad-panel';
123
124/** The band's button that opens the pane: drawn `[ pane ]`, pressed by `p`. */
125const PANE_BUTTON_LABEL = 'pane';
126const PANE_BUTTON_KEY = 'p';
127const PANE_BUTTON_CELLS = PANE_BUTTON_LABEL.length + 4;
128
129/** `dir/name`, without doubling the separator at a filesystem root. */
130const at = (/** @type {string} */ dir, /** @type {string} */ name) =>
131 (/[\\/]$/.test(dir) ? dir + name : `${dir}/${name}`);
132
133/**
134 * Ask the host to draw the band again (D-08). The draw re-reads STATE.md, so
135 * this is all a refresh needs. A throw here never reaches the event's answer.
136 * @param {any} $
137 */
138function redraw($) {
139 try {
140 $.ui.invalidate('ui.render');
141 } catch {
142 // the next event tries again
143 }
144}
145
146/**
147 * The most stopped subagents' windows `held` keeps waiting for a close (PNL-07).
148 * A close comes right after its subagent's return, so a window still waiting
149 * after this many later stops is one no close will ever name; the oldest goes.
150 */
151const HELD_MAX = 64;
152
153/**
154 * @typedef {{windows: Map<string, number>, adopted: Map<string, {phase: string, anchor: string | null}>,
155 * held: Map<string, number>}} Capture
156 * `windows`: each running subagent's latest step window. `adopted`: the
157 * close a running subagent ran on itself, waiting for its last window.
158 * `held`: a stopped Cadence subagent's last window, waiting for its close,
159 * at most HELD_MAX of them.
160 * Every read-and-forget below happens before the first await, so two
161 * handlers interleaving never write one window twice.
162 */
163
164/**
165 * Run one step-window fact in the project a walk from `start` finds.
166 * @param {any} $
167 * @param {string} start
168 * @param {{phase: string, anchor: string | null}} close the close it prices
169 * @param {string} id
170 * @param {number} tokens
171 */
172async function writeFact($, start, close, id, tokens) {
173 const root = await planningRootAsync(start, (dir, name) => $.fs.exists(at(dir, name)));
174 if (root === null) return;
175 await $.process.run(stepWindowArgv($.plugin.root, close.phase, id, tokens, close.anchor),
176 { cwd: root, timeoutMs: 10000 });
177}
178
179/**
180 * A subagent stopped. A Cadence subagent whose own close already named its
181 * phase gets its fact now, with the window of its LAST step; the project is
182 * the walk from the stop's own `cwd`, the field subagent-trace walks from, so
183 * the fact lands in the trace.jsonl the close landed in. Without that close
184 * its window is held for the coordinator's.
185 * @param {any} $
186 * @param {any} e the `classic.SubagentStop` input
187 * @param {Capture} c
188 */
189async function stopped($, e, c) {
190 try {
191 const id = e.agent_id;
192 if (typeof id !== 'string') return;
193 const tokens = c.windows.get(id);
194 const close = c.adopted.get(id);
195 c.windows.delete(id);
196 c.adopted.delete(id);
197 if (tokens === undefined || roleOfAgent(e.agent_type) === null) return;
198 if (close === undefined) {
199 // Re-inserted, so a Map's insertion order stays oldest-first.
200 c.held.delete(id);
201 c.held.set(id, tokens);
202 if (c.held.size > HELD_MAX) c.held.delete(c.held.keys().next().value);
203 return;
204 }
205 await writeFact($, typeof e.cwd === 'string' && e.cwd ? e.cwd : await $.session.cwd(), close, id, tokens);
206 } catch {
207 // no record this time; the bracket stays figureless
208 }
209}
210
211/**
212 * A Bash call is about to run. When it is a figureless `trace close` naming a
213 * stopped Cadence subagent, write that subagent's held window under the
214 * close's own phase first. When it names a subagent still running, keep the
215 * close for its stop. A close carrying `--tokens` needs no fact.
216 * @param {any} $
217 * @param {any} e the event as it goes to `next`, rewrite included
218 * @param {Capture} c
219 */
220async function closing($, e, c) {
221 try {
222 if (e.tool !== 'Bash') return;
223 const close = closeArgs(e.command);
224 if (close === null) return;
225 const id = close.agentId;
226 const tokens = c.held.get(id);
227 c.held.delete(id);
228 if (close.priced) return;
229 if (tokens === undefined) {
230 if (c.windows.has(id)) c.adopted.set(id, { phase: close.phase, anchor: close.anchor });
231 return;
232 }
233 await writeFact($, await $.session.cwd(), close, id, tokens);
234 } catch {
235 // no record this time; the bracket stays figureless
236 }
237}
238
239/**
240 * @typedef {{snapshot: import('../cadence-core/bin/lib/pane.mjs').Snapshot | null, open: boolean,
241 * kick: (task: () => unknown) => Promise<void>}} Pane
242 * `snapshot`: what the pane's last fetch read, or null before the first one
243 * settles. `open`: whether the pane is up, so a fetch is worth running.
244 * `kick`: the pane's single-flight runner.
245 */
246
247/**
248 * Hand the pane a fetch when it is open, without waiting on it, so no event's
249 * answer ever waits on a fetch.
250 * @param {any} $
251 * @param {Pane} p
252 */
253function refresh($, p) {
254 if (!p.open) return;
255 void p.kick(() => fetchPane($, p));
256}
257
258/**
259 * Read what the pane shows, keep it, and ask for a draw. Never throws, and
260 * always leaves a snapshot, failed or not.
261 * @param {any} $
262 * @param {Pane} p
263 */
264async function fetchPane($, p) {
265 if (!p.open) return;
266 try {
267 // A pane whose drawing threw is dropped without a `ui.close` reaching
268 // the hooks, so ask the engine whether it is still up.
269 const panes = await $.ui.panes();
270 if (Array.isArray(panes) && !panes.some((x) => x && x.id === PANE_ID)) {
271 p.open = false;
272 return;
273 }
274 } catch {
275 // no list: fetch anyway
276 }
277 /** @type {import('../cadence-core/bin/lib/pane.mjs').Snapshot} */
278 const read = { cursor: null, status: { ok: false, reason: 'not-read' }, captures: { ok: false, reason: 'not-read' },
279 spend: null, resolves: [], sessionModel: null };
280 try {
281 const root = await planningRootAsync(await $.session.cwd(), (dir, name) => $.fs.exists(at(dir, name)));
282 if (root === null) {
283 read.status = read.captures = { ok: false, reason: 'no-planning-dir' };
284 } else {
285 [read.cursor, read.status, read.captures, read.resolves, read.sessionModel] = await Promise.all([
286 readCursor($, root), runSeam($, root, ['status']), runSeam($, root, ['capture-check']),
287 readResolves($, root), sessionModel($)]);
288 // The spend describes status's derived phase (D-05); none, no run.
289 const current = read.status.ok ? read.status.value.current : null;
290 if (current !== null && current !== undefined) {
291 read.spend = await runSeam($, root, ['trace', 'render', '--phase', String(current)]);
292 }
293 }
294 } catch {
295 // no walk: nothing read
296 }
297 p.snapshot = read;
298 redraw($);
299}
300
301/**
302 * The project's STATE.md cursor, or null when it is missing or unreadable:
303 * the band's feed (D-03).
304 * @param {any} $
305 * @param {string} root
306 */
307async function readCursor($, root) {
308 try {
309 return parseCursor(await $.fs.read(at(root, '.planning/STATE.md')));
310 } catch {
311 return null;
312 }
313}
314
315/**
316 * The resolve events in the project's trace.jsonl. A read that rejects (no
317 * file, or one over the host's 4 MiB cap) yields none.
318 * @param {any} $
319 * @param {string} root
320 */
321async function readResolves($, root) {
322 try {
323 return parseResolves(await $.fs.read(at(root, '.planning/trace.jsonl')));
324 } catch {
325 return [];
326 }
327}
328
329/**
330 * The session's model, for a resolve that routed none, or null unread.
331 * @param {any} $
332 * @returns {Promise<string | null>}
333 */
334async function sessionModel($) {
335 try {
336 const m = await $.session.model();
337 return typeof m === 'string' && m ? m : null;
338 } catch {
339 return null;
340 }
341}
342
343/**
344 * One `planning.mjs` subcommand against the project, its answer parsed. The
345 * pane re-derives nothing in-process: planning-files imports `node:fs`, and a
346 * second derivation would be a second answer (D-04).
347 * @param {any} $
348 * @param {string} root
349 * @param {readonly string[]} args
350 * @returns {Promise<import('../cadence-core/bin/lib/pane.mjs').Seam>}
351 */
352async function runSeam($, root, args) {
353 try {
354 const ran = await $.process.run(seamArgv($.plugin.root, root, args), { cwd: root, timeoutMs: SEAM_TIMEOUT_MS });
355 return seamAnswer(ran && ran.stdout);
356 } catch {
357 return RUN_FAILED;
358 }
359}
360
361/**
362 * Open the pane in the project a walk from the session's directory finds, and
363 * start its fetch without waiting on it. The answer is the command's text: the
364 * no-project line, the reason an open waits undrawn, or none once it is drawn.
365 * @param {any} $
366 * @param {Pane} p
367 */
368async function openPane($, p) {
369 try {
370 const root = await planningRootAsync(await $.session.cwd(), (dir, name) => $.fs.exists(at(dir, name)));
371 if (root === null) return { text: NO_PROJECT_TEXT };
372 const opened = await $.ui.open({ id: PANE.id, title: PANE.title });
373 p.open = true;
374 refresh($, p);
375 // An empty text, never a missing one: only a text replaces the host's own
376 // "no command.run hook answered it" line (measured on 2.1.289).
377 return opened && opened.isPlaced === false ? { text: String(opened.reason) } : { text: '' };
378 } catch {
379 return { text: 'The Cadence pane did not open.' };
380 }
381}
382
383/**
384 * Write the `panel` setting, as its `/config` row would. Off closes the pane
385 * first: the write reloads the module, and the pane goes with the band. The
386 * answer is the command's text, the host's deny included.
387 * @param {any} $
388 * @param {Pane} p
389 * @param {boolean} value
390 */
391async function setPanel($, p, value) {
392 if (!value) {
393 try {
394 await $.ui.close({ id: PANE_ID });
395 } catch {
396 // not up
397 }
398 p.open = false;
399 }
400 try {
401 const set = await $.config.set({ key: `${$.plugin.name}.${PANEL_FIELD}`, value });
402 if (set && set.deny !== undefined) return { text: panelUnchanged(String(set.deny)) };
403 } catch {
404 return { text: panelUnchanged('') };
405 }
406 return { text: value ? PANEL_ON_TEXT : PANEL_OFF_TEXT };
407}
408
409/**
410 * @param {any} on the host's hook registrar
411 * @param {unknown} [options] the plugin's userConfig values; a change reloads
412 * the module, so they are fixed for this activation
413 */
414export function register(on, options) {
415 // The band and token capture run only with the `panel` setting on, off by
416 // default.
417 const panel = panelOn(options);
418 // The Cadence agents running in this session (D-07). Every write is
419 // `roster = transition(roster, ...)` with its await done first, so two
420 // handlers interleaving never write back a stale roster.
421 /** @type {readonly {id: string, role: string, rung: string}[]} */
422 let roster = [];
423 // When the module first saw each roster agent, and its host type: the
424 // pane's, kept beside the roster so the roster's shape stays phase 3's.
425 /** @type {readonly import('../cadence-core/bin/lib/pane.mjs').Sight[]} */
426 let sights = [];
427 /** @type {Pane} */
428 const pane = { snapshot: null, open: false, kick: singleFlight() };
429 /** @type {Capture} */
430 const capture = { windows: new Map(), adopted: new Map(), held: new Map() };
431 // The session's cache meter. Like the roster, every write is
432 // `meter = meterStep(meter, ...)`.
433 let meter = EMPTY_METER;
434 const { windows } = capture;
435 // Bare agent names a non-`plugin` `agent.offer` named (MOD-04). It only
436 // grows: an agent deleted mid-session stays owned, which errs toward sending
437 // the user's name as written.
438 /** @type {Set<string>} */
439 const owned = new Set();
440
441 // Pass-through: `e` goes down unchanged (D-12 leaves Phase 6 its effort
442 // override here). A subagent's step that reports usage replaces its window,
443 // so the last step wins, with the `panel` setting on. Every step, main's
444 // too, goes to the cache meter, and a break or an open pane asks for a draw.
445 on('turn.step', async function* ($, e, next) {
446 const result = yield* next(e);
447 try {
448 const w = panel && typeof e.agentId === 'string' ? stepWindow(result && result.usage) : null;
449 if (w !== null) windows.set(e.agentId, w);
450 } catch {
451 // a step we could not read prices nothing
452 }
453 try {
454 const before = meter.breaks;
455 meter = meterStep(meter, typeof e.agentId === 'string' ? e.agentId : MAIN, e.model, e.messageCount,
456 result && result.usage);
457 if (meter.breaks !== before || pane.open) redraw($);
458 } catch {
459 // a step we could not read counts nothing
460 }
461 return result;
462 });
463
464 on('classic.SubagentStart', async ($, e, next) => {
465 const answer = await next(e);
466 try {
467 const session = await $.session.id();
468 roster = rosterStart(roster, e, session);
469 // The band's reconcile may have added this agent already; its type and
470 // start still count.
471 if (roster.some((a) => a.id === e.agent_id)) sights = sightStart(sights, e.agent_id, e.agent_type, Date.now());
472 } catch {
473 // the next draw's reconcile adds what this missed
474 }
475 redraw($);
476 refresh($, pane);
477 return answer;
478 });
479
480 on('classic.SubagentStop', async ($, e, next) => {
481 const answer = await next(e);
482 try {
483 roster = rosterStop(roster, e.agent_id);
484 sights = sightStop(sights, e.agent_id);
485 meter = meterDrop(meter, e.agent_id);
486 } catch {
487 // the next draw's reconcile drops what this missed
488 }
489 redraw($);
490 refresh($, pane);
491 if (panel) await stopped($, e, capture);
492 return answer;
493 });
494
495 // A `cursor set` or `renumber` is a Bash call, and they are the only STATE
496 // writers, so redrawing after each tool call shows a cursor change as soon as
497 // it lands. A write from outside the session shows at the next event. No timer.
498 //
499 // With the `panel` setting on, a subagent's own `planning.mjs trace close`
500 // gains `--agent-id` here (D-11), and any figureless close that names an
501 // agent id prices it from its window.
502 // An Agent call naming a bare Cadence stem gains the plugin's prefix (phase 5,
503 // D-07) as a safety net, unless the offer observer below saw a project or
504 // user agent of that bare name. Anything that goes wrong before `next` sends
505 // the event exactly as the model wrote it.
506 on('tool.call', async ($, e, next) => {
507 let sent = e;
508 try {
509 if (panel && e.tool === 'Bash' && typeof e.agentId === 'string') {
510 const rewritten = withAgentId(e.command, e.agentId);
511 if (rewritten !== null) sent = { ...e, command: rewritten };
512 } else if (e.tool === 'Agent') {
513 const type = prefixedAgent(e.subagent_type, $.plugin.name, owned);
514 if (type !== null) sent = { ...e, subagent_type: type };
515 }
516 } catch {
517 sent = e;
518 }
519 if (panel) await closing($, sent, capture);
520 const result = await next(sent);
521 redraw($);
522 refresh($, pane);
523 return result;
524 });
525
526 // The offer observer (MOD-04). The listing batch reaches here before the
527 // first Agent call, so every project and user agent is known by then. No
528 // matcher: a matcher cannot say "any source but `plugin`". It passes `e`
529 // through and returns what `next` returned, never `isOffered: false`.
530 on('agent.offer', async ($, e, next) => {
531 try {
532 const name = ownedAgent(e);
533 if (name !== null) owned.add(name);
534 } catch { /* an offer it cannot read records nothing (D-04) */ }
535 return next(e);
536 });
537
538 // The two listings, without Cadence's agents and contract skills (phase 5,
539 // D-01). It filters what `next` resolved, so a text another hook dropped
540 // stays dropped, and anything that throws sends the listing unfiltered.
541 on('prompt.attachment', { type: LISTING_TYPES }, async ($, e, next) => {
542 const result = await next(e);
543 try {
544 const text = result.text;
545 if (typeof text !== 'string') return result;
546 const kept = filterListing(e.type, text);
547 return kept === text ? result : { text: kept };
548 } catch {
549 return result;
550 }
551 });
552
553 // The band. AbovePrompt holds one tree, so the band goes in a column above
554 // whatever the mods beneath drew, never in place of it. No band with the
555 // `panel` setting off, while a survey holds the row, or outside a Cadence
556 // project (D-06).
557 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
558 const drawn = await next(e);
559 try {
560 if (!panel || e.props.hasSurvey) return drawn;
561 const root = await planningRootAsync(await $.session.cwd(), (dir, name) => $.fs.exists(at(dir, name)));
562 if (root === null) return drawn;
563 let cursor = null;
564 try {
565 cursor = parseCursor(await $.fs.read(at(root, '.planning/STATE.md')));
566 } catch {
567 // unreadable is the same as absent: the /cad-progress line
568 }
569 try {
570 const list = await $.agent.list();
571 roster = rosterReconcile(roster, list);
572 } catch {
573 // no list: draw the roster the start and stop events built
574 }
575 const { Box, Text, Button } = $.ui.resolve(e);
576 if (typeof Button !== 'function') {
577 const band = Text({ wrap: 'truncate-end', children: bandLine(cursor, roster, e.props.bodyColumns, meter.breaks) });
578 return Box({ flexDirection: 'column', children: [band, drawn] });
579 }
580 // The pane's button beside the line, which takes what is left. A letter,
581 // never a digit: a bare digit in an empty composer presses a band Button.
582 const line = bandLine(cursor, roster, Math.max(0, e.props.bodyColumns - PANE_BUTTON_CELLS - 1), meter.breaks);
583 const button = Button({ label: PANE_BUTTON_LABEL, hotkey: PANE_BUTTON_KEY,
584 onPress: () => { openPane($, pane).catch(() => {}); } });
585 const band = Box({ flexDirection: 'row', gap: 1, children: [Text({ wrap: 'truncate-end', children: line }), button] });
586 return Box({ flexDirection: 'column', children: [band, drawn] });
587 } catch {
588 return drawn;
589 }
590 });
591
592 // --- the pane --------------------------------------------------------------
593
594 on('session.start', async ($, e, next) => {
595 try {
596 await $.command.register({ name: PANEL_COMMAND, immediate: true, argumentHint: 'on | off',
597 description: 'Open the Cadence pane: plans, running agents, UAT, captures, spend and next command' });
598 } catch {
599 // no command this session; the band still draws
600 }
601 return next(e);
602 });
603
604 // `/cad-panel` opens the pane whatever the setting; `on` and `off` write it.
605 on('command.run', { command: PANEL_COMMAND }, async ($, e, next) => {
606 await next(e);
607 const arg = panelArg(e.args);
608 if (arg === 'open') return openPane($, pane);
609 if (arg === null) return { text: PANEL_USAGE };
610 return setPanel($, pane, arg === 'on');
611 });
612
613 // `/cad-panel`'s empty answer draws as a bare `cadence:`; draw one dim
614 // line in its place. A reason (no project, the open waited) draws as written.
615 on('ui.render', { component: 'CommandOutput', props: { command: PANEL_COMMAND } }, async ($, e, next) => {
616 const drawn = await next(e);
617 try {
618 // The host prints a plugin's answer after its name (`cadence: `).
619 const said = String(e.props.text).replace(`${$.plugin.name}:`, '').trim();
620 if (said) return drawn;
621 const { Box, Text } = $.ui.resolve(e);
622 return Box({ paddingLeft: 2, children: [Text({ dimColor: true, children: '⎿ Cadence pane open' })] });
623 } catch {
624 return drawn;
625 }
626 });
627
628 // A close marks the pane down, so no event fetches for it.
629 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
630 const answer = await next(e);
631 pane.open = false;
632 return answer;
633 });
634
635 // The pane draws the last snapshot and awaits nothing but `next`. With no
636 // snapshot (a module reloaded while its pane stayed up) the pane is up, so
637 // it counts as open and starts the one fetch that leaves a snapshot.
638 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
639 const drawn = await next(e);
640 try {
641 if (pane.snapshot === null) {
642 pane.open = true;
643 refresh($, pane);
644 }
645 const { Box, Text, Button } = $.ui.resolve(e);
646 sights = sightDraw(sights, roster, Date.now());
647 // Docked beside the transcript the pane has a separator only, so it gets
648 // a frame; inline above the prompt the host draws a border already.
649 const framed = e.props.placement === 'dock';
650 const width = Math.max(1, e.props.bodyColumns - (framed ? FRAME_CELLS : 0));
651 const title = Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
652 Text({ bold: true, color: 'cyan', children: '◆ Cadence' }),
653 Text({ dimColor: true, wrap: 'truncate-end', children: typeof Button === 'function' ? `${NEXT_KEY} next` : '' })] });
654 const cell = (/** @type {any} */ s) => s.action === 'next' && typeof Button === 'function'
655 ? Button({ key: 'next', label: s.text, hotkey: NEXT_KEY, variant: 'primary',
656 onPress: () => { $.prompt.fill({ text: s.text, mode: 'replace' }).catch(() => {}); } })
657 : segmentText(Text, s);
658 const rows = paneView(pane.snapshot, width, roster, sights, meter)
659 .map((row) => Box({ flexDirection: 'row', children: row.map(cell) }));
660 const body = [title, Text({ children: ' ' }), ...rows];
661 return framed
662 ? Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'gray', paddingX: 1, children: body })
663 : Box({ flexDirection: 'column', children: body });
664 } catch {
665 return drawn;
666 }
667 });
668}
669cadence-core/bin/lib/git-segments.mjs 192 lines1// @ts-check
2// git-segments.mjs - the whole of what git-guard.mjs sees. It replaces an
3// 840-line shell tokenizer plus a 367-line model of git's option grammar, both
4// deleted, and it is deliberately the smaller thing.
5//
6// WHY IT IS THIS SMALL. The guard is a PreToolUse hook whose adversary is the
7// model issuing the command, not an attacker, and references/git-publish.md has always
8// conceded the rail is "a detection widener, not a security boundary": being
9// wrong here costs a prompt, never a bypass, and the sanctioned publish never
10// reaches this hook at all (it runs through the git-publish seam as a
11// subprocess). A reader that tries to predict what the shell will do has an
12// unbounded escape surface - `bash -c`, `$(...)`, backticks, `env -S`, aliases,
13// variable indirection - so every review round found another hole and every
14// patch added grammar to close it. That cost three blocking review panels in a
15// single phase, a measured V8 OOM at 280KB of input on a hook that runs on
16// EVERY Bash call, and it still left three families of live silent destruction
17// open. The escape surface does not shrink by being modelled harder.
18//
19// So this reads one thing and declines to guess at the rest: a segment counts
20// ONLY when its command word is `git`. Everything else is silent BY
21// CONSTRUCTION rather than by a rule somebody has to keep correct - and the
22// shapes that consequently go silent are written down in references/git-publish.md
23// rail 3 and in the CHANGELOG-v1-v2.md entry that removed the parser, as the accepted
24// cost rather than as an oversight.
25//
26// IT ALSO HOLDS THE SCOPE RULE: the `.planning/` walk that decides whether a
27// directory sits inside a Cadence project. git-guard applies it, read-trace and
28// subagent-trace share it, and the Cadence module (hooks/cadence-mod.mjs)
29// imports it. It lives here because git-guard.test.mjs pins git-guard's import
30// set, and this is the one dependency-free file in that set.
31//
32// The module runs with no Node globals and may import no `node:` module, so
33// nothing in this file may either. That shapes the walk two ways:
34// - The existence probe is injected. The walk is one generator that yields
35// `[dir, name]` and takes back a boolean; `planningRoot` drives it with a
36// sync probe (the hooks' `existsSync`), `planningRootAsync` with an async one
37// (the module's `$.fs.exists`). One loop, two drivers.
38// - The parent step is `parentDir`, a port of node's own `dirname`, so the
39// hooks and the module step through the same directories.
40'use strict';
41
42/** The git global options that take a SEPARATE argument. A fixed list, not a
43 * grammar: it is the only reason the scan looks past a flag at all. Without it
44 * `git -C /srv/repo push` reads `/srv/repo` as its verb and a real push goes
45 * silent. The `=`-glued spellings (`--git-dir=x`) need no entry of their own -
46 * each is one `-`-leading word, which the flag skip below already covers. */
47const GLOBAL_OPT_WITH_ARG = new Set([
48 '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env',
49]);
50
51/** `;`, a newline, `|`, `||`, `&&` and `&` each end a simple command. The
52 * two-character forms lead the alternation so they match before the
53 * single-character class can split them in half. */
54const SEPARATOR = /&&|\|\||[;|&\n]/;
55
56/**
57 * Every git verb this command runs, in order: `git add . && git push` reads
58 * `['add', 'push']`.
59 *
60 * TOTAL and LINEAR. Any input at all - a non-string, a hostile object, a
61 * megabyte of repeated separators - returns an array and never throws, because
62 * this runs on every Bash tool call and a guard that stalls or aborts is worse
63 * than one that misses. The deleted reader was neither: it was O(K x N) in
64 * memory and OOMed the hook, which fails OPEN.
65 *
66 * A segment contributes a verb only when its FIRST word is `git` (or ends in
67 * `/git`, so `/usr/bin/git push` counts). That anchor is the whole story, and
68 * it is why there is no separate deny gate any more: a verb read here came from
69 * a command word by construction, so `rg -t sh "git commit"`,
70 * `command -v git commit`, `grep git commit` and `echo "git push"` are silent
71 * rather than being detected wide and then gated back down to an ask.
72 *
73 * The cost, stated rather than hidden: an invocation reached through a wrapper,
74 * a substitution or a transparent prefix (`bash -c "git push"`, `$(git push)`,
75 * `sudo git push`, `xargs git push`, `env -S "git push"`) is NOT seen.
76 * references/git-publish.md rail 3 carries the list.
77 *
78 * @param {unknown} text the raw command string from the hook payload
79 * @returns {string[]} the verbs, in the order they appear
80 */
81export function gitVerbs(text) {
82 if (typeof text !== 'string' || !text) return [];
83
84 const verbs = [];
85 for (const segment of text.split(SEPARATOR)) {
86 const words = segment.trim().split(/\s+/).filter(Boolean);
87 const head = words[0];
88 // ANCHORED: the command word, and nothing else, admits a segment.
89 if (head !== 'git' && !(head !== undefined && head.endsWith('/git'))) continue;
90
91 for (let i = 1; i < words.length; i++) {
92 const word = words[i];
93 if (GLOBAL_OPT_WITH_ARG.has(word)) { i++; continue; } // skip it AND its argument
94 if (word.startsWith('-')) continue;
95 verbs.push(word);
96 break; // the first non-flag word is the verb; the rest are its operands
97 }
98 }
99 return verbs;
100}
101
102/**
103 * The parent of an absolute directory, as node's `path.dirname` answers it. A
104 * `/`-leading path steps the way `path.posix.dirname` does; anything else (a
105 * drive path, a UNC path) steps the way `path.win32.dirname` does, with both
106 * separators. A root answers itself, which is what ends the walk.
107 *
108 * @param {string} dir
109 * @returns {string}
110 */
111export function parentDir(dir) {
112 const posix = dir.startsWith('/');
113 const isSep = posix ? (c) => c === '/' : (c) => c === '/' || c === '\\';
114 let root = 0; // how much of the front is root, which a step never cuts into
115 if (posix) {
116 root = 1;
117 } else if (/^[A-Za-z]:/.test(dir)) {
118 root = isSep(dir[2]) ? 3 : 2;
119 } else if (isSep(dir[0])) {
120 // UNC: `\\server\share\` is the root, and a bare `\\server\share` is too.
121 const unc = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]+/.exec(dir);
122 if (unc && unc[0].length === dir.length) return dir;
123 root = unc ? unc[0].length + 1 : 1;
124 }
125
126 // Skip the trailing separators and the last name, then cut at the separator
127 // before it. Only that one separator goes: `/a//b` answers `/a/`, as node does.
128 let end = -1;
129 let named = false;
130 for (let i = dir.length - 1; i >= root; i--) {
131 if (!isSep(dir[i])) named = true;
132 else if (named) { end = i; break; }
133 }
134 if (end === -1) return root ? dir.slice(0, root) : '.';
135 if (posix && end === 1) return '//'; // posix.dirname's own quirk for `//a`
136 return dir.slice(0, end);
137}
138
139/**
140 * The walk itself. From `start` upward: a directory holding `.planning` is the
141 * project root; a directory holding `.git` first is a repo that is not Cadence's;
142 * reaching the filesystem root is nothing. Yields each `[dir, name]` it needs
143 * probed and expects the answer back through `next(boolean)`.
144 *
145 * @param {string} start
146 * @returns {Generator<[string, string], string | null, boolean>}
147 */
148function* planningWalk(start) {
149 let dir = start;
150 for (;;) {
151 if (yield [dir, '.planning']) return dir;
152 if (yield [dir, '.git']) return null; // repo root, not Cadence
153 const parent = parentDir(dir);
154 if (parent === dir) return null;
155 dir = parent;
156 }
157}
158
159/**
160 * The Cadence project root above `start`, or null. Sync driver for the hooks.
161 *
162 * @param {string} start
163 * @param {(dir: string, name: string) => boolean} has does `dir/name` exist
164 * @returns {string | null}
165 */
166export function planningRoot(start, has) {
167 const walk = planningWalk(start);
168 let step = walk.next();
169 while (!step.done) {
170 const [dir, name] = step.value;
171 step = walk.next(has(dir, name));
172 }
173 return step.value;
174}
175
176/**
177 * The same answer through an async probe. Driver for the module.
178 *
179 * @param {string} start
180 * @param {(dir: string, name: string) => Promise<boolean> | boolean} has
181 * @returns {Promise<string | null>}
182 */
183export async function planningRootAsync(start, has) {
184 const walk = planningWalk(start);
185 let step = walk.next();
186 while (!step.done) {
187 const [dir, name] = step.value;
188 step = walk.next(await has(dir, name));
189 }
190 return step.value;
191}
192cadence-core/bin/lib/state-cursor.mjs 54 lines1// @ts-check
2// state-cursor.mjs - the grammar of STATE.md's four-line cursor, read side.
3//
4// It lives apart from lib/planning-files.mjs so the Cadence module
5// (hooks/cadence-mod.mjs) can load it: a hooks module may import only relative
6// files and `claude-code`, and planning-files imports `node:fs`. The band reads
7// STATE.md through `$.fs.read` and parses it here, with the same parser
8// `planning.mjs cursor get` uses (phase 3, D-05).
9//
10// planning-files re-exports `parseCursor`, so the seam and its tests still
11// import it from there. The writer (`renderCursor`) and the status vocabulary
12// (`CURSOR_STATUSES`) stay there with the code that writes the file.
13//
14// Imports nothing and touches no Node global.
15'use strict';
16
17/**
18 * Parse the canonical 4-line cursor. Returns null when any line is missing
19 * or malformed - callers degrade, never guess.
20 * @param {string} text
21 */
22export function parseCursor(text) {
23 const m = (re) => { const r = text.match(re); return r ? r : null; };
24 const phase = m(/^Phase:\s*(\d+(?:\.\d+)?)\s+of\s+(\d+)\s+\((.+)\)\s*$/m);
25 const status = rest(text, /^Status:([^\r\n]*)/m);
26 const next = rest(text, /^Next:([^\r\n]*)/m);
27 const updated = m(/^Updated:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
28 if (!phase || !status || !next || !updated) return null;
29 return {
30 phase: Number(phase[1]), total: Number(phase[2]), name: phase[3],
31 status, next, updated: updated[1],
32 };
33}
34
35/**
36 * The rest of a `Key:` line, trimmed, or null when the line is missing or
37 * holds nothing. Greedy to the end of the line and then `trim()`, never a lazy
38 * capture before `\s*$`: that one backtracks in quadratic time on a long run of
39 * inner spaces, and the band parses STATE.md inside the host's hooks worker on
40 * every draw.
41 *
42 * The capture stops at `\r` or `\n` only, never at `.`'s edge: `.` and a
43 * multiline `$` also stop at U+2028 and U+2029, which `cursor set` accepts in a
44 * value, so a value led by one would trim to nothing. `trim()` drops them from
45 * either end, as the `\s*` of the parser before this one did.
46 * @param {string} text
47 * @param {RegExp} re one capture: everything after the colon
48 */
49function rest(text, re) {
50 const r = text.match(re);
51 const value = r ? r[1].trim() : '';
52 return value === '' ? null : value;
53}
54cadence-core/bin/lib/band.mjs 159 lines1// @ts-check
2// band.mjs - the one line the Cadence module (hooks/cadence-mod.mjs) draws
3// above the prompt: phase, status, the running Cadence agents, next command.
4//
5// No imports beyond lib/, no Node globals: a hooks module may load nothing
6// else. The module does the I/O and hands this the parsed cursor.
7//
8// Once the session has a cache break (lib/cache-meter.mjs), the count follows
9// the status, so it is seen before the running list or next is.
10//
11// The line never wraps and never runs past the width it is given. Too long,
12// the running list shrinks to its first agent plus a count, then the end of
13// the line is cut with an ellipsis.
14//
15// The running list is a roster: a plain array of `{id, role, rung}`, oldest
16// first, moved only by the three pure transitions below (D-07). The module
17// holds the current value and swaps in what each transition returns. A start
18// or stop event can be missed (a subagent spawned before the module loaded, a
19// stop the host never delivered), so the module reconciles against
20// `$.agent.list()` before each draw. Role and rung come from RUNG_FILES through
21// `roleOfAgent` and `rungOfAgent`, never from a `-<rung>` suffix.
22'use strict';
23
24import { breaksText } from './cache-meter.mjs';
25import { roleOfAgent, rungOfAgent } from './rung-agent.mjs';
26
27/** The line when STATE.md is missing or does not parse (phase 3, D-06). */
28export const NO_CURSOR_LINE = 'Cadence · no readable cursor · run /cad-progress';
29
30/**
31 * The status the band and the pane draw: `executing` while a Cadence executor
32 * runs on a `planned` phase, the status as given otherwise. Display only: no
33 * cursor or derived status is ever `executing`, since a value outside
34 * planning.mjs's AGREE map reads as drift. An executor `/cad-task --plan`
35 * dispatches shows the same way, because the roster cannot tell them apart.
36 * @param {string} status
37 * @param {readonly {role: string}[]} running
38 * @returns {string}
39 */
40export function shownStatus(status, running) {
41 return status === 'planned' && running.some((a) => a.role === 'cad-executor') ? 'executing' : status;
42}
43
44/**
45 * @param {{phase: number, total: number, status: string, next: string} | null} cursor
46 * @param {readonly {role: string, rung: string}[]} running
47 * @param {number} width cells the line may take
48 * @param {number} [breaks] the session's cache breaks
49 * @returns {string}
50 */
51export function bandLine(cursor, running, width, breaks = 0) {
52 const flag = breaks > 0 ? ` · ${breaksText(breaks)}` : '';
53 if (!cursor) return fit(NO_CURSOR_LINE + flag, width);
54 const head = `Cadence · Phase ${cursor.phase} of ${cursor.total} · ${visible(shownStatus(cursor.status, running))}${flag}`;
55 const tail = ` · next ${visible(cursor.next)}`;
56 const names = running.map((a) => `${a.role} (${a.rung})`);
57 let line = head + (names.length ? ` · running ${names.join(', ')}` : '') + tail;
58 if (names.length > 1 && cells(line) > width) {
59 line = `${head} · running ${names[0]} +${names.length - 1}${tail}`;
60 }
61 return fit(line, width);
62}
63
64/**
65 * STATE.md text with every control character shown as `?`. The host refuses a
66 * whole AbovePrompt tree when any text child holds one (C0, DEL, C1, and so
67 * tab, CR and LF too), which would wipe out every other mod's drawing beside
68 * the band. `cursor get` still answers the text as written.
69 * @param {string} s
70 */
71function visible(s) {
72 return String(s).replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
73}
74
75/** @param {string} s */
76function cells(s) {
77 return Array.from(s).length;
78}
79
80/**
81 * Cut at the width, ending in `…`. Counted by code point, so a cut never
82 * splits a surrogate pair.
83 * @param {string} line
84 * @param {number} width
85 */
86function fit(line, width) {
87 const chars = Array.from(line);
88 if (chars.length <= width) return line;
89 if (!(width >= 1)) return '';
90 return chars.slice(0, width - 1).join('') + '…';
91}
92
93/**
94 * @typedef {{id: string, role: string, rung: string}} RosterEntry
95 * @typedef {readonly RosterEntry[]} Roster
96 */
97
98/**
99 * The entry a Cadence agent type gets, or null for any type RUNG_FILES does not
100 * file (the host's own `general-purpose`, `Explore`, a fork).
101 * @param {unknown} id
102 * @param {unknown} type
103 * @returns {RosterEntry | null}
104 */
105function entry(id, type) {
106 const role = roleOfAgent(type);
107 const rung = rungOfAgent(type);
108 if (typeof id !== 'string' || id === '' || role === null || rung === null) return null;
109 return { id, role, rung };
110}
111
112/**
113 * A `classic.SubagentStart` input joins the roster when it is a Cadence agent
114 * of THIS session. Anything else answers the roster unchanged.
115 * @param {Roster} roster
116 * @param {{session_id?: unknown, agent_id?: unknown, agent_type?: unknown}} start
117 * @param {string} sessionId this session's id, `$.session.id()`
118 * @returns {Roster}
119 */
120export function rosterStart(roster, start, sessionId) {
121 if (!start || start.session_id !== sessionId) return roster;
122 const added = entry(start.agent_id, start.agent_type);
123 if (added === null || roster.some((a) => a.id === added.id)) return roster;
124 return [...roster, added];
125}
126
127/**
128 * A `classic.SubagentStop` drops the agent with that id, if the roster holds it.
129 * @param {Roster} roster
130 * @param {unknown} agentId
131 * @returns {Roster}
132 */
133export function rosterStop(roster, agentId) {
134 return roster.some((a) => a.id === agentId) ? roster.filter((a) => a.id !== agentId) : roster;
135}
136
137/**
138 * The roster `$.agent.list()` says is true: exactly the Cadence agents it shows
139 * `running`. Ones the roster already held keep their place; ones it missed join
140 * at the end, in the list's order. The list holds this session's agents only,
141 * so no session check is needed here.
142 * @param {Roster} roster
143 * @param {unknown} list `$.agent.list()`'s answer: `{id, type, status}` entries
144 * @returns {Roster}
145 */
146export function rosterReconcile(roster, list) {
147 if (!Array.isArray(list)) return roster;
148 /** @type {Map<string, RosterEntry>} */
149 const running = new Map();
150 for (const a of list) {
151 if (!a || a.status !== 'running') continue;
152 const found = entry(a.id, a.type);
153 if (found !== null) running.set(found.id, found);
154 }
155 const kept = roster.filter((a) => running.has(a.id));
156 const held = new Set(kept.map((a) => a.id));
157 return [...kept, ...[...running.values()].filter((a) => !held.has(a.id))];
158}
159cadence-core/bin/lib/rung-agent.mjs 608 lines1// @ts-check
2// rung-agent.mjs - the ONE statement of which agent FILE carries which rung of
3// which role, imported by route.mjs (which resolves a cell's rung to an agent
4// name), through lib/route-cells.mjs by self-verify.mjs (which proves every
5// name the grids can produce exists on disk), and through `roleOfAgent` and
6// `rungOfAgent` by lib/read-trace.mjs, lib/subagent-trace.mjs and the Cadence
7// module, hooks/cadence-mod.mjs (which names running agents in its band).
8// Spelling the map twice is exactly the resolved-then-silently-wrong class this
9// repo keeps closing (#39, #43, #64): route.mjs would name a file the linter
10// never looked for.
11//
12// RUNG_FILES is the whole mapping story - a stated table, not a naming
13// convention, because no convention is true of all 30 files.
14// `rungBody`/`normalizeBody`/`rungBodyIssue` beside it state the one legitimate
15// BODY of a rung file, and `rungPrefixIssues` states that one role's rung files
16// all carry it byte for byte, for the same single-source reason.
17//
18// Pure lib: no fs, no emit, no process, no Date, no randomness. It returns
19// names and problem CODES; the callers own the envelope - route.mjs decides
20// what an unmapped rung means for a dispatch (nothing: it fails open), and
21// self-verify.mjs decides what it means for CI (a problem entry).
22'use strict';
23
24/**
25 * The rung ladder, weakest first - the whole vocabulary of rung names, and the
26 * order `rungFiles` returns a role's files in. It lived in the routing data
27 * table until the stakes level was deleted and that table with it; it now
28 * belongs beside RUNG_FILES because the two are the two halves of ONE
29 * statement - the ladder names the rungs, the map says which file carries each -
30 * and `rungOrderIssues` below is what holds them together now that no data file
31 * does.
32 * @type {readonly string[]}
33 */
34export const RUNG_ORDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
35
36/**
37 * The rung -> agent-file map, stated per role rather than derived (D-05). The
38 * unsuffixed `agents/<role>.md` is one rung among the others, and nothing about
39 * a rung's NAME says which file carries it:
40 * `cad-assumptions-analyzer` is the `xhigh` rung while its `-high` sibling is
41 * the lower one, so any convention would have to lie about one of them. The
42 * alternative was renaming five of six files to make a convention true, which
43 * invalidates every one of their exact-fit weight budgets and buys nothing a
44 * reader of this table cannot already see.
45 *
46 * Each role's rungs are listed in RUNG_ORDER (low -> max), which is the order
47 * `rungFiles` returns them in. Frozen: this is a statement of what is on disk,
48 * and a caller mutating it would make route.mjs and self-verify disagree about
49 * the same question.
50 * @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
51 */
52export const RUNG_FILES = Object.freeze({
53 'cad-planner': Object.freeze({
54 low: 'cad-planner-low',
55 medium: 'cad-planner-medium',
56 high: 'cad-planner',
57 xhigh: 'cad-planner-xhigh',
58 max: 'cad-planner-max',
59 }),
60 'cad-assumptions-analyzer': Object.freeze({
61 low: 'cad-assumptions-analyzer-low',
62 medium: 'cad-assumptions-analyzer-medium',
63 high: 'cad-assumptions-analyzer-high',
64 xhigh: 'cad-assumptions-analyzer',
65 max: 'cad-assumptions-analyzer-max',
66 }),
67 'cad-verifier': Object.freeze({
68 low: 'cad-verifier-low',
69 medium: 'cad-verifier-medium',
70 high: 'cad-verifier',
71 xhigh: 'cad-verifier-xhigh',
72 max: 'cad-verifier-max',
73 }),
74 'cad-reviewer': Object.freeze({
75 low: 'cad-reviewer-low',
76 medium: 'cad-reviewer-medium',
77 high: 'cad-reviewer',
78 xhigh: 'cad-reviewer-xhigh',
79 max: 'cad-reviewer-max',
80 }),
81 'cad-executor': Object.freeze({
82 low: 'cad-executor-low',
83 medium: 'cad-executor-medium',
84 high: 'cad-executor',
85 xhigh: 'cad-executor-xhigh',
86 max: 'cad-executor-max',
87 }),
88 'cad-plan-checker': Object.freeze({
89 low: 'cad-plan-checker',
90 medium: 'cad-plan-checker-medium',
91 high: 'cad-plan-checker-high',
92 xhigh: 'cad-plan-checker-xhigh',
93 max: 'cad-plan-checker-max',
94 }),
95});
96
97/**
98 * Whether the map and the ladder still say the same thing: every role's rung
99 * keys are exactly RUNG_ORDER, in that order.
100 *
101 * The routing data table used to hold the ladder and self-verify held the cells
102 * against it, so a rung the map dropped could not be named by any cell. With
103 * that table gone the two exports above are the only statement left, and nothing
104 * else compares them: a role missing `xhigh` would resolve `rungFile` to null
105 * and route.mjs would fail open, while a role whose keys were REORDERED would
106 * hand `rungFiles` and `rungPrefixIssues` a tie-break order that is not the
107 * ladder's - both silent.
108 *
109 * Takes the map so a caller can hold a drifted one against the ladder; defaults
110 * to the shipped map, which is the only one that exists today.
111 *
112 * @param {any} [files] the rung map to check, stem values unread
113 * @param {any} [order] the ladder to check it against, weakest first
114 * @returns {{code: string, role: string, detail: string}[]}
115 */
116export function rungOrderIssues(files, order) {
117 const map = files !== undefined && files !== null ? files : RUNG_FILES;
118 const ladder = Array.isArray(order) ? order : RUNG_ORDER;
119 /** @type {{code: string, role: string, detail: string}[]} */
120 const out = [];
121 const read = map !== null && typeof map === 'object' && !Array.isArray(map) ? map : {};
122 for (const role of Object.keys(read)) {
123 const rungs = read[role];
124 const got = rungs !== null && typeof rungs === 'object' && !Array.isArray(rungs)
125 ? Object.keys(rungs) : [];
126 if (got.length === ladder.length && ladder.every((r, i) => got[i] === r)) continue;
127 out.push({ code: 'rung-order-drift', role,
128 detail: `${role} files rungs ${JSON.stringify(got)}, but the rung ladder is ${
129 JSON.stringify([...ladder])} - the map and RUNG_ORDER are one statement `
130 + 'and nothing else holds them together' });
131 }
132 return out;
133}
134
135/**
136 * The agent-file stem for one rung of one role, or null when the pair is not
137 * in the map. Null rather than a guessed `<role>-<rung>`: a guess names a file
138 * that does not exist and reads as a real answer, while null is a fact the
139 * caller can act on - route.mjs degrades the dispatch and says so, self-verify
140 * files a problem.
141 * @param {string} role
142 * @param {string} rung
143 * @returns {string|null}
144 */
145export function rungFile(role, rung) {
146 const map = typeof role === 'string' ? RUNG_FILES[role] : undefined;
147 if (!map || typeof rung !== 'string') return null;
148 return Object.prototype.hasOwnProperty.call(map, rung) ? map[rung] : null;
149}
150
151/**
152 * Every agent-file stem one role's map names, in declared rung order. An
153 * unknown role yields an empty array rather than throwing - self-verify calls
154 * this on a table it has not validated yet.
155 * @param {string} role
156 * @returns {string[]}
157 */
158export function rungFiles(role) {
159 const map = typeof role === 'string' ? RUNG_FILES[role] : undefined;
160 return map ? Object.values(map) : [];
161}
162
163// The reverse lookups: a recorded `agent_type` back to the role and rung it is
164// filed under. Built off RUNG_FILES rather than a `-<rung>` suffix regex, which
165// would be a SECOND statement of the mapping: `cad-assumptions-analyzer` is that
166// role's `xhigh` rung while `cad-assumptions-analyzer-high` is its lower one, so
167// no suffix convention is true of all 30 files, and a rung added to the table
168// but not to the regex would leave the answer silently wrong.
169
170/** Every rung file's stem, mapped back to the role whose rung it is. */
171const ROLE_OF_STEM = new Map(
172 Object.keys(RUNG_FILES).flatMap(
173 (role) => Object.values(RUNG_FILES[role]).map((stem) => [stem, role]),
174 ),
175);
176
177/**
178 * The same 30 stems, mapped back to the RUNG each one is filed under. Built off
179 * the SAME import in the same shape as `ROLE_OF_STEM`, because the two answers
180 * are two columns of one table: a rung added to `RUNG_FILES` reaches both maps
181 * or neither, and neither can go stale while the other does not.
182 */
183const RUNG_OF_STEM = new Map(
184 Object.keys(RUNG_FILES).flatMap(
185 (role) => Object.entries(RUNG_FILES[role]).map(([rung, stem]) => [stem, rung]),
186 ),
187);
188
189/**
190 * The agent-file stem inside a recorded `agent` value - the host writes
191 * `<plugin>:<agent-file-stem>` and a bare stem is accepted as itself.
192 *
193 * ONE copy of the split, called by both readers below. A second copy is how the
194 * role answer and the rung answer start disagreeing about which file a spelling
195 * names, and `helper-census.test.mjs` matches shared-contract BODY idioms
196 * precisely so a paste-back under another name is caught rather than noticed.
197 * @param {any} agent
198 * @returns {string|null} null for anything that is not a non-empty string.
199 */
200function stemOfAgent(agent) {
201 if (typeof agent !== 'string' || !agent) return null;
202 return agent.includes(':') ? agent.slice(agent.indexOf(':') + 1) : agent;
203}
204
205/**
206 * The role a recorded `agent` value names, or null when it names none.
207 *
208 * The corpus carries `cadence:cad-executor`, `cadence:cad-planner`,
209 * `cadence:cad-verifier-medium` and `cadence:cad-assumptions-analyzer-high` -
210 * the host's `<plugin>:<agent-file-stem>` spelling - while a dispatch event
211 * carries the bare ROLE. Null for anything else, including the host types and
212 * `coordinator`, so the caller decides what each absence means rather than
213 * having one of them silently become a role.
214 *
215 * EXPORTED for `lib/read-trace.mjs`'s join, for `lib/subagent-trace.mjs`, whose
216 * `SubagentStop` self-filter asks the same question of the same spelling (both
217 * through read-trace's re-export), and for the band in `hooks/cadence-mod.mjs`.
218 * They import this rather than holding a copy: two readers of one record
219 * deriving the role independently is how they start disagreeing about which
220 * bracket closed.
221 * @param {any} agent
222 * @returns {string|null}
223 */
224export function roleOfAgent(agent) {
225 const stem = stemOfAgent(agent);
226 if (stem === null) return null;
227 return ROLE_OF_STEM.get(stem) || null;
228}
229
230/**
231 * The RUNG a recorded `agent` value names, or null when it names none.
232 *
233 * The sibling of `roleOfAgent` over the same spelling and the same table:
234 * `cadence:cad-verifier-medium` is the `cad-verifier` role at its `medium`
235 * rung, so the two functions answer the two halves of one lookup. Null for
236 * anything `RUNG_FILES` does not file - the host's own types, `coordinator`, a
237 * non-string - so the caller decides what the absence means.
238 *
239 * NEVER derived from a `-<rung>` filename suffix, for the reason stated above
240 * `ROLE_OF_STEM`: `cad-assumptions-analyzer` is that role's
241 * `xhigh` rung while `cad-assumptions-analyzer-high` is its lower one, so no
242 * suffix convention is true of all 30 files and a suffix rule would report the
243 * wrong rung for the unsuffixed file of every role.
244 *
245 * EXPORTED for `lib/subagent-trace.mjs`, whose `SubagentStop` close records the
246 * rung a worker was DISPATCHED under beside the effort its own transcript says
247 * it RAN at - the pair the run record exists to let a reader compare - and for
248 * the band, which names the rung of each running Cadence agent.
249 * @param {any} agent
250 * @returns {string|null}
251 */
252export function rungOfAgent(agent) {
253 const stem = stemOfAgent(agent);
254 if (stem === null) return null;
255 return RUNG_OF_STEM.get(stem) || null;
256}
257
258/**
259 * Whether one role's rung files still share ONE body, byte for byte (RNG-03).
260 *
261 * A role's rungs are separate registered agents whose bodies are assembled
262 * into separate prompts, and a prompt cache can only reuse a prefix that is
263 * identical from its first byte. The rung sentence used to make that
264 * impossible by construction - every rung file opened with a different line -
265 * and deleting it bought a shared prefix that nothing then held. This is what
266 * holds it: an edit landing in one rung file and not its siblings re-forecloses
267 * the sharing, and it is invisible to every other check, because each file on
268 * its own is still a perfectly legal rung file.
269 *
270 * RAW BYTES, deliberately, and this is the one place in this lib where
271 * whitespace is load-bearing (D-04). `rungBodyIssue` normalizes whitespace away
272 * so that re-wrapping a paragraph is free - which is right for "does this file
273 * carry behaviour of its own" and exactly wrong here, since two line-break
274 * variants are two different cache prefixes. Re-wrapping ONE rung file and not
275 * its siblings is precisely the edit this rule exists to catch, so the two
276 * rules are not duplicates: they disagree about that edit on purpose.
277 *
278 * Scoped by RUNG_FILES: a stem the map does not name is not this rule's
279 * business (check 8's reachability arm owns stale and unreachable files), and
280 * a role contributing fewer than two bodies yields nothing - an absent file is
281 * already `missing-rung-agent`'s to report, and a second entry would
282 * double-count one fault.
283 *
284 * The majority body is the rank and the minority is what broke it, ties going
285 * to whichever group holds the earliest-declared rung, so the detail names the
286 * FILE a maintainer would open rather than every file in the role.
287 *
288 * @param {any} bodies stem -> that file's raw prose, frontmatter already
289 * stripped; entries whose value is not a string are treated as absent
290 * @returns {{code: string, role: string, stems: string[], detail: string}[]}
291 */
292export function rungPrefixIssues(bodies) {
293 const read = bodies !== null && typeof bodies === 'object' && !Array.isArray(bodies)
294 ? bodies : {};
295 const bodyOf = (stem) => (Object.prototype.hasOwnProperty.call(read, stem)
296 && typeof read[stem] === 'string' ? read[stem] : null);
297
298 /** @type {{code: string, role: string, stems: string[], detail: string}[]} */
299 const out = [];
300 for (const role of Object.keys(RUNG_FILES)) {
301 // Declared rung order (low -> max), which is what makes the tie-break and
302 // the listed order below reproducible rather than filesystem-dependent.
303 const stems = Object.values(RUNG_FILES[role]).filter((s) => bodyOf(s) !== null);
304 if (stems.length < 2) continue;
305
306 /** @type {Map<string, string[]>} */
307 const groups = new Map();
308 for (const stem of stems) {
309 const body = /** @type {string} */ (bodyOf(stem));
310 const seen = groups.get(body);
311 if (seen) seen.push(stem);
312 else groups.set(body, [stem]);
313 }
314 if (groups.size === 1) continue;
315
316 // Insertion order IS declared rung order, and `>` is strict, so a tie
317 // leaves the earliest-declared group as the rank.
318 let rank = [];
319 for (const members of groups.values()) {
320 if (members.length > rank.length) rank = members;
321 }
322 const strays = stems.filter((s) => !rank.includes(s));
323 const name = (s) => `agents/${s}.md`;
324 out.push({ code: 'rung-prefix-split', role, stems: strays,
325 detail: `${strays.map(name).join(', ')} ${strays.length === 1 ? 'does' : 'do'} not carry `
326 + `the same body BYTE FOR BYTE as ${rank.map(name).join(', ')} - `
327 + `${role}'s rungs are dispatched as separate agents and share a cached prefix `
328 + 'only while their bodies are identical, so this edit has to land in every '
329 + `rung file of ${role} or in none` });
330 }
331 return out;
332}
333
334/**
335 * The canonical BODY of a rung agent file: a pointer at the contract it
336 * preloads, and nothing else. Stated here rather than inside self-verify for
337 * the same reason the name mapping is - the check and the files it checks must
338 * read ONE source, or they drift and the linter blesses the drift.
339 *
340 * It names NO rung, and that is the point (RNG-03). The body used to open
341 * ``Your rung is `high`.``, which put a per-rung token at body line 1 and gave
342 * every rung file of one role a different prefix from its first character - so
343 * two rungs of the same role could share no cached prefix at all, however
344 * identical the rest. The rung was never lost by deleting it: the frontmatter
345 * `effort:` is what the host actually reads and what `rungEffortIssue` holds
346 * against this map. A role whose CONTRACT branches on the rung takes it from
347 * its dispatch prompt, which is billed fresh and costs no prefix.
348 * @param {string} skill the contract skill the file preloads
349 * @returns {string}
350 */
351export function rungBody(skill) {
352 return `Follow the preloaded \`${skill}\` skill exactly - it is your full\n`
353 + 'contract. This file names that contract and adds nothing else.\n';
354}
355
356/**
357 * A body in whitespace-insensitive form, so re-wrapping a paragraph is free
358 * and only a REWORD counts as a change. Comparing raw text would make the
359 * line breaks load-bearing - a CI failure with no fix a maintainer would
360 * think of.
361 * @param {string} text
362 * @returns {string}
363 */
364export function normalizeBody(text) {
365 return String(text === undefined || text === null ? '' : text).replace(/\s+/g, ' ').trim();
366}
367
368/**
369 * Whether a rung file's body is anything other than the canonical template.
370 *
371 * An ALLOWLIST, deliberately, and this is the second attempt at the rule.
372 * D-04 rejected a size-only check because a 200-byte behavioural instruction
373 * fits under any weight budget - but so does a 200-byte instruction carrying
374 * no contract section tag, so the tag denylist it chose instead had the same
375 * hole: a rung file whose whole body is plain prose passed CI. A rung file has
376 * exactly ONE legitimate body, so "is it that body" is the only rule that
377 * matches what INTERNALS.md:11 claims - it refuses a rung file carrying any
378 * instruction of its own, including a same-size REPLACEMENT of the pointer
379 * paragraph, which no byte budget can see.
380 *
381 * The tag denylist stays in front of this in self-verify: when a body DOES
382 * carry `<process>`, naming the tag is the more actionable message.
383 *
384 * A file declaring several skills passes if its body points at any ONE of
385 * them - the template names a single contract, and nothing here rules out a
386 * future multi-contract agent.
387 *
388 * The template no longer names a rung, so this rule no longer holds a body
389 * against its own frontmatter `effort:` (RNG-03). That arm is gone, not
390 * bypassed, and it was the redundant one: `rungEffortIssue` below holds the
391 * file's `effort:` against the rung RUNG_FILES filed it under, which is the
392 * link that decides how deep a dispatch actually thinks.
393 *
394 * @param {string} body the agent file's prose, frontmatter already stripped
395 * @param {string[]} [skills] the file's declared `skills:` entries
396 * @returns {null|{detail: string}} null when the body IS the template
397 */
398export function rungBodyIssue(body, skills) {
399 const found = normalizeBody(body);
400 const declared = (Array.isArray(skills) ? skills : [])
401 .filter((s) => typeof s === 'string' && s);
402 const names = declared.length ? declared : ['<contract>'];
403 const wanted = names.map((s) => normalizeBody(rungBody(s)));
404 if (wanted.includes(found)) return null;
405 return { detail: `body is not the rung template - expected exactly ${JSON.stringify(wanted[0])}` };
406}
407
408/** The config-key prefix every per-role start rung is written under. */
409export const EFFORT_PREFIX = 'model.effort.';
410
411/**
412 * The roles-block spelling of the same quantity, as its two fixed halves:
413 * `roles.<role>.effort`. It is a SECOND key naming one role's start rung, not a
414 * rename - the older prefix above stays live as the narrower fallback - so both
415 * spellings are held to the rules below rather than one of them being trusted.
416 */
417export const ROLES_PREFIX = 'roles.';
418/** The last segment that makes a `roles.<role>.*` key a start rung. */
419export const ROLES_EFFORT_SUFFIX = '.effort';
420
421/**
422 * The two keys one role's start rung can be written under, older spelling
423 * first - which is also the order the issues below come out in, so a drift in
424 * the older key still reports before its roles-block sibling.
425 * @param {string} role
426 * @returns {string[]}
427 */
428function effortKeyNames(role) {
429 return [`${EFFORT_PREFIX}${role}`, `${ROLES_PREFIX}${role}${ROLES_EFFORT_SUFFIX}`];
430}
431
432/**
433 * The role a start-rung key names, or null when the key is neither spelling.
434 *
435 * `roles.<role>.model` is deliberately NOT one: D-10 types it `string_or_null`
436 * with nothing to drift against, so classifying it here would file a drift
437 * issue about a key that has no enum to drift.
438 * @param {string} key
439 * @returns {string|null}
440 */
441function effortKeyRole(key) {
442 if (key.startsWith(EFFORT_PREFIX)) return key.slice(EFFORT_PREFIX.length);
443 if (key.startsWith(ROLES_PREFIX) && key.endsWith(ROLES_EFFORT_SUFFIX)) {
444 return key.slice(ROLES_PREFIX.length, key.length - ROLES_EFFORT_SUFFIX.length);
445 }
446 return null;
447}
448
449/**
450 * Whether the shipped `model.effort.<role>` and `roles.<role>.effort` schema
451 * enums still say what RUNG_FILES says. It belongs beside the map because the
452 * map is the statement it checks against, and because the refusal it protects
453 * is one a USER meets:
454 * `config.mjs` refuses a start rung by key off these enums, so an enum that
455 * drifts from the map starts refusing the wrong values - accepting a rung with
456 * no file (which route.mjs then has to warn its way out of) or refusing one
457 * this role really has.
458 *
459 * BOTH spellings, to the same rules and with no shared-enum shortcut: the two
460 * keys are separate schema rows and `checkValue` reads whichever one the user
461 * typed, so a guard that checked only the older prefix would leave the winning
462 * key - the roles block beats `model.effort.<role>` - the unguarded one.
463 *
464 * The DEFAULT is checked on the roles spelling ALONE. Since the cells grid went,
465 * `roles.<role>.effort`'s default is the rung a project with no config resolves
466 * at, so a default naming no rung of this role leaves nothing to dispatch;
467 * `model.effort.<role>` defaults to null on purpose, meaning "this key does not
468 * answer", and refusing that would refuse the fall-through itself.
469 *
470 * self-verify never reads a user's config and so cannot refuse a user's value;
471 * this is its half of that criterion (D-08), which is why every detail NAMES
472 * THE KEY a maintainer would edit.
473 *
474 * `rungOrder` is the caller's rung vocabulary - RUNG_ORDER above, handed in
475 * rather than read here so a caller can hold a drifted ladder against these
476 * enums. An empty or absent one skips the vocabulary arm ALONE, the way
477 * `cellIssues` tolerates an absent vocabulary - the schema-vs-map proof must
478 * still run when the ladder is unavailable, which is where a drifted enum is
479 * likeliest and least noticed.
480 *
481 * @param {any} schema the `keys` map of config.schema.json, trusted for nothing
482 * @param {any} [rungOrder] the declared rung vocabulary, lowest first
483 * @returns {{code: string, detail: string}[]}
484 */
485export function effortEnumIssues(schema, rungOrder) {
486 /** @type {{code: string, detail: string}[]} */
487 const out = [];
488 const keys = schema !== null && typeof schema === 'object' && !Array.isArray(schema)
489 ? schema : {};
490 const order = Array.isArray(rungOrder) ? rungOrder.filter((r) => typeof r === 'string') : [];
491
492 for (const role of Object.keys(RUNG_FILES)) {
493 for (const key of effortKeyNames(role)) {
494 const spec = keys[key];
495 if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
496 out.push({ code: 'missing-effort-key',
497 detail: `${key} is absent, but lib/rung-agent.mjs files ${
498 Object.keys(RUNG_FILES[role]).length} rungs for ${role}` });
499 continue;
500 }
501 // Type BEFORE values: `checkValue` enforces an enum's `values` only when
502 // `type` IS "enum", so a key whose type drifted to "string" keeps a correct
503 // values list while the write face silently accepts any rung - the exact
504 // accepting-a-rung-with-no-file drift this function exists to refuse.
505 if (spec.type !== 'enum') {
506 out.push({ code: 'effort-enum-drift',
507 detail: `${key} has type ${JSON.stringify(spec.type)}, must be "enum" - `
508 + 'a non-enum type disables the write-face refusal' });
509 continue;
510 }
511 // The map's rungs in DECLARED order, then null - the exact shape D-03 ships,
512 // so a reordered enum reads as drift too: the order is what a reader of the
513 // refusal message sees, and it is meant to be the ladder's own order.
514 const want = [...Object.keys(RUNG_FILES[role]), null];
515 const got = Array.isArray(spec.values) ? spec.values : null;
516 if (!got || got.length !== want.length || want.some((v, i) => got[i] !== v)) {
517 out.push({ code: 'effort-enum-drift',
518 detail: `${key} holds ${JSON.stringify(got)}, but lib/rung-agent.mjs files ${
519 role} at ${JSON.stringify(want)}` });
520 continue;
521 }
522 // The DEFAULT half, and only for the roles spelling. `roles.<role>.effort`
523 // is what answers when no layer names a rung, so its default has to name a
524 // rung this role has a FILE for: a null or stray default hands `agentFor`
525 // nothing, and the dispatch falls open to the unsuffixed file while the
526 // resolve still reports a rung - the report-a-rung-nothing-ran-at shape
527 // `rungEffortIssue` exists to close, reached one door out.
528 //
529 // `model.effort.<role>` is EXEMPT and its null default is correct: null
530 // there means the key does not answer, and the roles row's own default
531 // decides. Checking both would refuse the very fall-through the two-key
532 // precedence is built on.
533 if (key.startsWith(ROLES_PREFIX)) {
534 const def = spec.default;
535 if (typeof def !== 'string'
536 || !Object.prototype.hasOwnProperty.call(RUNG_FILES[role], def)) {
537 out.push({ code: 'effort-default-invalid',
538 detail: `${key} defaults to ${def === undefined ? '(absent)' : JSON.stringify(def)}, `
539 + `which is not one of ${role}'s rungs (${Object.keys(RUNG_FILES[role]).join(', ')}) `
540 + '- this default IS the rung route.mjs resolves when no layer sets one' });
541 }
542 }
543
544 if (!order.length) continue;
545 const strays = want.filter((v) => v !== null && !order.includes(v));
546 if (strays.length) {
547 out.push({ code: 'effort-enum-drift',
548 detail: `${key} offers ${JSON.stringify(strays)}, which the rung ladder `
549 + `(${order.join(', ')}) does not carry` });
550 }
551 }
552 }
553
554 for (const key of Object.keys(keys)) {
555 const role = effortKeyRole(key);
556 if (role === null) continue;
557 if (Object.prototype.hasOwnProperty.call(RUNG_FILES, role)) continue;
558 out.push({ code: 'unknown-effort-role',
559 detail: `${key} names "${role}", which lib/rung-agent.mjs files no rungs for `
560 + `(${Object.keys(RUNG_FILES).join(', ')})` });
561 }
562 return out;
563}
564
565/**
566 * Whether the file a rung is filed under carries a DIFFERENT effort than that
567 * rung. The third link in the chain, and the one that was open.
568 *
569 * A cell states a rung, RUNG_FILES turns it into a file NAME, and the dispatch
570 * carries only that name - so the depth that actually runs is the `effort` in
571 * that file's frontmatter, and since RNG-03 deleted the rung sentence from the
572 * body this is the ONLY rule that reads that field against anything. Check 8's
573 * reachability arm reads the rung out of the FILENAME rather than out of the
574 * file, and `rungBodyIssue` held a file's body against its OWN frontmatter, so
575 * a file that was internally consistent and externally wrong passed it
576 * anyway - which is why losing that arm loses no coverage this one has, and
577 * why this one may not be weakened. Leave the gap and a config layer can
578 * name `xhigh`, this map
579 * can resolve it to a file carrying `effort: high`, and the resolver's JSON,
580 * the transcript's `subagent_type` and the escalation `reason` all report
581 * `xhigh` while nothing ran at it. Subagent turns record no effort anywhere,
582 * so no observable downstream disagrees either - it is unfalsifiable outside
583 * the file. It is also the same invariant CI already holds against the table,
584 * where a retry rung may not sit below the rung it started on; this holds it
585 * against the filesystem, so a rung cannot think less while every surface
586 * reports that it thought more.
587 *
588 * A stem this map does not name is not this rule's business - check 8's
589 * reachability arm owns stale and unreachable files - and returns null.
590 *
591 * @param {string} stem the agent file's basename without `.md`
592 * @param {string} [effort] the file's frontmatter `effort`
593 * @returns {null|{role: string, rung: string, detail: string}} null when they agree
594 */
595export function rungEffortIssue(stem, effort) {
596 for (const role of Object.keys(RUNG_FILES)) {
597 const map = RUNG_FILES[role];
598 for (const rung of Object.keys(map)) {
599 if (map[rung] !== stem) continue;
600 if (effort === rung) return null;
601 const found = effort === undefined ? 'carries no effort' : `carries effort: ${effort}`;
602 return { role, rung,
603 detail: `lib/rung-agent.mjs files this as ${role}'s ${rung} rung, but it ${found}` };
604 }
605 }
606 return null;
607}
608cadence-core/bin/lib/agent-prefix.mjs 56 lines1// @ts-check
2// agent-prefix.mjs - the rule that gives a bare Cadence agent stem its plugin
3// prefix at dispatch, as a safety net (phase 5, D-07). The Cadence module
4// (hooks/cadence-mod.mjs) applies it to an Agent `tool.call`.
5//
6// Cadence's commands dispatch the `agent_type` route.mjs returns, which
7// already carries the plugin's prefix (`cadence:cad-planner`), because a bare
8// stem belongs to whoever owns that bare name. This rewrite catches the calls
9// that still go out bare - route's `{ok:false}` arm dispatches the base stem,
10// and a model can drop the prefix - since the host resolves only the prefixed
11// name and the listing filter took away the line the model used to read it off
12// (spike agent-offer-dispatch, criteria 8 and 9).
13//
14// Only a value that is exactly one of the stems RUNG_FILES files is rewritten,
15// and never one a non-`plugin` `agent.offer` named: a project or user agent
16// called `cad-reviewer` is the user's, and it goes through as sent. So a
17// user's own unnamespaced `cad-<x>` agent, a name that already carries a
18// prefix, and the host's own types all go through as sent, for every name.
19//
20// Its one import is lib/rung-agent.mjs, which imports nothing; no Node globals,
21// because a hooks module may load only relative dependency-free files. The
22// stem lookup is rung-agent's own `roleOfAgent`, not a second copy of the map.
23'use strict';
24
25import { roleOfAgent } from './rung-agent.mjs';
26
27/**
28 * The bare agent name an `agent.offer` says someone other than a plugin owns,
29 * or null. Keyed on `source` alone - `projectSettings`, `userSettings`, or any
30 * other non-`plugin` source - never on `provider.plugin`. Reads the event's
31 * getters, so the caller wraps it: a throw must record nothing.
32 * @param {any} offer the `agent.offer` input
33 * @returns {string | null}
34 */
35export function ownedAgent(offer) {
36 const { agent, source } = offer;
37 if (typeof agent !== 'string' || agent === '' || agent.includes(':')) return null;
38 return typeof source === 'string' && source !== 'plugin' ? agent : null;
39}
40
41/**
42 * The `subagent_type` to dispatch: `<plugin>:<value>` when `value` is exactly
43 * one of Cadence's agent stems, `plugin` is a non-empty string, and `owned`
44 * does not hold `value`; null when the call stays as sent.
45 * @param {unknown} value the Agent call's `subagent_type`
46 * @param {unknown} plugin the plugin's own name, from plugin.json
47 * @param {ReadonlySet<string>} [owned] bare names a non-plugin offer named
48 * @returns {string | null}
49 */
50export function prefixedAgent(value, plugin, owned) {
51 if (typeof value !== 'string' || value.includes(':')) return null;
52 if (typeof plugin !== 'string' || plugin === '') return null;
53 if (owned !== undefined && owned.has(value)) return null;
54 return roleOfAgent(value) === null ? null : `${plugin}:${value}`;
55}
56cadence-core/bin/lib/listing-filter.mjs 85 lines1// @ts-check
2// listing-filter.mjs - the line rule that takes Cadence's agents and contract
3// skills out of the two listings the host shows the model (phase 5, D-01/D-04).
4// The Cadence module (hooks/cadence-mod.mjs) applies it to the `prompt.attachment`
5// text of those two types. Every other type comes back as it was given: the
6// spike's first filter ignored the type and cut lines out of a
7// `hook_additional_context` attachment.
8//
9// No imports, no Node globals: a hooks module may load nothing else. The
10// answer depends on the type and the text alone, nothing kept between calls,
11// because the host caches the prompt on this text (D-05).
12//
13// The rule, read off listings 2.1.289 rendered (fixtures/listing.*.json):
14// - the text splits on `\n` and the kept lines join on `\n`, so every kept
15// byte, a `\r` included, comes back as it was;
16// - an entry starts at a line beginning `- `, and its name runs to the first
17// `: `, or to the end of the line when there is none. Over its character
18// budget the host lists a skill as a bare `- <name>`, least-used first, and
19// the contract skills are never invoked through the Skill tool, so they go
20// bare first;
21// - an entry runs on through each line that neither begins `- ` nor is empty.
22// The host prints a description's own newlines raw at column 0, which is
23// how a two-line entry looks;
24// - an empty line ends it too. In `agent_listing_delta` the last entry is
25// followed by an empty line and a trailer that belongs to no entry;
26// - a target goes with all of its lines. Targets: in `agent_listing_delta`,
27// a name beginning `cadence:`; in `skill_listing`, a name beginning
28// `cadence:` and ending `-contract`. Another plugin's `-contract` skill
29// stays.
30//
31// Its one limit: a description holding an empty line, or a line beginning
32// `- `, can't be told from the listing's own structure by text, so whatever
33// follows that line stays in the prompt. All 36 Cadence target descriptions
34// are one line today, and self-verify check 26 keeps them, and any
35// `when_to_use`, that way.
36'use strict';
37
38/** The agent listing's attachment type, as 2.1.289 names it. */
39export const AGENT_LISTING = 'agent_listing_delta';
40
41/** The skill listing's attachment type, as 2.1.289 names it. */
42export const SKILL_LISTING = 'skill_listing';
43
44/** The two types this filters: the module's matcher, and nothing else's. */
45export const LISTING_TYPES = Object.freeze([AGENT_LISTING, SKILL_LISTING]);
46
47/** An entry's name: after `- `, up to the first `: ` or the end of the line. */
48function entryName(/** @type {string} */ line) {
49 const rest = line.slice(2);
50 const at = rest.indexOf(': ');
51 return at < 0 ? rest : rest.slice(0, at);
52}
53
54/**
55 * Which entry names `type` drops, or null for a type this leaves alone.
56 * @param {unknown} type
57 * @returns {((name: string) => boolean) | null}
58 */
59function targetsOf(type) {
60 if (type === AGENT_LISTING) return (name) => name.startsWith('cadence:');
61 if (type === SKILL_LISTING) return (name) => name.startsWith('cadence:') && name.endsWith('-contract');
62 return null;
63}
64
65/**
66 * The attachment text to send: `text` without Cadence's target entries for
67 * the two listing types, and `text` itself for any other type.
68 * @param {unknown} type
69 * @param {string} text
70 * @returns {string}
71 */
72export function filterListing(type, text) {
73 const isTarget = targetsOf(type);
74 if (isTarget === null) return text;
75 /** @type {string[]} */
76 const kept = [];
77 let dropping = false;
78 for (const line of text.split('\n')) {
79 if (line.startsWith('- ')) dropping = isTarget(entryName(line));
80 else if (line === '') dropping = false;
81 if (!dropping) kept.push(line);
82 }
83 return kept.join('\n');
84}
85cadence-core/bin/lib/token-capture.mjs 174 lines1// @ts-check
2// token-capture.mjs - the rules the Cadence module (hooks/cadence-mod.mjs) uses
3// to price a subagent dispatch whose return carried no token figure (phase 3,
4// PNL-06, D-10).
5//
6// It lives apart from lib/trace.mjs so the module can load it: a hooks module
7// may import only relative files and `claude-code`, and lib/trace.mjs imports
8// `node:fs`. lib/trace.mjs imports the event name from here and re-exports it,
9// so the name has one definition.
10//
11// Imports nothing and touches no Node global.
12'use strict';
13
14/**
15 * The lifecycle event the Cadence module writes for a Cadence subagent's
16 * figureless close: the host's own usage for that subagent's LAST `turn.step`,
17 * keyed by `corr` and `agent_id`. `renderTrace`'s post-pass folds it into the
18 * bracket that names the same pair, and only into one whose return carried no figure. A
19 * return's own `tokens` always wins, whichever line landed first.
20 *
21 * It is a lifecycle NAME and not a fifth family, for the reason `COORDINATOR`
22 * states in lib/trace.mjs: `FAMILIES` is validated at the seam while
23 * `renderTrace`'s `counts` is a fixed four-key literal, so a new family would
24 * write fine and count nowhere.
25 *
26 * It must NEVER join `TERMINAL`, for the reason `WORKER_CACHE` states: a name
27 * in that array re-enters the pairing and the `funded` accounting, and would
28 * open and close a bracket for a worker that never returned.
29 *
30 * Its `tokens` is ONE step's window, `input + cache_read + cache_creation +
31 * output`, the same denomination as a return's `tokens`, which is a
32 * final-window figure. It never carries `turn.complete`'s usage, which is the
33 * SUM of every step in the turn (`.planning/spikes/mod-runtime-facts/SPIKE.md`,
34 * criterion 5): a sum of windows counts one cached prefix once per step and is
35 * denominated in nothing a bracket holds.
36 */
37export const STEP_WINDOW = 'step_window';
38
39// --- the advisory reviewer's own close gains its id (D-11) ------------------
40//
41// An advisory reviewer closes its own bracket from a persistence tail, and a
42// subagent never sees its own id, so that close carries no `--agent-id` and the
43// step-window fact had nothing to join. The host does see the id, on the
44// subagent's `tool.call`, so the module appends it there.
45//
46// The rewrite answers for ONE simple `planning.mjs trace close` command and
47// nothing else. In a compound line an appended flag could land on another
48// command, so any `;`, `&`, `|`, newline, backtick, `$(`, `#` or trailing `\`
49// answers nothing. A command git-guard acts on has a `git` segment and is never
50// a bare `planning.mjs` call, so the rewrite cannot touch one.
51
52/** A host agent id, as `tool.call` carries it. */
53const AGENT_ID = /^[A-Za-z0-9_-]+$/;
54/** Anything that makes a command line more than one simple command. */
55const COMPOUND = /[;&|\r\n`#]|\$\(|\\\s*$/;
56/** `node <…/planning.mjs> trace close`, the path quoted or bare. */
57const TRACE_CLOSE = /^\s*node\s+(?:"[^"]*planning\.mjs"|'[^']*planning\.mjs'|\S*planning\.mjs)\s+trace\s+close(?=\s|$)/;
58const HAS_AGENT_ID = /(?:^|\s)--agent-id(?=[=\s]|$)/;
59
60/** @param {unknown} command */
61function isTraceClose(command) {
62 return typeof command === 'string' && !COMPOUND.test(command) && TRACE_CLOSE.test(command);
63}
64
65/**
66 * The command with ` --agent-id <agentId>` appended, or null when the rewrite
67 * does not apply: a bad id, a compound line, anything but `trace close`, or a
68 * close that already names an id.
69 * @param {unknown} command the Bash call's `command`
70 * @param {unknown} agentId the call's `agentId`
71 * @returns {string | null}
72 */
73export function withAgentId(command, agentId) {
74 if (typeof agentId !== 'string' || !AGENT_ID.test(agentId)) return null;
75 if (!isTraceClose(command) || HAS_AGENT_ID.test(/** @type {string} */ (command))) return null;
76 return `${command} --agent-id ${agentId}`;
77}
78
79/**
80 * How many times `--<name>` appears, and its value when it appears exactly once
81 * and reads as one plain token (`--name v`, `--name=v`, quoted or bare), never
82 * the next flag. A flag written twice reads as nothing: the module cannot know
83 * which one the seam kept, and a fact filed under the other would never join.
84 * @param {string} command
85 * @param {string} name
86 */
87function flag(command, name) {
88 const count = command.match(new RegExp(`(?:^|\\s)--${name}(?=[=\\s]|$)`, 'g'))?.length ?? 0;
89 const m = count === 1
90 ? command.match(new RegExp(`(?:^|\\s)--${name}(?:=|\\s+)(["']?)([A-Za-z0-9._][A-Za-z0-9._-]*)\\1(?=\\s|$)`))
91 : null;
92 return { count, value: m ? m[2] : null };
93}
94
95/**
96 * The `--phase` of a simple `planning.mjs trace close` command, as written
97 * (`3`, `2.1`), or null.
98 * @param {unknown} command
99 * @returns {string | null}
100 */
101export function closePhase(command) {
102 if (!isTraceClose(command)) return null;
103 const { value } = flag(/** @type {string} */ (command), 'phase');
104 return value !== null && /^\d+(?:\.\d+)?$/.test(value) ? value : null;
105}
106
107/**
108 * What a step-window fact adopts from the `trace close` it prices: the phase
109 * and agent id the close names, its `--anchor` when it carries one, and whether
110 * it carries its own `--tokens`. Null for anything but one simple close naming
111 * a readable phase and agent id, and for a close whose anchor cannot be read.
112 *
113 * The phase is ADOPTED, never derived. The STATE.md cursor often names another
114 * phase than the dispatch's (a `/cad-context N+1` dispatch while the cursor
115 * still reads N, every `/cad-task` dispatch under phase 0), and a fact filed
116 * there joins nothing and lands as a stray line in another phase's record. The
117 * same rule as lib/subagent-trace.mjs's ADOPT, NEVER DERIVE.
118 * @param {unknown} command the Bash call's `command`, after any D-11 rewrite
119 * @returns {{phase: string, agentId: string, anchor: string | null, priced: boolean} | null}
120 */
121export function closeArgs(command) {
122 const phase = closePhase(command);
123 if (phase === null) return null;
124 const text = /** @type {string} */ (command);
125 const id = flag(text, 'agent-id').value;
126 if (id === null || !AGENT_ID.test(id)) return null;
127 const anchor = flag(text, 'anchor');
128 if (anchor.count > 0 && anchor.value === null) return null;
129 return { phase, agentId: id, anchor: anchor.value, priced: flag(text, 'tokens').count > 0 };
130}
131
132// --- the step window and the fact that carries it (D-10) --------------------
133
134const USAGE_KEYS = ['input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'output_tokens'];
135
136/**
137 * One step's window: `input + cache_read + cache_creation + output`, off a
138 * `turn.step` result's `usage`. Null when the usage is null or any of the four
139 * is not a finite non-negative number. Never fed `turn.complete`'s usage, which
140 * sums the steps (see `STEP_WINDOW`).
141 * @param {unknown} usage
142 * @returns {number | null}
143 */
144export function stepWindow(usage) {
145 if (!usage || typeof usage !== 'object') return null;
146 let sum = 0;
147 for (const k of USAGE_KEYS) {
148 const n = /** @type {Record<string, unknown>} */ (usage)[k];
149 if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
150 sum += n;
151 }
152 return sum;
153}
154
155/**
156 * The argv that writes one step-window fact through the plugin's own seam,
157 * with `--anchor` when the close it prices carried one, so the fact takes that
158 * close's `corr`.
159 * @param {string} pluginRoot the plugin's directory, `$.plugin.root`
160 * @param {string} phase
161 * @param {string} agentId
162 * @param {number} tokens
163 * @param {string | null} [anchor]
164 * @returns {string[]}
165 */
166export function stepWindowArgv(pluginRoot, phase, agentId, tokens, anchor = null) {
167 const planning = /[\\/]$/.test(pluginRoot)
168 ? `${pluginRoot}cadence-core/bin/planning.mjs`
169 : `${pluginRoot}/cadence-core/bin/planning.mjs`;
170 return ['node', planning, 'trace', 'append', '--phase', phase, '--family', 'lifecycle',
171 '--event', STEP_WINDOW, '--agent-id', agentId, '--tokens', String(tokens),
172 ...(anchor === null ? [] : ['--anchor', anchor])];
173}
174cadence-core/bin/lib/pane.mjs 465 lines1// @ts-check
2// pane.mjs - the Cadence pane: the full picture of the current phase, drawn
3// by the Cadence module (hooks/cadence-mod.mjs) in a pane of its own, opened
4// with `/cad-panel` (phase 4, D-01).
5//
6// Every figure it shows is a seam's answer, taken as the seam gave it. None is
7// re-derived here (D-04): a second derivation would be a second answer, free
8// to disagree with the first. The adapter owns all I/O, through `$`, and hands
9// this file what it read; this file only turns that into lines.
10//
11// No imports beyond lib/ files that import nothing, no Node globals: a hooks
12// module may load nothing else.
13//
14// What each section reads:
15// - `next`: the STATE.md cursor's `next`, parsed by lib/state-cursor.mjs, the
16// value the band shows (D-03).
17// - the heading, the disagreement line and the plan rows: one `planning.mjs
18// status` run. The phase is its derived `current` (D-05); the rows are that
19// entry's `plans`, or one `PLAN.md` once it is planned, each marked by
20// `outstanding[]` (D-06). A cursor `status` says disagrees (`cursor.agrees`
21// false) gets a line of its own: the pane names both phases, picks neither.
22// - UAT: the same run's `phases[current].uat`, the five counts in its order.
23// No `uat` key, no line: never a row of zeros (D-06).
24// - running agents: the band's roster, as phase 3's start, stop and
25// reconcile leave it, is who runs (D-07). Each row's role, rung and model
26// come from the newest `routing`/`resolve` in trace.jsonl whose `agent` is
27// the host type without `cadence:`, written at or before the module saw the
28// agent start; phase, plan and corr are ignored. A null model is the
29// session's, marked so. No match: role and rung from the host type through
30// lib/rung-agent.mjs, and the model `unrecorded`. The file is read, not
31// `trace render --events`: the default render carries no routing event.
32// - open captures: `capture-check`'s `substantive`, from a run of its own
33// beside `status` (D-08). Its count is not `capture-sections`' bullets: a
34// `None.` placeholder counts zero there. An absent CAPTURE.md is its own
35// `exists: false`, `substantive: 0`, an empty queue, and shows as 0.
36// - token spend: `trace render --phase <current>`, run once `status` names a
37// current phase. The total is the sum of `roles[*].tokens`, the figure
38// `/cad-report` prints as tokens on subagent returns (D-02), and the
39// unrecorded count the sum of `roles[*].unrecorded`. No role with a figure
40// is no figure, never 0. The caveat names every `SPEND_EXCLUDES` entry,
41// imported, never copied. Whatever the render folds into brackets and
42// keeps out of `roles` (phase 3's step-window facts) stays out of this.
43//
44// The layout, one row per line, top to bottom:
45// 1. the phase heading
46// 2. the cursor-disagreement line
47// 3. `next <command>`
48// 4. the plan rows
49// 5. UAT
50// 6. the running agents
51// 7. the open captures
52// 8. the token spend and its caveat
53// A line never wraps and never runs past the width: too long, it is cut and
54// ends in `…`. A section whose source failed reads as unavailable and names
55// the refusal's reason, never an empty list or a zero in place of the data.
56'use strict';
57
58import { roleOfAgent, rungOfAgent } from './rung-agent.mjs';
59import { SPEND_EXCLUDES } from './trace-suggest.mjs';
60
61/** What the pane draws until its first fetch has left a snapshot. */
62export const READING_LINE = 'Cadence · reading…';
63
64/** The `next` line with no readable cursor: the band's hint (phase 3, D-06). */
65export const NO_CURSOR_NEXT = 'next · no readable cursor · run /cad-progress';
66
67/** The answer to `/cad-panel` outside a Cadence project (PNL-02). */
68export const NO_PROJECT_TEXT =
69 'No .planning/ here, so there is no Cadence pane to open. /cad-new-project or /cad-adopt starts one.';
70
71/** The plugin's userConfig field that turns the band and token capture on: `<plugin>.panel` in `/config`. */
72export const PANEL_FIELD = 'panel';
73
74/**
75 * Whether the band draws and token capture runs: the `panel` field, as
76 * `register(on, options)` receives it. Off unless it is exactly true, so a
77 * host that hands no options runs neither.
78 * @param {unknown} options
79 */
80export function panelOn(options) {
81 return typeof options === 'object' && options !== null && /** @type {any} */ (options)[PANEL_FIELD] === true;
82}
83
84/**
85 * What `/cad-panel <args>` asks for: `open` with no argument, `on` or `off`
86 * in any case, or null for anything else.
87 * @param {unknown} args
88 * @returns {'open' | 'on' | 'off' | null}
89 */
90export function panelArg(args) {
91 const arg = String(args ?? '').trim().toLowerCase();
92 if (arg === '') return 'open';
93 return arg === 'on' || arg === 'off' ? arg : null;
94}
95
96/** `/cad-panel on`'s answer once the setting is written. */
97export const PANEL_ON_TEXT = 'Cadence band and token capture on. /cad-panel off turns them off.';
98/** `/cad-panel off`'s answer once the setting is written. */
99export const PANEL_OFF_TEXT = 'Cadence band and token capture off. /cad-panel on turns them back on.';
100/** `/cad-panel` with an argument it does not take. */
101export const PANEL_USAGE = '/cad-panel opens the pane. /cad-panel on or /cad-panel off turns the band and token capture on or off.';
102
103/**
104 * `/cad-panel on` or `off`'s answer when the setting was not written.
105 * @param {string} reason the host's deny, or '' when the write threw
106 */
107export function panelUnchanged(reason) {
108 return `The panel setting did not change${reason ? `: ${reason}` : ''}. It is "Cadence band and token capture" in /config.`;
109}
110
111/** How long one seam run may take before the host kills it. */
112export const SEAM_TIMEOUT_MS = 10000;
113
114/**
115 * @typedef {{next: string} | null} Cursor
116 * @typedef {{ok: boolean, value?: any, reason?: string, hint?: string}} Seam
117 * a seam's answer: `ok` with its envelope in `value`, or not `ok` with the
118 * refusal's `reason` and `hint`
119 * @typedef {{agent: unknown, role: unknown, effort: unknown, model: unknown, ts: unknown}} Resolve
120 * @typedef {{cursor: Cursor, status: Seam, captures: Seam, spend: Seam | null,
121 * resolves: readonly Resolve[], sessionModel: string | null}} Snapshot one fetch's answers;
122 * `spend` is null when there was no current phase to price
123 * @typedef {{id: string, role: string, rung: string}} RosterEntry the band's (lib/band.mjs)
124 * @typedef {{id: string, type: string | null, seen: number}} Sight when the module first
125 * saw a running agent, and its host type when the start carried one
126 */
127
128/**
129 * The argv that runs one `planning.mjs` subcommand against a project.
130 * @param {string} pluginRoot `$.plugin.root`
131 * @param {string} projectRoot the directory holding `.planning/`
132 * @param {readonly string[]} args the subcommand and its flags
133 * @returns {string[]}
134 */
135export function seamArgv(pluginRoot, projectRoot, args) {
136 return ['node', join(pluginRoot, 'cadence-core/bin/planning.mjs'), '--dir', join(projectRoot, '.planning'), ...args];
137}
138
139/** The answer for a run that rejected: a timeout, a spawn the host refused. */
140export const RUN_FAILED = Object.freeze({ ok: false, reason: 'run-failed' });
141
142/**
143 * A seam's stdout as a Seam: the envelope when it says `ok: true`, else the
144 * refusal's `reason` and `hint`, else `unparseable-output`.
145 * @param {unknown} stdout
146 * @returns {Seam}
147 */
148export function seamAnswer(stdout) {
149 let v;
150 try {
151 v = JSON.parse(String(stdout));
152 } catch {
153 v = null;
154 }
155 if (!v || typeof v !== 'object' || Array.isArray(v)) return { ok: false, reason: 'unparseable-output' };
156 if (v.ok === true) return { ok: true, value: v };
157 const reason = typeof v.reason === 'string' && v.reason ? v.reason : 'refused';
158 return typeof v.hint === 'string' && v.hint ? { ok: false, reason, hint: v.hint } : { ok: false, reason };
159}
160
161/**
162 * The pane's lines for a snapshot, each at most `width` cells.
163 * @param {Snapshot | null} snapshot null until the first fetch settles
164 * @param {number} width the pane's `bodyColumns`
165 * @param {readonly RosterEntry[]} [roster] the running agents at draw time
166 * @param {readonly Sight[]} [sights] what the module saw of them
167 * @returns {string[]}
168 */
169export function paneLines(snapshot, width, roster = [], sights = []) {
170 if (!snapshot) return [fit(READING_LINE, width)];
171 const phase = phaseView(snapshot.status);
172 const lines = [
173 ...phase.heading,
174 snapshot.cursor ? `next ${snapshot.cursor.next}` : NO_CURSOR_NEXT,
175 ...phase.rows,
176 ...uatLines(phase.entry),
177 ...agentRows(roster, sights, snapshot.resolves || [], snapshot.sessionModel ?? null),
178 capturesLine(snapshot.captures),
179 ...spendLines(snapshot.spend),
180 ];
181 return lines.map((line) => fit(visible(line), width));
182}
183
184/** The UAT counts, in `status`'s spelling and order. */
185const UAT_COUNTS = Object.freeze(['pass', 'fail', 'pending', 'skipped', 'blocked']);
186
187/**
188 * The UAT line, or none when the current phase's entry carries no `uat`.
189 * @param {any} entry `phaseView`'s entry
190 * @returns {string[]}
191 */
192function uatLines(entry) {
193 const uat = entry && entry.uat;
194 if (!uat || typeof uat !== 'object') return [];
195 return [`UAT ${UAT_COUNTS.map((k) => `${k} ${uat[k]}`).join(' · ')}`];
196}
197
198/**
199 * The open-captures line: `capture-check`'s `substantive`, or why it is missing.
200 * @param {Seam} captures
201 */
202function capturesLine(captures) {
203 if (!captures || !captures.ok) return unavailable('Open captures', captures);
204 const n = captures.value.substantive;
205 return Number.isInteger(n) ? `Open captures ${n}` : unavailable('Open captures', { ok: false, reason: 'unparseable-output' });
206}
207
208/**
209 * The `routing`/`resolve` events in trace.jsonl's text. A line that does not
210 * parse is skipped, as is every other event.
211 * @param {string} text
212 * @returns {Resolve[]}
213 */
214export function parseResolves(text) {
215 /** @type {Resolve[]} */
216 const out = [];
217 for (const line of String(text).split('\n')) {
218 if (!line.trim()) continue;
219 let e;
220 try {
221 e = JSON.parse(line);
222 } catch {
223 continue;
224 }
225 if (e && e.family === 'routing' && e.event === 'resolve') out.push(e);
226 }
227 return out;
228}
229
230/**
231 * A Cadence agent the roster holds has started, seen now. A typeless record a
232 * draw made first (the band's reconcile can add the agent before its start
233 * lands) takes the start's type and time.
234 * @param {readonly Sight[]} sights
235 * @param {unknown} id
236 * @param {unknown} type its host `agent_type`
237 * @param {number} now ms
238 * @returns {readonly Sight[]}
239 */
240export function sightStart(sights, id, type, now) {
241 if (typeof id !== 'string') return sights;
242 const typed = typeof type === 'string' ? type : null;
243 const had = sights.find((s) => s.id === id);
244 if (!had) return [...sights, { id, type: typed, seen: now }];
245 if (had.type !== null || typed === null) return sights;
246 return sights.map((s) => (s === had ? { id, type: typed, seen: now } : s));
247}
248
249/**
250 * An agent stopped: its record goes.
251 * @param {readonly Sight[]} sights
252 * @param {unknown} id
253 * @returns {readonly Sight[]}
254 */
255export function sightStop(sights, id) {
256 return sights.some((s) => s.id === id) ? sights.filter((s) => s.id !== id) : sights;
257}
258
259/**
260 * Every roster agent gets a record the first time the pane draws it (a start
261 * the reconcile made up for carries no type), and records of agents the
262 * roster no longer holds go.
263 * @param {readonly Sight[]} sights
264 * @param {readonly RosterEntry[]} roster
265 * @param {number} now ms
266 * @returns {readonly Sight[]}
267 */
268export function sightDraw(sights, roster, now) {
269 const kept = sights.filter((s) => roster.some((a) => a.id === s.id));
270 const added = roster.filter((a) => !kept.some((s) => s.id === a.id)).map((a) => ({ id: a.id, type: null, seen: now }));
271 return kept.length === sights.length && added.length === 0 ? sights : [...kept, ...added];
272}
273
274/**
275 * One row per running agent: `<role> · rung <rung> · <model>`.
276 * @param {readonly RosterEntry[]} roster
277 * @param {readonly Sight[]} sights
278 * @param {readonly Resolve[]} resolves
279 * @param {string | null} sessionModel `$.session.model()`, or null unread
280 * @returns {string[]}
281 */
282export function agentRows(roster, sights, resolves, sessionModel) {
283 if (roster.length === 0) return ['No Cadence agents running'];
284 return roster.map((a) => {
285 const sight = sights.find((s) => s.id === a.id);
286 const type = sight ? sight.type : null;
287 const r = type === null ? null : newestResolve(resolves, type.replace(/^cadence:/, ''), sight.seen);
288 if (r === null) {
289 return `${roleOfAgent(type) ?? a.role} · rung ${rungOfAgent(type) ?? a.rung} · unrecorded`;
290 }
291 const model = r.model === null ? `${sessionModel ?? 'the session model'} (session)`
292 : typeof r.model === 'string' && r.model ? r.model : 'unrecorded';
293 // trace.jsonl is any JSON: a field that is not text counts as absent, so
294 // one odd line costs its own row's field, never the whole pane.
295 const role = typeof r.role === 'string' ? r.role : roleOfAgent(type) ?? a.role;
296 const rung = typeof r.effort === 'string' || Number.isFinite(r.effort) ? r.effort : rungOfAgent(type) ?? a.rung;
297 return `${role} · rung ${rung} · ${model}`;
298 });
299}
300
301/**
302 * The newest resolve for `agent` written at or before `seen`; the later line
303 * wins a tie.
304 * @param {readonly Resolve[]} resolves
305 * @param {string} agent
306 * @param {number} seen ms
307 * @returns {Resolve | null}
308 */
309function newestResolve(resolves, agent, seen) {
310 let best = null;
311 let bestAt = -Infinity;
312 for (const r of resolves) {
313 const at = typeof r.ts === 'string' ? Date.parse(r.ts) : NaN;
314 if (r.agent !== agent || !(at <= seen) || at < bestAt) continue;
315 best = r;
316 bestAt = at;
317 }
318 return best;
319}
320
321/**
322 * The phase's spend as `/cad-report` reads it from the render's `roles`.
323 * @param {any} render `trace render --phase N`'s envelope
324 * @returns {{total: number | null, unrecorded: number}} `total` is null when
325 * no role carries a figure
326 */
327export function spendOf(render) {
328 const roles = render && render.roles && typeof render.roles === 'object' ? Object.values(render.roles) : [];
329 let total = null;
330 let unrecorded = 0;
331 for (const r of roles) {
332 if (r && typeof r.tokens === 'number') total = (total ?? 0) + r.tokens;
333 if (r && typeof r.unrecorded === 'number') unrecorded += r.unrecorded;
334 }
335 return { total, unrecorded };
336}
337
338/**
339 * The spend line and its caveat, or none when there was no phase to price.
340 * @param {Seam | null} spend
341 * @returns {string[]}
342 */
343function spendLines(spend) {
344 if (spend === null || spend === undefined) return [];
345 const label = 'Tokens on subagent returns';
346 if (!spend.ok) return [unavailable(label, spend)];
347 const { total, unrecorded } = spendOf(spend.value);
348 const figure = total === null ? `${label}: none recorded` : `${label} ${total}`;
349 return [`${figure}${unrecorded ? ` · ${unrecorded} unrecorded` : ''}`, `Excludes ${SPEND_EXCLUDES.join(', ')}`];
350}
351
352/** The statuses a phase has a `PLAN.md` in, when `status` lists no `plans`. */
353const PLANNED = new Set(['planned', 'executed', 'complete']);
354
355/**
356 * The heading, the disagreement line and the plan rows, from `status`.
357 * @param {Seam} status
358 * @returns {{heading: string[], rows: string[], entry: any}} `entry`: the
359 * current phase's `phases[]` entry, or null with no current phase
360 */
361export function phaseView(status) {
362 if (!status || !status.ok) return { heading: [unavailable('Phase', status)], rows: [], entry: null };
363 const s = status.value;
364 /** @type {string[]} */
365 const heading = [];
366 let entry = null;
367 if (s.current === null || s.current === undefined) {
368 heading.push(s.cycle === 'none' ? 'No active phase · the milestone is closed' : 'No active phase · every phase is complete');
369 } else {
370 entry = (Array.isArray(s.phases) ? s.phases : []).find((p) => p && String(p.n) === String(s.current)) || null;
371 heading.push(`Phase ${s.current} of ${s.total}${entry ? ` · ${entry.name} · ${entry.status}` : ''}`);
372 }
373 if (s.cursor && s.cursor.agrees === false) heading.push(`Cursor says phase ${s.cursor.phase} · ${s.cursor.status}`);
374 if (entry === null) return { heading, rows: [], entry };
375 const plans = Array.isArray(entry.plans) ? entry.plans : PLANNED.has(entry.status) ? ['PLAN.md'] : [];
376 if (plans.length === 0) return { heading, rows: ['No plan yet'], entry };
377 const due = (Array.isArray(s.outstanding) ? s.outstanding : [])
378 .find((o) => o && String(o.phase) === String(s.current));
379 const open = new Set(due && Array.isArray(due.plans) ? due.plans : []);
380 return { heading, rows: plans.map((f) => `${f} · ${open.has(f) ? 'outstanding' : 'complete'}`), entry };
381}
382
383/**
384 * A section whose source failed: never an empty list or a zero in its place.
385 * @param {string} label
386 * @param {Seam | null | undefined} seam
387 */
388function unavailable(label, seam) {
389 if (!seam || seam.ok) return `${label} unavailable · not-read`;
390 return `${label} unavailable · ${seam.reason}${seam.hint ? ` · ${seam.hint}` : ''}`;
391}
392
393/**
394 * `dir/name`, without doubling the separator at a filesystem root.
395 * @param {string} dir
396 * @param {string} name
397 */
398function join(dir, name) {
399 return /[\\/]$/.test(dir) ? dir + name : `${dir}/${name}`;
400}
401
402/**
403 * A kick runs its task when nothing is running (D-04). A kick during a run
404 * queues exactly one more run, of the latest kick's task, started when this
405 * one settles: the last change is never missed, and a burst of events costs
406 * two runs, not one each. A task that throws leaves the runner usable.
407 *
408 * The task comes with each kick rather than once here because the module may
409 * not hold `$` between events; each kick's task closes over its own.
410 * @returns {(task: () => unknown) => Promise<void>} the kick; its promise
411 * settles once the run it started or joined, and any queued behind it, have
412 */
413export function singleFlight() {
414 /** @type {Promise<void> | null} */
415 let running = null;
416 /** @type {(() => unknown) | null} */
417 let queued = null;
418 return function kick(task) {
419 if (running) {
420 queued = task;
421 return running;
422 }
423 running = (async () => {
424 try {
425 for (let run = task; run; run = queued) {
426 queued = null;
427 try {
428 // through `then`, so even a task that throws at once yields first
429 // and `running` is set before this loop can end
430 await Promise.resolve().then(run);
431 } catch {
432 // the next kick runs it again
433 }
434 }
435 } finally {
436 running = null;
437 }
438 })();
439 return running;
440 };
441}
442
443/**
444 * Every control character shown as `?`. The text comes from files and seam
445 * output a person or a tool wrote, and the host refuses a whole tree when a
446 * text child holds one (C0, DEL, C1: tab, CR and LF too).
447 * @param {string} s
448 */
449function visible(s) {
450 return String(s).replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
451}
452
453/**
454 * Cut at the width, ending in `…`. Counted by code point, so a cut never
455 * splits a surrogate pair.
456 * @param {string} line
457 * @param {number} width
458 */
459function fit(line, width) {
460 const chars = Array.from(line);
461 if (chars.length <= width) return line;
462 if (!(width >= 1)) return '';
463 return chars.slice(0, width - 1).join('') + '…';
464}
465cadence-core/bin/lib/pane-view.mjs 335 lines1// @ts-check
2// pane-view.mjs - the Cadence pane as styled rows: what lib/pane.mjs reads,
3// laid out as the owner's dashboard. Each row is a list of segments
4// `{ text, color?, bold?, dim? }`; the module's Pane render turns a segment
5// into a host Text. Nothing here is a host element.
6//
7// It draws what paneLines draws and reads it the same way: the heading, the
8// drift and closed-milestone lines and the plan rows come from phaseView, the
9// agents from agentRows, the spend from spendOf. Nothing is derived twice.
10//
11// The cache figures are the one part paneLines never had. They come from the
12// module's live meter (lib/cache-meter.mjs), not a seam, so they are the
13// session's and say so, never the phase's.
14//
15// No imports beyond lib/ files that import nothing, no Node globals: the
16// hooks module loads this.
17'use strict';
18
19import { shownStatus } from './band.mjs';
20import { breaksText, EMPTY_METER, hitRate, kilo, MAIN, percent } from './cache-meter.mjs';
21import { agentRows, NO_CURSOR_NEXT, phaseView, READING_LINE, spendOf } from './pane.mjs';
22import { SPEND_EXCLUDES } from './trace-suggest.mjs';
23
24/**
25 * @typedef {{text: string, color?: string, bg?: string, bold?: boolean, dim?: boolean, action?: 'next'}} Segment
26 * @typedef {Segment[]} Row
27 */
28
29/** Cells the section heads take, the head included. */
30const LABEL_CELLS = 11;
31
32/** A phase status's chip colour, by the first word it starts with. */
33const STATUS_COLORS = Object.freeze([['unplanned', 'gray'], ['context', 'blue'], ['planned', 'cyan'],
34 ['executing', 'yellow'], ['executed', 'magenta'], ['verif', 'green'], ['complete', 'green']]);
35
36/** The UAT counts in `status`'s order, and the color each draws in. */
37const UAT_COLORS = Object.freeze({ pass: 'green', fail: 'red', pending: 'yellow', skipped: undefined, blocked: 'red' });
38
39/**
40 * The pane's rows for a snapshot, none wider than `width` cells.
41 * @param {import('./pane.mjs').Snapshot | null} snapshot null until the first fetch settles
42 * @param {number} width the pane's `bodyColumns`
43 * @param {readonly import('./pane.mjs').RosterEntry[]} [roster]
44 * @param {readonly import('./pane.mjs').Sight[]} [sights]
45 * @param {import('./cache-meter.mjs').Meter} [meter] the session's cache meter
46 * @returns {Row[]}
47 */
48export function paneView(snapshot, width, roster = [], sights = [], meter = EMPTY_METER) {
49 if (!snapshot) return [fitRow([{ text: READING_LINE, dim: true }], width)];
50 const phase = phaseView(snapshot.status);
51 const bar = barCells(width);
52 /** @type {Row} */
53 const rule = [{ text: '─'.repeat(Math.max(0, width)), dim: true }];
54 /** @type {Row[]} */
55 const rows = [
56 ...headingRows(snapshot, phase, roster),
57 rule,
58 ...planRows(phase.rows, bar),
59 ...(phase.rows.length ? [rule] : []),
60 ...agentLines(roster, sights, snapshot, meter),
61 ...uatRows(phase.entry, bar),
62 capturesRow(snapshot.captures),
63 ...spendRows(snapshot.spend, width),
64 ...cacheRows(meter, width),
65 ];
66 return rows.map((row) => fitRow(row.map((s) => ({ ...s, text: visible(s.text) })), width));
67}
68
69/**
70 * The heading, the next command, and the drift line when the cursor disagrees.
71 * @param {import('./pane.mjs').Snapshot} snapshot
72 * @param {ReturnType<typeof phaseView>} phase
73 * @param {readonly import('./pane.mjs').RosterEntry[]} roster
74 * @returns {Row[]}
75 */
76function headingRows(snapshot, phase, roster) {
77 const s = snapshot.status && snapshot.status.ok ? snapshot.status.value : null;
78 const [first, ...drift] = phase.heading;
79 /** @type {Row[]} */
80 const rows = [];
81 if (!s) rows.push([{ text: first, color: 'red' }]);
82 else if (phase.entry) rows.push([{ text: `Phase ${s.current}`, bold: true, color: 'cyan' }, { text: ` of ${s.total} `, dim: true }, { text: String(phase.entry.name), bold: true }]);
83 else rows.push([{ text: first, bold: true }]);
84 /** @type {Row} */
85 const next = snapshot.cursor
86 ? [{ text: 'next ', dim: true }, { text: snapshot.cursor.next, action: 'next' }]
87 : [{ text: NO_CURSOR_NEXT, color: 'yellow' }];
88 rows.push(phase.entry ? [chip(shownStatus(String(phase.entry.status), roster)), { text: ' ' }, ...next] : next);
89 for (const line of drift) rows.push([{ text: '⚠ ', color: 'yellow' }, { text: line, color: 'yellow' }]);
90 return rows;
91}
92
93/**
94 * PLANS with its bar, then one row per plan: `✓` complete, `○` outstanding.
95 * The rows are phaseView's, which end in ` · complete` or ` · outstanding`.
96 * @param {readonly string[]} lines
97 * @param {number} bar
98 * @returns {Row[]}
99 */
100function planRows(lines, bar) {
101 if (lines.length === 0) return [];
102 const plans = lines.map((l) => /^(.*) · (complete|outstanding)$/.exec(l));
103 if (plans.some((m) => m === null)) return [[head('PLANS'), { text: lines.join(' · '), dim: true }]];
104 const done = plans.filter((m) => m[2] === 'complete').length;
105 return [
106 [head('PLANS'), ...barSegments(done, plans.length, bar, 'green'), { text: ` ${done} of ${plans.length}`, dim: true }],
107 ...plans.map((m) => m[2] === 'complete'
108 ? [{ text: ' ' }, { text: '☑', color: 'green' }, { text: ` ${m[1]}`, dim: true }]
109 : [{ text: ' ' }, { text: '☐', color: 'yellow' }, { text: ` ${m[1]}` }]),
110 ];
111}
112
113/**
114 * AGENTS: one row per running agent behind a cyan `●`, the first beside the
115 * head, then its cache figure once it has sent a request, and its breaks.
116 * @param {readonly import('./pane.mjs').RosterEntry[]} roster
117 * @param {readonly import('./pane.mjs').Sight[]} sights
118 * @param {import('./pane.mjs').Snapshot} snapshot
119 * @param {import('./cache-meter.mjs').Meter} meter
120 * @returns {Row[]}
121 */
122function agentLines(roster, sights, snapshot, meter) {
123 const lines = agentRows(roster, sights, snapshot.resolves || [], snapshot.sessionModel ?? null);
124 if (roster.length === 0) return [[head('AGENTS'), { text: lines[0], dim: true }]];
125 return lines.map((line, i) => {
126 const loop = meter.loops.get(roster[i].id);
127 /** @type {Row} */
128 const cache = loop ? [{ text: ' · ', dim: true }, { text: `cache ${rateText(loop)}` }] : [];
129 if (loop && loop.breaks) cache.push({ text: ' · ', dim: true }, { text: breaksText(loop.breaks), color: 'yellow' });
130 return [i === 0 ? head('AGENTS') : { text: ' '.repeat(LABEL_CELLS) },
131 { text: '●', color: 'cyan' }, { text: ` ${line}` }, ...cache];
132 });
133}
134
135/**
136 * UAT: pass of total as a bar, then each count that is not 0 in its color.
137 * No `uat` key, no row.
138 * @param {any} entry
139 * @param {number} bar
140 * @returns {Row[]}
141 */
142function uatRows(entry, bar) {
143 const uat = entry && entry.uat;
144 if (!uat || typeof uat !== 'object') return [];
145 const keys = /** @type {(keyof typeof UAT_COLORS)[]} */ (Object.keys(UAT_COLORS));
146 const total = keys.reduce((sum, k) => sum + (Number.isFinite(uat[k]) ? uat[k] : 0), 0);
147 const pass = Number.isFinite(uat.pass) ? uat.pass : 0;
148 /** @type {Row} */
149 const counts = [];
150 for (const k of keys.filter((key) => uat[key] !== 0 && uat[key] !== undefined)) {
151 if (counts.length) counts.push({ text: ' · ', dim: true });
152 counts.push(UAT_COLORS[k] ? { text: `${uat[k]} ${k}`, color: UAT_COLORS[k] } : { text: `${uat[k]} ${k}` });
153 }
154 if (counts.length === 0) counts.push({ text: 'none recorded', dim: true });
155 const drawn = total > 0 ? [...barSegments(pass, total, bar, 'green'), { text: ' ' }] : [];
156 return [[head('UAT'), ...drawn, ...counts]];
157}
158
159/**
160 * CAPTURES: `capture-check`'s `substantive`, or why it is missing.
161 * @param {import('./pane.mjs').Seam} captures
162 * @returns {Row}
163 */
164function capturesRow(captures) {
165 const n = captures && captures.ok ? captures.value.substantive : null;
166 if (!Number.isInteger(n)) {
167 const why = !captures ? { ok: false, reason: 'not-read' } : captures.ok ? { ok: false, reason: 'unparseable-output' } : captures;
168 return [head('CAPTURES'), unavailable(why)];
169 }
170 return [head('CAPTURES'), { text: `${n} open` }];
171}
172
173/**
174 * SPEND and its caveat, dim; none when there was no phase to price.
175 * @param {import('./pane.mjs').Seam | null} spend
176 * @param {number} width the pane's width, which the caveat wraps inside
177 * @returns {Row[]}
178 */
179function spendRows(spend, width) {
180 if (spend === null || spend === undefined) return [];
181 if (!spend.ok) return [[head('SPEND'), unavailable(spend)]];
182 const { total, unrecorded } = spendOf(spend.value);
183 /** @type {Row} */
184 const row = [head('SPEND'), total === null ? { text: 'none recorded', dim: true } : { text: `${grouped(total)} tokens` }];
185 if (unrecorded) row.push({ text: ` · ${unrecorded} unrecorded`, dim: true });
186 const room = Math.max(10, width - LABEL_CELLS);
187 return [row, ...wrapWords(`Excludes ${SPEND_EXCLUDES.join(', ')}`, room)
188 .map((line) => [{ text: ' '.repeat(LABEL_CELLS) }, { text: line, dim: true }])];
189}
190
191/**
192 * CACHE: the main loop's hit rate over the session and on its last request,
193 * or what its first request wrote while that is all it has sent, its breaks
194 * with the last one's tokens lost, and the breaks in agents, then a dim line
195 * saying whose figures they are.
196 * @param {import('./cache-meter.mjs').Meter} meter
197 * @param {number} width the pane's width, which the caveat wraps inside
198 * @returns {Row[]}
199 */
200function cacheRows(meter, width) {
201 const main = meter.loops.get(MAIN);
202 if (!main) return [[head('CACHE'), { text: 'no request yet this session', dim: true }]];
203 /** @type {Row} */
204 const row = [head('CACHE'), { text: `main ${rateText(main)}` }];
205 if (main.calls > 1) row.push({ text: ` (last ${percent(main.last)})`, dim: true });
206 if (main.breaks) row.push({ text: ' · ', dim: true }, { text: `${breaksText(main.breaks)}, last ${kilo(main.lost)} lost`, color: 'yellow' });
207 const agents = meter.breaks - main.breaks;
208 if (agents > 0) row.push({ text: ' · ', dim: true }, { text: `${agents} in agents`, color: 'yellow' });
209 const room = Math.max(10, width - LABEL_CELLS);
210 return [row, ...wrapWords('This session, live: not part of the phase\'s spend', room)
211 .map((line) => [{ text: ' '.repeat(LABEL_CELLS) }, { text: line, dim: true }])];
212}
213
214/**
215 * A loop's hit rate over the session, or what its first request wrote while
216 * that is all it has sent: one request's rate is only its cold write. After a
217 * module reload that first request may be warm, so this says first, not cold.
218 * @param {import('./cache-meter.mjs').Loop} loop
219 * @returns {string}
220 */
221function rateText(loop) {
222 return loop.calls === 1 ? `first request, ${kilo(loop.write)} written` : percent(hitRate(loop));
223}
224
225/**
226 * A section head, bold, padded to the label column.
227 * @param {string} label
228 * @returns {Segment}
229 */
230function head(label) {
231 return { text: label.padEnd(LABEL_CELLS), bold: true, color: 'cyan' };
232}
233
234/**
235 * A status as a chip: the word on its colour.
236 * @param {string} status
237 * @returns {Segment}
238 */
239function chip(status) {
240 const hit = STATUS_COLORS.find(([word]) => status.startsWith(word));
241 return { text: ` ${status} `, color: 'black', bg: hit ? hit[1] : 'white', bold: true };
242}
243
244/**
245 * `text` in lines of at most `room` cells, broken at spaces.
246 * @param {string} text
247 * @param {number} room
248 * @returns {string[]}
249 */
250function wrapWords(text, room) {
251 const lines = [];
252 let line = '';
253 for (const word of text.split(' ')) {
254 if (line && (line + ' ' + word).length > room) { lines.push(line); line = word; }
255 else line = line ? `${line} ${word}` : word;
256 }
257 if (line) lines.push(line);
258 return lines;
259}
260
261/**
262 * A failed source's reason, red: never an empty list or a zero in its place.
263 * @param {import('./pane.mjs').Seam} seam
264 * @returns {Segment}
265 */
266function unavailable(seam) {
267 return { text: `unavailable · ${seam.reason}${seam.hint ? ` · ${seam.hint}` : ''}`, color: 'red' };
268}
269
270/**
271 * Bar cells for a pane `width` cells across: a fifth of it, 10 to 24, and
272 * never more than what the label column leaves.
273 * @param {number} width
274 */
275function barCells(width) {
276 return Math.max(0, Math.min(24, Math.max(10, Math.floor(width / 5)), width - LABEL_CELLS - 1));
277}
278
279/**
280 * `part` of `whole` as `cells` cells: `█` filled in `color`, `░` dim.
281 * @param {number} part
282 * @param {number} whole
283 * @param {number} cells
284 * @param {string} color
285 * @returns {Segment[]}
286 */
287function barSegments(part, whole, cells, color) {
288 const filled = Math.max(0, Math.min(cells, Math.round((part / whole) * cells)));
289 return [{ text: '█'.repeat(filled), color }, { text: '░'.repeat(cells - filled), dim: true }];
290}
291
292/**
293 * Thousands with commas, without Intl.
294 * @param {number} n
295 */
296function grouped(n) {
297 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
298}
299
300/**
301 * Every control character shown as `?`, as paneLines shows it: the host
302 * refuses a whole tree when a text child holds one.
303 * @param {unknown} s
304 */
305function visible(s) {
306 return String(s).replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
307}
308
309/**
310 * A row cut at the width, its last kept segment ending in `…`. Counted by
311 * code point, so a cut never splits a surrogate pair. Empty segments drop.
312 * @param {Row} row
313 * @param {number} width
314 * @returns {Row}
315 */
316function fitRow(row, width) {
317 const kept = row.filter((s) => s.text !== '');
318 const cells = kept.reduce((n, s) => n + Array.from(s.text).length, 0);
319 if (cells <= width) return kept;
320 if (!(width >= 1)) return [];
321 /** @type {Row} */
322 const out = [];
323 let room = width - 1;
324 for (const s of kept) {
325 const chars = Array.from(s.text);
326 if (chars.length >= room) {
327 out.push({ ...s, text: chars.slice(0, room).join('') + '…' });
328 break;
329 }
330 out.push(s);
331 room -= chars.length;
332 }
333 return out;
334}
335cadence-core/bin/lib/cache-meter.mjs 138 lines1// @ts-check
2// cache-meter.mjs - the session's prompt-cache hit rate and its cache breaks,
3// per loop: the main loop under MAIN, each agent under its id. The Cadence
4// module (hooks/cadence-mod.mjs) steps it from every request's usage; the band
5// and the pane draw it.
6//
7// A loop's hit rate is the share of what it sent that the cache served: cache
8// read over cache read, cache write and fresh input. A break is a request that
9// read back less than the same loop's previous request cached (read plus
10// write). A new model, or a message count that dropped (compaction, /clear, a
11// rewind), starts a new prefix, so there is nothing to read back and no break.
12// A cache that expired between requests is a break, because it was one.
13//
14// Live and in memory: a session's figures, gone on a module reload. The trace
15// keeps none of it yet (#309).
16//
17// No imports, no Node globals: the hooks module loads this.
18'use strict';
19
20/** The main loop's key. Every agent id is non-empty. */
21export const MAIN = '';
22
23/**
24 * @typedef {{model: string, messages: number, cached: number}} Prefix
25 * what a loop's last request left cached, and on what
26 * @typedef {{read: number, write: number, fresh: number, calls: number, last: number | null,
27 * breaks: number, lost: number, prefix: Prefix | null}} Loop
28 * `last`: the last request's hit rate. `lost`: the tokens the last break
29 * failed to read back.
30 * @typedef {{loops: ReadonlyMap<string, Loop>, breaks: number}} Meter
31 * `breaks` counts every loop's, a dropped one's included
32 */
33
34/** @type {Meter} */
35export const EMPTY_METER = Object.freeze({ loops: new Map(), breaks: 0 });
36
37/** @param {unknown} n */
38const count = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
39
40/**
41 * The share the cache served, or null when nothing was sent.
42 * @param {number} read
43 * @param {number} write
44 * @param {number} fresh
45 * @returns {number | null}
46 */
47function rate(read, write, fresh) {
48 const total = read + write + fresh;
49 return total === 0 ? null : read / total;
50}
51
52/**
53 * A loop's hit rate over the session, or null before it sent anything.
54 * @param {Loop | undefined} loop
55 * @returns {number | null}
56 */
57export function hitRate(loop) {
58 return loop ? rate(loop.read, loop.write, loop.fresh) : null;
59}
60
61/**
62 * The meter after one request of `loop`. A usage it cannot read leaves the
63 * meter as it was.
64 * @param {Meter} meter
65 * @param {string} loop MAIN, or the agent id
66 * @param {unknown} model the request's model
67 * @param {unknown} messages the request's message count
68 * @param {unknown} usage the step's usage
69 * @returns {Meter}
70 */
71export function meterStep(meter, loop, model, messages, usage) {
72 if (!usage || typeof usage !== 'object') return meter;
73 const u = /** @type {Record<string, unknown>} */ (usage);
74 const read = u.cache_read_input_tokens;
75 const write = u.cache_creation_input_tokens;
76 const fresh = u.input_tokens;
77 if (!count(read) || !count(write) || !count(fresh)) return meter;
78 const r = /** @type {number} */ (read);
79 const w = /** @type {number} */ (write);
80 const f = /** @type {number} */ (fresh);
81
82 const was = meter.loops.get(loop);
83 const before = was ? was.prefix : null;
84 const known = typeof model === 'string' && count(messages);
85 const broke = known && before !== null && before.model === model
86 && /** @type {number} */ (messages) >= before.messages && r < before.cached;
87
88 /** @type {Loop} */
89 const next = {
90 read: (was ? was.read : 0) + r,
91 write: (was ? was.write : 0) + w,
92 fresh: (was ? was.fresh : 0) + f,
93 calls: (was ? was.calls : 0) + 1,
94 last: rate(r, w, f),
95 breaks: (was ? was.breaks : 0) + (broke ? 1 : 0),
96 lost: broke && before !== null ? before.cached - r : was ? was.lost : 0,
97 prefix: known ? { model: /** @type {string} */ (model), messages: /** @type {number} */ (messages), cached: r + w } : null,
98 };
99 return { loops: new Map(meter.loops).set(loop, next), breaks: meter.breaks + (broke ? 1 : 0) };
100}
101
102/**
103 * The meter without a stopped agent's loop. Its breaks stay in the session's.
104 * @param {Meter} meter
105 * @param {unknown} loop
106 * @returns {Meter}
107 */
108export function meterDrop(meter, loop) {
109 if (typeof loop !== 'string' || loop === MAIN || !meter.loops.has(loop)) return meter;
110 const loops = new Map(meter.loops);
111 loops.delete(loop);
112 return { loops, breaks: meter.breaks };
113}
114
115/**
116 * A rate as `87.9%`, or `-` for none.
117 * @param {number | null} r
118 */
119export function percent(r) {
120 return r === null ? '-' : `${(r * 100).toFixed(1)}%`;
121}
122
123/**
124 * Tokens as `850` or `38.5k`.
125 * @param {number} n
126 */
127export function kilo(n) {
128 return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
129}
130
131/**
132 * `1 cache break`, `2 cache breaks`.
133 * @param {number} n
134 */
135export function breaksText(n) {
136 return `${n} cache break${n === 1 ? '' : 's'}`;
137}
138cadence-core/bin/lib/trace-suggest.mjs 827 lines1// @ts-check
2// trace-suggest.mjs - evidence-backed config suggestions read off the joined
3// run record. The pure half of `planning.mjs trace suggest`: renderTrace()
4// produces the render, this file turns it into suggestions, and the caller
5// owns the envelope. No I/O here, deliberately - every rule is a pure
6// function over the render so a test can pin exact outputs to exact traces.
7//
8// The posture is the triage gate's, applied to configuration: suggestions are
9// INPUT to a decision the user makes, never applied by anything. Each carries
10// its evidence inline (counts drawn from the record, not adjectives), the
11// exact config key it concerns, and a kind:
12// - `suggest` - the record supports changing a key; the user decides.
13// - `info` - a receipt worth seeing that asks for nothing.
14//
15// Every rule needs a floor of evidence before it speaks (MIN_* below). A
16// suggestion computed from one event is a guess wearing a verdict, and the
17// whole point of reading the trace is to not guess.
18//
19// A keyed suggestion also names WHICH WAY to move the key and what it holds now
20// (SGT-01), and that is why `suggestFromRender` takes a second argument. The
21// values behind those keys live on disk - the merged config layers, the gate
22// ladder in `config.schema.json`, the resolved task ceiling - and reading them
23// here would end the purity above. So the CALLER resolves them and passes them
24// in: `planning.mjs`'s `suggest` arm owns every read, this file owns every
25// rule, and the argument is optional so a test can still call
26// `suggestFromRender(render(...))` with one argument and get an honest "unset"
27// rather than a throw. `direction` is assigned per RULE rather than by the
28// caller (phase 5 plan-2 note): the caller cannot know whether R1 fired on its
29// gate arm or its reviewer arm until these rules have run.
30
31/**
32 * @typedef {{kind: 'suggest'|'info', subject: string, evidence: string,
33 * action: string|null, direction?: 'raise'|'lower',
34 * current?: any, proposed?: any}} Suggestion
35 * @typedef {{values?: Record<string, any>, gates?: string[], rungs?: string[],
36 * checkpointTasks?: (number|null)[]}} Resolution
37 * @typedef {{counts: Record<string, number>,
38 * roles: Record<string, {dispatches: number, tokens?: number, unrecorded?: number}>,
39 * events: any[],
40 * brackets?: {duration_ms?: number}[],
41 * coordinator?: {wall_ms: number, bracket_ms: number, residue_ms: number,
42 * steps: {phase: any, step: any, ts: any, residue_ms: number}[]}}} RenderLike
43 * @typedef {{roles: {role: string, brackets: number, touches: number, distinct: number,
44 * ratio: number|null,
45 * worst: {path: string, count: number, phase: any, plan: any}|null}[],
46 * joined: number, fileCarrying: number, coverage: number|null,
47 * coordinatorFiles: number}} InDispatchReads
48 */
49
50// Evidence floors. Below these a rule stays silent rather than extrapolating.
51/**
52 * R1's floor, counted in UNVETOED EMPTY fires - fires that adjudicated zero
53 * survivors and were not the fire a re-arm round came back to fix - never in a
54 * trigger's fires overall. A trigger that fires ten times and comes back empty
55 * once is not evidence about the gate; two empty fires are the least that can
56 * be.
57 */
58export const MIN_FIRES_FOR_GATE_SUGGESTION = 2;
59export const MIN_DISPATCHES_FOR_RUNG_INFO = 4;
60export const MIN_ESCALATIONS_FOR_RUNG_SUGGESTION = 2;
61export const MIN_CHECKPOINTS_FOR_SIZE_SUGGESTION = 2;
62/**
63 * R9's floor, counted in OVERRIDE RECEIPTS carrying one trigger - the WRITES,
64 * never the authorizations behind them. Two is the least that can be: a single
65 * receipt cannot be the second application of an answer, so one override says
66 * nothing about whether a decision was reused.
67 */
68export const MIN_OVERRIDES_FOR_AUTHORIZATION_INFO = 2;
69/**
70 * The coordinator receipt's floor, in milliseconds. Ten minutes: below that the
71 * residue is dominated by the second or two between a step's marker and the
72 * dispatch that follows it, which is a measurement artefact rather than time
73 * anyone spent. The other floors count events; this one cannot, because one
74 * marker can carry a whole afternoon and a hundred can carry nothing.
75 */
76export const MIN_RESIDUE_MS_FOR_COORDINATOR_INFO = 600000;
77
78/**
79 * R7's floor, PER ROLE, and the map is the gate rather than the number: a role
80 * this object does not name never produces an in-dispatch entry whatever its
81 * ratio.
82 *
83 * Only two roles are named because only two showed signal.
84 * `.planning/spikes/read-set-redundancy/SPIKE.md` measured, in-dispatch:
85 * `cad-executor` 3.64 over 78 dispatches, `cad-verifier` 2.05 over 31,
86 * `cad-planner` 1.88, `cad-assumptions-analyzer` 1.78, `cad-reviewer` 1.74.
87 * The last three sit in a band the spike calls noise - a rule firing on them
88 * spends the user's attention to save nothing - so a global threshold picked
89 * low enough to keep `cad-verifier` would speak on all five.
90 *
91 * The two numbers, derived rather than chosen:
92 * - `cad-verifier` sits at the spike's own C2 bar of 2.0, the level at which
93 * it declared the redundancy real, and clears it at 2.05.
94 * - `cad-executor` sits ABOVE that bar because that role legitimately returns
95 * to a file once per task across up to `workflow.max_plan_tasks` tasks in
96 * one dispatch, so the same 2.0 would report ordinary per-task work as
97 * repetition. 3.00 leaves it speaking on today's 3.64 and goes quiet on a
98 * real improvement, which is the whole test of a floor.
99 */
100export const IN_DISPATCH_FLOORS = Object.freeze({
101 'cad-executor': 3.00,
102 'cad-verifier': 2.00,
103});
104
105/**
106 * The two sources the recorded token total DOES NOT include, in the words
107 * every reader of that total states.
108 *
109 * Exported and frozen for the reason `lib/trace.mjs` exports
110 * `DISPATCH`/`TERMINAL`/`ANCHOR` rather than letting the bracket census hold
111 * its own copy of them: this claim has TWO readers - R5's `evidence` string
112 * below, which `/cad-suggest` relays unchanged, and the spend line in
113 * `cadence-core/workflows/report.md` - and a second copy of the list is green
114 * on the day the two stop claiming the same thing. `prose-agreement.test.mjs`
115 * reads THIS array to check the prose, so there is one list and one claim.
116 *
117 * Why these two, and why they are not a hedge:
118 * 1. the orchestrator's own turns - a figure is read off a subagent RETURN
119 * and the coordinator has no return, so it contributes nothing to a total
120 * that most of the run's spend belongs to;
121 * 2. figureless returns - a close that carried no `--tokens`, the advisory
122 * fire among them, counted under `unrecorded` rather than as a zero.
123 *
124 * THREE until v3.7.10, when `'cross-model provider calls'` was DROPPED from
125 * this list - and it must not come back. The entry's stated reason was that the
126 * arm had no lifecycle bracket and no token field at all; the seam now records
127 * the provider's own reported usage on the `provider/request` event,
128 * `planning/trace.mjs` folds it into `provider_spend`, and
129 * `workflows/report.md` prints it on its own `Cross-model reviews` line. That
130 * spend is a DIFFERENT denomination and still never sums into this total, so
131 * the arithmetic did not move - but "excluded" became the wrong word for it,
132 * because it is reported rather than missing, and naming it here would send a
133 * reader hunting for a figure already on the page.
134 *
135 * No third entry is a ratio or a correction factor, and none is coming: the
136 * terms are what MSR-03 and PLN-01 need, and a stored product is the
137 * maintenance loop `v2.7.0` deleted.
138 */
139export const SPEND_EXCLUDES = Object.freeze([
140 "the orchestrator's own turns",
141 'figureless returns',
142]);
143
144/**
145 * A duration in whole minutes, the unit a run record is read in.
146 * @param {number} ms
147 */
148function minutes(ms) {
149 return `${Math.round(ms / 60000)} min`;
150}
151
152/**
153 * The value a config layer (or the caller's schema-default fallback) holds for
154 * `key`, or `undefined` when nothing does. `null` reads as nothing on purpose:
155 * on the keys this seam names it is the sentinel for "no layer pins this", not
156 * a value anybody set.
157 * @param {Resolution|undefined} resolution
158 * @param {string} key
159 */
160function resolved(resolution, key) {
161 const values = resolution && typeof resolution.values === 'object' && resolution.values
162 ? resolution.values
163 : null;
164 if (!values) return undefined;
165 const v = /** @type {any} */ (values)[key];
166 return v === undefined || v === null ? undefined : v;
167}
168
169/**
170 * What an unset key prints as `current`: the refusal `config.mjs get` makes, in
171 * the same words and for the same reason (D-06). It names the DECIDER and never
172 * the value that decider would fire, because printing an effective value
173 * invites the user to set it and pin a key the schema is already answering.
174 *
175 * The decider used to be the routing LEVEL the record carried, interpolated
176 * into the sentence. That level is gone and the schema's own default is what
177 * answers, so the sentence names that instead - and takes no `resolution`
178 * reading at all, because every historical `routing/resolve` row still carries
179 * the retired level string that the resolver no longer produces, and rendering
180 * it would name a decider that does not decide (D-05).
181 */
182function unsetCurrent() {
183 return 'unset: no config layer pins this, so the schema default decides it';
184}
185
186/**
187 * `current` and, where one can be READ rather than guessed, `proposed` - as the
188 * fragment a suggestion spreads into itself. `proposed` is OMITTED rather than
189 * set to null or 0 (D-07/D-12), the omit-not-zero rule `--turns` already
190 * follows: a key nobody computed a target for must be invisible, not zero.
191 * @param {Resolution|undefined} resolution
192 * @param {string} key
193 * @param {(current: any) => any} [target] priced only when the key is SET
194 */
195function keyState(resolution, key, target) {
196 const value = resolved(resolution, key);
197 const proposed = value === undefined || !target ? undefined : target(value);
198 return {
199 current: value === undefined ? unsetCurrent() : value,
200 ...(proposed === undefined ? {} : { proposed }),
201 };
202}
203
204/**
205 * The rung the record shows a role's escalated resolves landing on, kept only
206 * where it names an actual RAISE. A rung a config layer SET is compared against
207 * it on the caller's rung ladder and must sit strictly BELOW it, so a target
208 * equal to the current rung - a retune that changes nothing - or under it -
209 * a target contradicting the `raise` it ships beside - is omitted instead.
210 * An UNSET key has no rung to compare and keeps the target: the record's rung
211 * is still a change from a default nobody stated. No ladder means no
212 * comparison and no target, the same omission `oneStepDown` reports.
213 * @param {Resolution|undefined} resolution
214 * @param {string} key
215 * @param {string|undefined} rung
216 */
217function raiseTarget(resolution, key, rung) {
218 if (!rung) return undefined;
219 const current = resolved(resolution, key);
220 if (current === undefined) return rung;
221 const rungs = resolution && Array.isArray(resolution.rungs) ? resolution.rungs : null;
222 if (!rungs) return undefined;
223 const i = rungs.indexOf(current);
224 return i >= 0 && rungs.indexOf(rung) > i ? rung : undefined;
225}
226
227/**
228 * One step DOWN the gate ladder `config.schema.json` states, or `undefined` when
229 * there is no ladder, the value is not on it, or it is already the bottom rung.
230 * The ladder is the caller's: an absent one omits `proposed`, and that omission
231 * IS the report - no ladder is substituted from memory here.
232 * @param {string[]|undefined} gates
233 * @param {any} value
234 */
235function oneStepDown(gates, value) {
236 if (!Array.isArray(gates)) return undefined;
237 const i = gates.indexOf(value);
238 return i > 0 ? gates[i - 1] : undefined;
239}
240
241/**
242 * Parse an adjudication EVENT: the trigger and survivor count out of its
243 * `<trigger>: <n> survivors; voices <...>` detail line (review-triggers.md
244 * step 5's shape), and the RAISED count - how many findings the reviewers put
245 * up before adjudication killed them.
246 *
247 * A bare detail STRING is accepted as well as the event, because the trigger
248 * and survivor half has always been readable from the string alone and callers
249 * that only hold one must keep working.
250 *
251 * Resolution order for `raised`, and it is the whole point of the widening:
252 * 1. the event's structured `raised` field (planning.mjs `--raised`);
253 * 2. else a legacy `of <m>` clause written into the detail by hand, before
254 * the flag existed - read only immediately after the survivor count, so a
255 * stray "of" further down the voice list cannot be mistaken for one;
256 * 3. else `null`, meaning UNKNOWN - never 0. A fire whose raised count
257 * nobody recorded is not a fire that raised nothing, and collapsing the
258 * two is the exact conflation the flag exists to end.
259 *
260 * The trigger/survivor regex stays as permissive as it has always been: D-03
261 * measured that tightening it drops the historical fires already on disk and
262 * takes R1's evidence floor down with them.
263 *
264 * A RE-ARM round's adjudication is spelled `<trigger> rearm:` or
265 * `<trigger> re-arm:` on disk - both spellings live in this project's own
266 * record, written by hand months apart - and both read as the BASE trigger
267 * carrying `rearm: true` (D-04). Never a trigger of its own: that would mint
268 * the phantom config key `review.triggers.risk_surface rearm.gate`, which this
269 * file's own schema test refuses. Those two spellings are the ONLY embedded
270 * space admitted; any other token with a space in it stays unparseable exactly
271 * as it is today, because counting it as a fire would feed R1 evidence it does
272 * not have.
273 * @param {unknown} input an adjudication event, or its detail string
274 * @returns {{trigger: string, survivors: number, raised: number|null,
275 * rearm: boolean}|null}
276 */
277export function parseAdjudication(input) {
278 const event = typeof input === 'string' ? { detail: input } : input;
279 if (!event || typeof event !== 'object') return null;
280 const detail = /** @type {any} */ (event).detail;
281 if (typeof detail !== 'string') return null;
282 const trimmed = detail.trim();
283 const m = /^([a-z_]+)(?:\s+(re-?arm))?:\s*(\d+)\s+survivors?\b/.exec(trimmed);
284 if (!m) return null;
285 const field = /** @type {any} */ (event).raised;
286 let raised = null;
287 if (typeof field === 'number' && Number.isInteger(field) && field >= 0) {
288 raised = field;
289 } else {
290 const legacy = /^\s*of\s+(\d+)\b/.exec(trimmed.slice(m[0].length));
291 if (legacy) raised = Number(legacy[1]);
292 }
293 return { trigger: m[1], survivors: Number(m[3]), raised, rearm: Boolean(m[2]) };
294}
295
296/**
297 * All suggestions the render supports, most actionable first (`suggest`
298 * before `info`, then by subject for a stable order tests can pin).
299 * @param {RenderLike} render
300 * @param {Resolution} [resolution] the values the caller read off disk for the
301 * keys these rules name - absent, every keyed suggestion still carries a
302 * direction and reports its `current` as unset.
303 * @param {InDispatchReads} [reads] the per-role in-dispatch file figures
304 * `lib/read-trace.mjs`'s `inDispatchReads` folded off `.planning/reads.jsonl`,
305 * for the same reason `resolution` is a parameter and not a read: the rules
306 * stay pure and the caller owns every open. Absent - which is every one- and
307 * two-argument call - R7 stays silent and nothing else changes.
308 * @returns {Suggestion[]}
309 */
310export function suggestFromRender(render, resolution, reads) {
311 /** @type {Suggestion[]} */
312 const out = [];
313 const events = Array.isArray(render.events) ? render.events : [];
314
315 // --- gather ---------------------------------------------------------------
316 // One row per FIRE, in file order, because that is the unit a re-arm veto
317 // acts on (D-03). A trigger's lifetime totals cannot carry the veto: nothing
318 // prunes `.planning/trace.jsonl` at a close, so a re-arm recorded in one
319 // cycle muted its trigger four cycles after the gate stopped finding
320 // anything. The record now ROTATES at its size bound (TRC-08), which bounds
321 // "the life of the file" at that cut rather than leaving it permanent - and
322 // changes nothing here: a bound measured in mebibytes is not a scoping rule,
323 // and one row per fire is what makes the veto act on the fire it belongs to.
324 /** @type {{corr: string, trigger: string, survivors: number, raised: number|null,
325 * rearm: boolean, vetoed: boolean}[]} */
326 const fires = [];
327 /** @type {Set<string>} */
328 const rearmed = new Set();
329 /**
330 * The correlation id an event joins on, as a comparable string.
331 * @param {any} e
332 */
333 const corrOf = (e) => (typeof e.corr === 'string' || typeof e.corr === 'number' ? String(e.corr) : '');
334 /**
335 * The authorization an override receipt descends from, as a comparable
336 * string - the SAME guard `corrOf` applies to `corr`, because this value is a
337 * join key too and an object or an array must not become the group key
338 * `[object Object]`. Empty string means unlabelled, which R9 reads as an
339 * unknown rather than as a shared answer. Trimmed for the reason the writer
340 * trims it: a padded copy of an id must not read as a second decision.
341 * @param {any} e
342 */
343 const authOf = (e) => (typeof e.authorization_id === 'string' || typeof e.authorization_id === 'number'
344 ? String(e.authorization_id).trim()
345 : '');
346 /**
347 * Per role: the resolve counts R3 reads, and the rung its ESCALATED resolves
348 * actually landed on, off the `effort` field those events carry. That rung is
349 * R3's `proposed` - a rung the routing table really resolved for this role,
350 * rather than a legal one it would never produce (D-07).
351 * @type {Map<string, {resolves: number, escalated: number, rung?: string}>}
352 */
353 const rungs = new Map();
354 /** @type {Map<string, number>} */
355 const checkpoints = new Map();
356 /**
357 * Per trigger, R9's two figures: the override receipts WRITTEN, and the
358 * distinct authorizations that stood behind them. The labelled ids go in the
359 * set and the rest are counted, because an unlabelled receipt is its own
360 * decision - see R9 for why that half is load-bearing.
361 * @type {Map<string, {writes: number, ids: Set<string>, unlabelled: number}>}
362 */
363 const overrides = new Map();
364
365 for (const e of events) {
366 if (!e || typeof e !== 'object') continue;
367 if (e.family === 'outcome' && e.event === 'adjudication') {
368 const parsed = parseAdjudication(e);
369 if (!parsed) continue;
370 fires.push({
371 corr: corrOf(e),
372 trigger: parsed.trigger,
373 survivors: parsed.survivors,
374 raised: parsed.raised,
375 rearm: parsed.rearm,
376 vetoed: false,
377 });
378 } else if (e.family === 'outcome' && e.event === 'rearm') {
379 const trigger = typeof e.detail === 'string' ? e.detail.trim() : '';
380 if (!trigger) continue;
381 rearmed.add(trigger);
382 // The veto lands on exactly ONE fire: the nearest fire BEFORE this one in
383 // the same `(corr, trigger)` group - the fire that forced the round.
384 // Nearest rather than oldest, because an earlier fire in the same phase
385 // was answered by its own adjudication and this round says nothing about
386 // it. A re-arm round's OWN adjudication is skipped: it is the second
387 // round's RESULT, not the fire that forced the round. A fire already
388 // vetoed is skipped too, so two re-arms mute two fires rather than one.
389 const corr = corrOf(e);
390 for (let i = fires.length - 1; i >= 0; i--) {
391 const f = fires[i];
392 if (f.trigger === trigger && f.corr === corr && !f.rearm && !f.vetoed) {
393 f.vetoed = true;
394 break;
395 }
396 }
397 } else if (e.family === 'routing' && e.event === 'resolve') {
398 const role = typeof e.role === 'string' ? e.role : '';
399 if (!role) continue;
400 const row = rungs.get(role) || { resolves: 0, escalated: 0 };
401 row.resolves++;
402 // Either spelling of a climb counts: the seam's own `escalated` flag, or
403 // a retry attempt (`--attempt 2`) that lands on the retry rung.
404 if (e.escalated === true || (typeof e.attempt === 'number' && e.attempt >= 2)) {
405 row.escalated++;
406 if (typeof e.effort === 'string' && e.effort.trim()) row.rung = e.effort.trim();
407 }
408 rungs.set(role, row);
409 } else if (e.family === 'lifecycle' && e.event === 'checkpoint') {
410 const role = typeof e.role === 'string' ? e.role : '';
411 if (!role) continue;
412 checkpoints.set(role, (checkpoints.get(role) || 0) + 1);
413 } else if (e.family === 'outcome' && e.event === 'override') {
414 // The trigger comes off the STRUCTURED field and is never parsed out of
415 // `detail` (D-12), the same rule `risk-check status` holds when it reads
416 // these events: on this repository's own record the trigger is spelled
417 // four different ways in that free text. An override carrying no
418 // structured trigger reaches no reader today - the gate filters on the
419 // same field - so it is grouped by nothing here either.
420 const trigger = typeof e.trigger === 'string' ? e.trigger.trim() : '';
421 if (!trigger) continue;
422 const row = overrides.get(trigger) || { writes: 0, ids: new Set(), unlabelled: 0 };
423 row.writes++;
424 const id = authOf(e);
425 if (id) row.ids.add(id);
426 else row.unlabelled++;
427 overrides.set(trigger, row);
428 }
429 }
430
431 // --- rules ----------------------------------------------------------------
432 // R1: an adjudicated trigger that keeps coming back empty. Read a FIRE at a
433 // time: a fire counts as evidence when it adjudicated zero survivors and no
434 // re-arm came back to it - a gate that forced a fix round has already paid
435 // for itself on THAT fire, whatever its adjudication said, and says nothing
436 // about the other fires the same trigger had. The evidence names the empty
437 // count out of the trigger's fires overall, so a reader sees the productive
438 // fires beside the empty ones instead of a bare total.
439 //
440 // Two OUTCOMES on the same evidence floor, because "nothing survived" means
441 // two opposite things (D-16). Nothing raised at all is a gate finding
442 // nothing; nine raised and nine killed is a gate doing real work in front of
443 // a reviewer that cannot tell a finding from an opinion - and proposing to
444 // turn that gate off is the wrong move on the same row. The raised total is
445 // summed over the EMPTY fires alone, and an UNKNOWN raised count contributes
446 // 0 rather than being invented, so every trace written before `--raised`
447 // existed keeps landing on the gate arm it lands on today.
448 /** @type {Map<string, {total: number, empty: number, raised: number}>} */
449 const triggers = new Map();
450 for (const f of fires) {
451 const row = triggers.get(f.trigger) || { total: 0, empty: 0, raised: 0 };
452 row.total++;
453 if (!f.vetoed && f.survivors === 0) {
454 row.empty++;
455 row.raised += f.raised === null ? 0 : f.raised;
456 }
457 triggers.set(f.trigger, row);
458 }
459 for (const [trigger, row] of [...triggers.entries()].sort()) {
460 if (row.empty >= MIN_FIRES_FOR_GATE_SUGGESTION) {
461 // The two arms move OPPOSITE ways, which is the whole reason the split
462 // exists: the gate arm's evidence is fires that keep coming back empty,
463 // so the move is DOWN the ladder; the reviewer arm's evidence is a gate
464 // catching work in front of a reviewer set that killed all of it, so the
465 // move is to STRENGTHEN that set. `raise`/`lower` is the whole vocabulary
466 // - widening it is a schema-shaped decision, and `lower` on the reviewer
467 // arm would name the opposite move.
468 out.push(row.raised > 0
469 ? {
470 kind: 'suggest',
471 subject: `${trigger} reviewers`,
472 evidence: `${row.empty} of ${row.total} adjudicated fire(s), 0 survivors of ${row.raised} raised`
473 + ' - the gate caught work; the reviewer set is what looks miscalibrated',
474 action: 'review.reviewers',
475 direction: 'raise',
476 // No `proposed`: which backend to add is not a thing the record
477 // names, and a guessed reviewer set beside two measured targets is
478 // the credibility the no-fabricated-figures guardrail protects.
479 ...keyState(resolution, 'review.reviewers'),
480 }
481 : {
482 kind: 'suggest',
483 subject: trigger,
484 evidence: `${row.empty} of ${row.total} adjudicated fire(s), 0 survivors, no re-arm`,
485 action: `review.triggers.${trigger}.gate`,
486 direction: 'lower',
487 // Priced only off a value a LAYER set: an unset gate has no position
488 // on the ladder to step down from, and reading the level's value to
489 // find one is exactly what D-06 refuses.
490 ...keyState(resolution, `review.triggers.${trigger}.gate`,
491 (current) => oneStepDown(resolution && resolution.gates, current)),
492 });
493 }
494 }
495
496 // R2: a gate that caught real work. Receipt only - nothing to change.
497 for (const trigger of [...rearmed].sort()) {
498 out.push({
499 kind: 'info',
500 subject: trigger,
501 evidence: 'a fire FAILed and re-armed on its own fix - the gate caught real work; keep it',
502 action: null,
503 });
504 }
505
506 // R3: escalation pressure per role, both directions.
507 for (const [role, row] of [...rungs.entries()].sort()) {
508 if (row.escalated >= MIN_ESCALATIONS_FOR_RUNG_SUGGESTION) {
509 // The target is a rung the record SHOWS this role's escalated resolves
510 // landing on, never a step guessed off `rung_order`: a rung the routing
511 // table actually resolved cannot be one the table would never produce.
512 // It still has to name a CHANGE against the rung in force - see
513 // `raiseTarget`.
514 const proposed = raiseTarget(resolution, `model.effort.${role}`, row.rung);
515 out.push({
516 kind: 'suggest',
517 subject: role,
518 evidence: `${row.escalated} of ${row.resolves} resolves climbed to the retry rung`,
519 action: `model.effort.${role}`,
520 direction: 'raise',
521 ...keyState(resolution, `model.effort.${role}`),
522 ...(proposed ? { proposed } : {}),
523 });
524 } else if (row.escalated === 0 && row.resolves >= MIN_DISPATCHES_FOR_RUNG_INFO) {
525 out.push({
526 kind: 'info',
527 subject: role,
528 evidence: `start rung held across ${row.resolves} resolves, 0 escalations`,
529 action: null,
530 });
531 }
532 }
533
534 // R4: executor checkpoint pressure. A checkpoint is a fresh-context
535 // continuation paid at full dispatch price; repeated ones say the plans are
536 // outrunning one context.
537 //
538 // SUPPRESSED - not returned with a caveat - when every checkpoint it counted
539 // maps to a readable plan whose task count is UNDER the resolved ceiling
540 // (D-08). A suggestion the evidence does not support is the thing that made
541 // `/cad-suggest` read as a report rather than advice: telling a user to lower
542 // a ceiling their plans never reached is a sentence they can only ignore, and
543 // a caveat printed beside it still leaves the retune list to be sorted by
544 // hand. This is a new class of check, not a tightened floor - the four MIN_*
545 // constants above are untouched.
546 //
547 // The comparison is against the ceiling the suggestion PRINTS as `current`,
548 // never a hardcoded 8 (D-10): a project that raised it to 12 must not be told
549 // to lower one its plans never touched. An unknown count - a checkpoint whose
550 // plan file cannot be read - is never under-ceiling (D-09), so a single one
551 // leaves the rule speaking; a count EQUAL to the ceiling is not under it
552 // either. And with no ceiling resolved at all, or no per-checkpoint counts
553 // passed, there is nothing to bind against and the rule speaks exactly as it
554 // did before this check existed.
555 const execCp = checkpoints.get('cad-executor') || 0;
556 const ceiling = resolved(resolution, 'workflow.max_plan_tasks');
557 const counted = resolution && Array.isArray(resolution.checkpointTasks)
558 ? resolution.checkpointTasks
559 : [];
560 const bounded = typeof ceiling === 'number' && Number.isFinite(ceiling)
561 && counted.length === execCp && execCp > 0
562 && counted.every((n) => typeof n === 'number' && Number.isFinite(n) && n < ceiling);
563 if (execCp >= MIN_CHECKPOINTS_FOR_SIZE_SUGGESTION && !bounded) {
564 out.push({
565 kind: 'suggest',
566 subject: 'cad-executor',
567 evidence: `${execCp} checkpoint return(s) - plans may exceed one context`,
568 action: 'workflow.max_plan_tasks',
569 direction: 'lower',
570 // No `proposed`, and none is derivable: no field in the record names a
571 // plan's task count, so the only target available would be a number
572 // invented here (D-07). The key is OMITTED rather than sent as null.
573 ...keyState(resolution, 'workflow.max_plan_tasks'),
574 });
575 }
576
577 // R5: the spend receipt. Names where the recorded tokens went and what that
578 // total is NOT - the two `SPEND_EXCLUDES` names ride the evidence string
579 // rather than the envelope, because `workflows/suggest.md` relays evidence
580 // unchanged and adds no flag, so this is the only way the caveat reaches a
581 // `/cad-suggest` reader at all. Asks for nothing: still `kind: 'info'`,
582 // still `action: null`, still silent when no role carried a figure, and the
583 // only arithmetic is the share it already computed.
584 const roles = render.roles && typeof render.roles === 'object' ? render.roles : {};
585 let top = null;
586 let total = 0;
587 for (const [role, row] of Object.entries(roles)) {
588 const t = typeof row.tokens === 'number' && Number.isFinite(row.tokens) ? row.tokens : 0;
589 total += t;
590 if (t > 0 && (!top || t > top.tokens)) top = { role, tokens: t };
591 }
592 if (top && total > 0) {
593 out.push({
594 kind: 'info',
595 subject: top.role,
596 evidence: `largest recorded spend: ${top.tokens.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} recorded tokens (${Math.round((top.tokens / total) * 100)}%); excludes ${SPEND_EXCLUDES.join(', ')}`,
597 action: null,
598 });
599 }
600
601 // R6: the coordinator's own share of the run. The counterpart to R5 - that
602 // one names where the TOKENS went, this one names the time no worker was
603 // billed for. Receipt only, and `action` is null on purpose: no
604 // `config.schema.json` key governs coordinator spend, and this file's own
605 // test refuses an action naming a key the schema lacks.
606 //
607 // The figure it relays is CORR-SCOPED (phase 5 D-01): `lib/trace.mjs` keys the
608 // residue accumulators on `corr`, so each run's last marker closes at that
609 // run's own last event and no window spans the clock between two runs that
610 // share a phase number. A `--phase` render can still pool several runs, and
611 // this receipt then relays the sum of their windows - never a span across
612 // them. The evidence string below is unchanged byte for byte: D-02 keeps the
613 // name, which the corrected arithmetic earns rather than outgrows.
614 //
615 // SILENT on a render with no `coordinator` block, never an "absent
616 // coordinator record" line (D-06). Every trace written before the marker
617 // existed - Cadence's own and the committed fixture - would otherwise gain a
618 // suggestion line saying nothing about the run it read.
619 const coord = render.coordinator;
620 const residue = coord && typeof coord.residue_ms === 'number' && Number.isFinite(coord.residue_ms)
621 ? coord.residue_ms
622 : null;
623 if (residue !== null && residue >= MIN_RESIDUE_MS_FOR_COORDINATOR_INFO) {
624 // The figures are the render's own (lib/trace.mjs computes the residue
625 // once, so this rule and `/cad-report` cannot disagree); the only
626 // arithmetic here is the share, and it is skipped rather than divided by a
627 // zero or absent wall.
628 const steps = Array.isArray(coord.steps) ? coord.steps : [];
629 /** @type {{step: any, residue_ms: number}|null} */
630 let top = null;
631 for (const s of steps) {
632 if (!s || typeof s !== 'object') continue;
633 const ms = typeof s.residue_ms === 'number' && Number.isFinite(s.residue_ms) ? s.residue_ms : 0;
634 if (!top || ms > top.residue_ms) top = { step: s.step, residue_ms: ms };
635 }
636 const wall = typeof coord.wall_ms === 'number' && Number.isFinite(coord.wall_ms) ? coord.wall_ms : 0;
637 const share = wall > 0 ? ` (${Math.round((residue / wall) * 100)}% of wall time)` : '';
638 const named = top && typeof top.step === 'string' && top.step
639 ? `, most of it at \`${top.step}\` (${minutes(top.residue_ms)})`
640 : '';
641 out.push({
642 kind: 'info',
643 subject: 'coordinator',
644 evidence: `coordinator time between worker brackets: ${minutes(residue)}${share}${named}`,
645 action: null,
646 });
647 }
648
649 // R7: in-dispatch re-reading, per role. `.planning/reads.jsonl` has recorded
650 // what a worker actually OPENED for four cycles and nothing has ever acted on
651 // the number; this is the rule that does.
652 //
653 // `action` is null, and that is this phase's ANSWER rather than an omission:
654 // no key in `cadence-core/config.schema.json` governs in-dispatch re-reading.
655 // The falsifying check was run over all 78 keys - counted as
656 // `Object.keys(schema.keys).length`, so the denominator is re-runnable - and
657 // the three closest candidates all fail:
658 // - `workflow.max_dispatch_tokens.<role>` is report-only by its own purpose
659 // text; moving it changes when `trace window` complains, never what a
660 // worker opens.
661 // - `workflow.max_plan_tasks` counts TASKS. It was re-decided at 8 in
662 // `v3.5.3` under PLN-01 against cold-prefix cost and context risk,
663 // neither of which is in-dispatch re-reading, and lowering it moves the
664 // same file opens into MORE dispatches rather than removing them -
665 // improving this ratio while raising the bill. R4 above already moves
666 // that key on checkpoint evidence, so a second rule moving it on
667 // unrelated evidence would put two entries for one key in the same list.
668 // - `model.effort.*` and `model.overrides.*` choose a rung and a model.
669 // The remedy that does exist is DISCIPLINE rather than configuration -
670 // symbol or line anchors on a plan's `files:` entries, and targeted reads
671 // over whole-file ones - filed at `.planning/CAPTURE.md:271` as parts 2 and 3
672 // of a three-part fix whose part 1 is the unshipped `workflow.max_plan_tokens`.
673 // Minting a key here to have somewhere to point would ship a key nothing
674 // reads. So the entry names the remedy in WORDS, exactly the precedent R6
675 // sets with its own null action.
676 //
677 // Everything the entry has to state rides the EVIDENCE string - the
678 // direction, the coverage, the scope and the exclusion - because
679 // `cadence-core/workflows/suggest.md` relays evidence unchanged and adds no
680 // flag, the same reason R5 carries `SPEND_EXCLUDES` there rather than on the
681 // envelope. The `Suggestion` vocabulary stays closed: an info entry gains no
682 // `direction`, `current` or `proposed`, which this file's own D-12 test pins
683 // and which `suggest.md`'s ask step depends on, since it builds
684 // `/cad-config <key>=<value>` tokens out of `action` plus `proposed`.
685 //
686 // SILENT - nothing at all, never an entry saying nothing - when the argument
687 // is absent, the role is not in `IN_DISPATCH_FLOORS`, the ratio is null, or
688 // the ratio is under the role's floor. A null ratio is never rendered as `0`:
689 // that is the reading which says the worker opened each file once.
690 const inDispatch = reads && Array.isArray(reads.roles) ? reads.roles : [];
691 for (const row of inDispatch) {
692 if (!row || typeof row !== 'object') continue;
693 const floor = Object.prototype.hasOwnProperty.call(IN_DISPATCH_FLOORS, row.role)
694 ? IN_DISPATCH_FLOORS[/** @type {keyof typeof IN_DISPATCH_FLOORS} */ (row.role)]
695 : undefined;
696 if (floor === undefined) continue;
697 const ratio = typeof row.ratio === 'number' && Number.isFinite(row.ratio) ? row.ratio : null;
698 if (ratio === null || ratio < floor) continue;
699 const worst = row.worst;
700 // A non-null ratio has at least one counted file behind it, so this guard
701 // is unreachable - it is here so the sentence below can never name a file
702 // nothing measured.
703 if (!worst || typeof worst.path !== 'string') continue;
704 const where = worst.phase == null && worst.plan == null
705 ? ''
706 : ` (phase ${worst.phase ?? '?'}, plan ${worst.plan ?? '?'})`;
707 const coverage = typeof reads.coverage === 'number' && Number.isFinite(reads.coverage)
708 ? `${Math.round(reads.coverage * 100)}% of the joined reads in scope, the share that recorded file paths`
709 : 'an unmeasured share of the joined reads in scope';
710 const excluded = typeof reads.coordinatorFiles === 'number' && Number.isFinite(reads.coordinatorFiles)
711 ? reads.coordinatorFiles
712 : 0;
713 out.push({
714 kind: 'info',
715 subject: row.role,
716 evidence: `in-dispatch re-reading: ${ratio} opens per distinct file inside one dispatch`
717 + ` over ${row.brackets} dispatch(es), and DOWN is the direction that helps`
718 + ` - worst inside one dispatch: read \`${worst.path}\` ${worst.count} times${where}.`
719 + ` Computed over ${coverage}.`
720 + ' SCOPE: nothing prunes `.planning/reads.jsonl` at a milestone close, and the one thing'
721 + ' that ever shortens it is the cut at its size bound, which moves the older generation'
722 + ' to a sibling no fold here reads - so an unscoped run reaches every milestone still in'
723 + ' the LIVE record, and `reads.rotated` on this envelope says whether the record was cut.'
724 + ` Excludes ${excluded.toLocaleString('en-US')} coordinator read(s) carrying files:`
725 + ' the main thread has no dispatch bracket by construction, so its re-reading cannot be'
726 + ' attributed to one and cannot be measured here.'
727 + ' No key in `config.schema.json` governs in-dispatch re-reading - the remedy is discipline, not'
728 + " configuration: symbol or line anchors on a plan's `files:` entries, and targeted reads"
729 + ' over whole-file ones.',
730 action: null,
731 });
732 }
733
734 // R8: the worker wall clock, the receipt denominated in the figure the HOST
735 // reported for the worker itself. The counterpart to R6 from the other end:
736 // that one names the time no worker was billed for, this one names the time
737 // the workers themselves reported. Receipt only, `action` null, for R6's
738 // reason - no `config.schema.json` key governs how long a worker runs.
739 //
740 // The two clocks are named APART in the evidence, the way `lib/trace.mjs`'s
741 // `TraceRender` typedef names them under TWO ELAPSED FIGURES: a bracket's
742 // `ms` is dispatch-to-close and includes whatever the orchestrator did
743 // between the two writes, while `duration_ms` is what the host reported for
744 // the worker. A reader handed one figure and no name for it would price a
745 // worker with the step's clock.
746 //
747 // The dispatches whose close carried no wall clock are COUNTED beside the sum
748 // rather than folded in as zeros (D-04) - a zero would claim a worker that
749 // took no time, which is not a measurement anyone made.
750 //
751 // SILENT - nothing at all, never an entry saying nothing - when no bracket in
752 // scope carries a `duration_ms`. That is R6's posture for a render with no
753 // coordinator block, and it is the only reading that fits the record: with 6
754 // of 386 live brackets carrying one (measured 2026-08-26), a scope where none
755 // does has no figure to denominate a receipt IN, rather than a run that took
756 // no worker time. R6's `coordinator.residue_ms` is NOT re-based on this
757 // figure for the same measurement (D-02): 380 of those brackets would
758 // contribute zero worker time and fire R6 on every run.
759 const brackets = Array.isArray(render.brackets) ? render.brackets : [];
760 let workerMs = 0;
761 let priced = 0;
762 let silent = 0;
763 for (const b of brackets) {
764 if (!b || typeof b !== 'object') continue;
765 const d = typeof b.duration_ms === 'number' && Number.isFinite(b.duration_ms)
766 ? b.duration_ms : null;
767 if (d === null) { silent++; continue; }
768 workerMs += d;
769 priced++;
770 }
771 if (priced > 0) {
772 out.push({
773 kind: 'info',
774 subject: 'workers',
775 evidence: `worker wall clock reported by the host: ${minutes(workerMs)} across`
776 + ` ${priced} dispatch(es)`
777 + (silent > 0
778 ? `, with ${silent} more whose close carried none - unrecorded, never counted as zero`
779 : '')
780 + '. This is the WORKER\'s own run time and not the dispatch-to-close `ms`'
781 + " /cad-report prints beside it, which includes the orchestrator's own time"
782 + ' between the two writes.',
783 action: null,
784 });
785 }
786
787 // R9: one human authorization, written as two receipts (AUT-03). An override
788 // re-applied to a second range is TWO events by construction - `risk-check
789 // status` requires every fired range to carry a receipt naming its own base
790 // and head, and a shared id never changes that (D-03) - so nothing on either
791 // event said the pair came from one answer, and a reader could not tell it
792 // from one range settled twice by hand. `trace append --authorization-id`
793 // carries the id the coordinator minted when the engineer answered, and this
794 // rule counts DECISIONS against WRITES off it.
795 //
796 // An UNLABELLED receipt counts as its OWN decision, and that half is the
797 // load-bearing one: every override written before the flag existed carries no
798 // id, and reading those as one shared answer would report a reuse on every
799 // trace already on disk. Unrecorded is never a match - the same disposition
800 // the token and turn totals take.
801 //
802 // SILENT where the two figures agree, which is every unlabelled trace and
803 // every run whose overrides were genuinely separate answers: `2 decisions
804 // from 2 writes` is a line added to every render that says nothing about the
805 // run it read, which is R6's disposition for a render with no coordinator
806 // block. `action` is null because no `config.schema.json` key governs this -
807 // the receipt is for a reader, not a retune.
808 for (const [trigger, row] of [...overrides.entries()].sort()) {
809 if (row.writes < MIN_OVERRIDES_FOR_AUTHORIZATION_INFO) continue;
810 const decisions = row.ids.size + row.unlabelled;
811 if (decisions >= row.writes) continue;
812 out.push({
813 kind: 'info',
814 subject: trigger,
815 evidence: `${row.writes} override receipt(s) on ${decisions} authorization(s)`
816 + ` - ${decisions} decision(s) from ${row.writes} writes, so one answer applied to a`
817 + ' second range is distinguishable from a duplicate write of one range. The shared id'
818 + ' LABELS that pair only: every fired range still carries a receipt naming its own'
819 + ' base and head.',
820 action: null,
821 });
822 }
823
824 out.sort((a, b) => (a.kind === b.kind ? (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0) : a.kind === 'suggest' ? -1 : 1));
825 return out;
826}
827