SLOPSHOPPER

Cadence

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…

newpanebandrowsguardcommand
★ 6v3.8.6MITupdated 2026-10-07crenshawdev/cadence
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cadence
│ ┃ cadence ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ Cadence n next │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ Cadence · reading… │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cad-panel │ ⎿ cadence: No .planning/ here, so there is no Cadence pane to open │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cadence
╭──────────────────────────────────────────────────────────╮ │ ◆ Cadence n next │ │ │ │ Cadence · reading… │ ╰──────────────────────────────────────────────────────────╯
README

Cadence

Test Release Latest release License: MIT

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.

Running /cadence:cad-progress in the Verbatim repo. Cadence reports phase 1 of 4 executed with its SUMMARY written and UAT not passed, lists the three unplanned phases after it, confirms the state cursor agrees with disk, and offers to run /cad-verify 1.

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.

Install

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.

The loop

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:

  1. /cad-new-project define the project through deep questioning: what, why, who, done.
  2. /cad-context <phase> gather locked decisions and acceptance criteria before planning.
  3. /cad-plan <phase> turn a phase into an executable, checkable plan.
  4. /cad-execute <phase> build it, one atomic commit per task.
  5. /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.

The Cadence phase loop: new-project feeds context, plan, execute and verify in sequence; a decision gate sits under each command, and verify loops back to context for the next phase or exits to milestone.

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.

The pane

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.

The Cadence pane docked beside the transcript. Phase 9 of 9, Bounded guard process and storage access, status executing, next /cad-execute 9. The plans bar reads 1 of 3, with PLAN-1.md checked and PLAN-2.md and PLAN-3.md open. One agent is running, cad-executor at rung xhigh, its model unrecorded. Two captures are open, and spend reads 2,745,857 tokens with 3 dispatches unrecorded, excluding the orchestrator's own turns and figureless returns.

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.

The controls

Eight of them, and every one hands its decision to you rather than deciding for you.

ControlWhere it firesWhat it does
Plan reviewbefore any code is writtenan adversarial reviewer tries to break the plan, findings come back as a numbered list you triage
Risk surfaceon each plan's completed commit rangechecks the diff against eight named surfaces, and blocks on a match by default
Push railevery git push a workflow attemptsa 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 brancha commit on main or masterasks, refuses, or allows, per git.on_protected
Verificationafter a phase is builtconversational UAT plus a goal-backward pass, claims scored verified, failed, or uncertain
Traceability auditbefore a release ships/cad-audit traces every requirement to a phase, a plan and a verification, both directions
Coverage auditon a completed phase/cad-coverage reads the assertions rather than counting test files
The recordevery dispatch, always.planning/trace.jsonl prices each subagent, /cad-report reads it back as receipts

Two of those rows, at work:

The deep verifier finishing a goal-backward pass over phase 1's eight UAT items. It reports 6 of 8 passed and 2 failed, and for the failed item it separates a criterion that no developer-run test asserts from a debug-profile cost that belongs to an earlier release, then asks how the criterion should be resolved.

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.

Running /cadence:cad-audit. The audit returns PASS over the one active requirement in REQUIREMENTS.md, showing INJ-07 traced to phase 1 and phases/1/PLAN-1.md with its verification box checked, counts of 1 traced and 0 broken, criteria coverage of 6 of 6, and no dropped, orphan or version-drift entries.

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.

How it works

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.

What Cadence sends, writes and reads

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.

Cross-model reviewers

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.

ProviderHostKey
OpenAIhttps://api.openai.comOPENAI_API_KEY
Geminihttps://generativelanguage.googleapis.comGEMINI_API_KEY
DeepSeekhttps://api.deepseek.comDEEPSEEK_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.

Your forge

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;
  • PR or MR create and merge at /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.

Optional MCP tools

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.

The module

On a host with mods, the module does five things:

  • draws the band and the /cad-panel pane from planning.mjs reads, the files under .planning/ and the token usage the host reports for each request;
  • filters Cadence's 30 agents and 6 contract skills out of the agent and skill listings the model sees, about 8,000 characters Claude would otherwise reread on every request in every project, while Cadence's commands still dispatch those agents by name;
  • rewrites the Agent tool's subagent_type from a bare Cadence agent name to the plugin-prefixed one, and leaves your own agents' names alone;
  • adds --agent-id <id> to a Cadence subagent's own planning.mjs trace close command, so the record joins it to the right dispatch;
  • appends token-count facts to .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.

The two hooks that watch

  • 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.

Outside the project

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;
  • the worktree.baseRef key, merged into .claude/settings.json or ~/.claude/settings.json, only after you pick the file at /cad-config;
  • scratch directories from 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.

Why every command keeps Bash open

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:

  • Cadence's own seam calls are typed by the model, and the form varies, a quoted ${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.
  • Workflows run compound scratch lines with node -e read-backs and case ... esac guards. The only rule that covers those allows arbitrary code, which narrows nothing.
  • Some commands run things with no fixed form at all. /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.

Privacy

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.

What each role costs

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

Source 12 files
hooks/cadence-mod.mjs 669 lines
1// @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}
669
cadence-core/bin/lib/git-segments.mjs 192 lines
1// @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}
192
cadence-core/bin/lib/state-cursor.mjs 54 lines
1// @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}
54
cadence-core/bin/lib/band.mjs 159 lines
1// @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}
159
cadence-core/bin/lib/rung-agent.mjs 608 lines
1// @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}
608
cadence-core/bin/lib/agent-prefix.mjs 56 lines
1// @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}
56
cadence-core/bin/lib/listing-filter.mjs 85 lines
1// @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}
85
cadence-core/bin/lib/token-capture.mjs 174 lines
1// @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}
174
cadence-core/bin/lib/pane.mjs 465 lines
1// @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}
465
cadence-core/bin/lib/pane-view.mjs 335 lines
1// @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}
335
cadence-core/bin/lib/cache-meter.mjs 138 lines
1// @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}
138
cadence-core/bin/lib/trace-suggest.mjs 827 lines
1// @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