SLOPSHOPPER

actual-cli

Feature-complete companion for the actual CLI — runs and troubleshoots sync, status, auth, config, runners, and models across all 5 runners and 3 output…

newpanebandguardcommandtoast
★ 5v1.4.0Apache-2.0updated 2026-10-16actual-software/actual-skill
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · actual-cli
│ ┃ Rule briefing ✕ › fix the failing auth test and add an audit log call │ ┃ Rule briefing this session │ ┃ ⏺ Read(src/auth.ts) │ ┃ No reads yet in this session. ⎿ Read 6 lines │ ⏺ 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 │ │ › /rules-brief │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rule briefing
Rule briefing this session No reads yet in this session.
README

<h1 align="center">Actual.ai CLI Skill</h1>

<a href="https://app.actual.ai"> <img src="assets/logo.png" alt="Actual.ai" width="160"> </a>

<a href="https://github.com/actual-software/actual-skill/blob/main/LICENSE"><img src="https://img.shields.io/github/license/actual-software/actual-skill" alt="License"></a> <a href="https://github.com/actual-software/actual-skill/stargazers"><img src="https://img.shields.io/github/stars/actual-software/actual-skill?style=flat" alt="Stars"></a> <a href="https://github.com/actual-software/actual-skill/issues"><img src="https://img.shields.io/github/issues/actual-software/actual-skill" alt="Issues"></a>

Quickstart

Install Actual.ai Skill — Claude Code CLI

Run each step as its own copy/paste.

Step 1 — add this repo as a marketplace:

/plugin marketplace add actual-software/actual-skill

Step 2 — install the plugin:

/plugin install actual-cli@actual-cli-skills

Step 3 — reload plugins so Claude Code CLI loads the newly installed plugin:

/reload-plugins

Step 4 — run the skill in your coding agent:

/actual

The first time you run it, the skill asks for your permission to install the Actual.ai CLI for you. For example:

/actual

The `actual` CLI is not installed. Should I install it with:

    npm install -g @actualai/actual

Then I'll verify it with `actual --version`.
$skill-installer install the actual skill from actual-software/actual-skill

On first use, the actual skill will offer to install the actual CLI if it is not already on your PATH. For example:

$actual

The `actual` CLI is still not installed. Should I install it with:

    npm install -g @actualai/actual

Then I'll verify it with `actual --version`.

Clone and symlink to your global skills directory:

git clone https://github.com/actual-software/actual-skill.git ~/.local/share/actual-skill

# For OpenCode
ln -s ~/.local/share/actual-skill/skills/actual ~/.config/opencode/skills/actual

# For Cursor
ln -s ~/.local/share/actual-skill/skills/actual ~/.cursor/skills/actual

# For Claude Code (alternative to marketplace)
ln -s ~/.local/share/actual-skill/skills/actual ~/.claude/skills/actual

# For Codex (alternative to $skill-installer)
ln -s ~/.local/share/actual-skill/skills/actual ~/.agents/skills/actual

Use Actual.ai Skill

Once installed, ask your agent in plain language. For example:

  • "Set up Actual for this repository."
  • "Preview the ADR guidance Actual would add here."
  • "Ask the Actual advisor how we should handle database access in a new service."
  • "Sign me in to Actual AI."
  • "Diagnose why Actual is failing."

In Codex, you can also call the skill directly by starting the prompt with $actual.

What is the Actual.ai Skill?

The Actual.ai Skill gives your AI coding agents architectural guardrails, keeping every plan and change grounded in the ADRs your team has already written.

Why do I need the Actual.ai Skill?

Coding agents don't know the architecture decisions your team has already made, so they write code that ignores them. The skill gives your agent your team's ADRs as context, so its code follows your architecture from the start.

Who is the Actual.ai Skill for?

AI-native software teams: developers who build with coding agents and want those agents to follow the architecture decisions their team has already made.

Where does the Actual.ai Skill work?

Inside the coding agent your team already uses: Claude Code, Codex, ChatGPT, Cursor, or OpenCode. It works in any repository with architecture rules committed in .actual/rules/, and stays silent in repositories without them.

When does the Actual.ai Skill run?

Whenever an architecture question comes up. Before your agent writes code, it asks your ADRs how your team builds things and gets an answer that cites its sources. In Claude Code, the skill also checks the plan before implementation starts and the changes at the end of every turn.

How does the Actual.ai Skill work?

ADR-backed context

The skill runs actual adr-bot, which reads your repository, fetches your team's ADRs from Actual AI, and writes guidance tailored to your codebase into the file your agent reads: CLAUDE.md, AGENTS.md, or Cursor rules. Your agent starts every session already knowing how your team builds things.

Architecture answers

When your agent hits an architecture question, the skill asks the Actual advisor. The advisor answers from your organization's ADRs and cites its sources, so you can trace every recommendation back to a decision your team made.

Governance

In Claude Code, the skill checks your agent's work against the architecture rules committed in .actual/rules/. When your agent leaves plan mode, its plan is checked before you see the approval dialog. At the end of every turn, its changes are checked too. A conflict goes back to the agent with the reason, and the turn continues until it's fixed.

Your agent is also briefed as it reads. When it opens a file covered by a rule, the rules governing that file are surfaced before it makes its first edit. A file it writes without reading first — usually a new one — is briefed when it writes it instead; that brief arrives with the result, after the write, so it is a reminder that can prompt a revision. Editing a file already briefed by its read adds nothing. Briefing is advisory: it never blocks anything, and it needs a CLI new enough to have actual rules brief — if yours isn't, the two checks above still run and the session start tells you so.

Reads through the shell count too, and they are most of them. Measured across 212 local sessions, four in five file reads went through cat, head, sed -n or grep rather than the Read tool, and a hook never sees those. A Claude Code mod covers them, and where it loads it takes over the Read tool too, so both kinds of read go through one path with one context budget, one record of what has already been said, and one way of reporting it. Reading a file twice does not brief it twice, whichever way you read it. The mod needs Claude Code 2.1.287 or later; on 2.1.285 and 2.1.286 set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to load it. Without it the Read path still works on its own, without the cap or the report below.

Where the mod loads, briefing is capped so it cannot crowd out your agent's context: 48,000 characters per session by default, which is roughly two and a half times what a 40-file session was measured to use against a 425-rule repository. When the cap is reached your agent is told once, rather than briefing going quietly dead.

You can see briefing happen. Each read of a governed file reports one line to the transcript, the same shape every time — a state in caps at a fixed column, then what arrived, then the files:

actual-cli: ▌ BRIEFED    2 ADRs · 8 of 35 rules    tokens.ts
actual-cli: ▌ NO CHANGE  briefed earlier  tokens.ts
  • A governed file that briefs nothing says why, rather than leaving you to guess: nothing new for it, the budget spent, or the CLI could not answer.
  • A read of files nothing governs reports nothing, once the CLI says so. Most files in a repository are ungoverned, and a line on each would bury the reads that matter. Until that ships in the CLI (actual-cli#867), such a read logs a "briefed earlier, or ungoverned" row instead.
  • /rules-brief lists the session, in the order it happened, those quiet reads included, with the reason beside each one: the last 40 of at most 200 records.

None of this governs: it never touches the Stop verdict, which is the implementation gate's. None of it costs your agent context either, because the model never reads these lines — they are not counted against the cap above. It does need a terminal or the desktop app: nothing is reported in claude -p, the SDK, the VS Code chat panel or a cloud session, though briefing itself still runs there.

Governance never gets in the way of unrelated work. Outside a repository with .actual/rules/, the checks do nothing. If the CLI is missing, outdated, or crashes, you get a message instead of a block. A rule that stays unresolved stops blocking after three denied rounds per session. To clear a denied rule for the rest of the session, run actual check-override --session <id> --rule <doc-slug>::<rule-id> --reason "..." from a terminal.

Set ACTUAL_PLAN_GATE=off to turn governance off, ACTUAL_RULES_BRIEF=off to turn off briefing alone while the checks keep running, or ACTUAL_RULES_DIR to use a different rules directory. The briefing limits have their own variables — ACTUAL_RULES_BRIEF_MAX_SESSION_CHARS for the per-session cap and ACTUAL_RULES_BRIEF_LIMIT, _RULES_PER_DECISION, _MAX_CHARS and _MIN_SCORE for actual rules brief's own limits — and the same settings appear as labelled rows under /plugin. _MIN_SCORE is worth knowing about if briefing feels noisy: a rule's score reflects how narrowly its verify path is written, so the useful floor differs per repository and there is no good default.

The hooks also stay silent inside the claude subprocesses actual itself spawns (marked with ACTUAL_CLI_SUBPROCESS=1), so the judge's own Stop hook can't rerun impl-check and recurse. Failures are silent by design, so if you need to see why briefing isn't appearing, set ACTUAL_HOOK_DEBUG=1 and run claude --debug; it reports the rules directory, the CLI's exit status, why a reply was dropped, and how much context briefing has used. Run bash hooks/tests/run.sh to test the hooks locally, and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test . to test the mod; neither needs the network or a CLI install. Governance relies on Claude Code hooks, so it isn't available in Codex, ChatGPT, Cursor, or OpenCode.

Session summary

In Claude Code, a row above the prompt shows how many of your repository's ADRs have been added to your agent's context this session: Actual AI: 3 of 12 ADRs added to context. It reads 0 of 12 from the start of the session and updates after each of your agent's replies, in the background, so no reply waits on it. After /clear it counts the new session. Claude Code's own surveys and other plugins share that band: the row steps aside while a survey is showing, and the [-] at its right collapses the band.

It counts against the same rules directory as briefing, ACTUAL_RULES_DIR included, and shows nothing outside a repository with .actual/rules/, when the rules hold no decision, or when the CLI is missing or predates actual session summary. ACTUAL_PLAN_GATE=off leaves it on. The same mod that briefs shell reads draws it, so it needs the same Claude Code: 2.1.287 or later, or 2.1.285 and 2.1.286 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. The band appears in the terminal and the desktop app, and not in VS Code or claude -p. Its tests run with the mod's: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ..

Troubleshooting

When something fails, the skill diagnoses it for you. It knows every error the CLI can return, all five runners (claude-cli, anthropic-api, openai-api, codex-cli, cursor-cli), and all three output formats, so it can find the cause and retry without you reading logs.

Documentation

See the Actual CLI docs for getting started and the full command reference. Point your agent at docs.md for the same docs in Markdown.

Requirements

  • A supported coding agent: Claude Code, Codex, ChatGPT, Cursor, or OpenCode.
  • Your repository onboarded at app.actual.ai, so Actual can generate its ADRs.
  • The actual CLI (actual-software/actual-cli). The skill offers to install it on first use, or you can install it yourself with npm install -g @actualai/actual or brew install actual-software/actual/actual.

License

Apache-2.0

Source 1 files
hooks/register.js 1448 lines
1// register.js - Claude mod: brief the agent on the rules governing a file it
2// read, through the Read tool or through Bash.
3//
4// Why this exists beside hooks/rules-brief.sh rather than replacing it.
5// PostToolUse:Read is the delivery point rules-brief.sh uses, and it is the
6// right one -- except that across 212 sampled local sessions, 80% of file
7// reads never went through the Read tool at all. They went through `cat`,
8// `head`, `sed -n` and `grep` in Bash, which fires no Read hook. 35% of those
9// sessions used Bash more than five times and Read exactly zero times. One of
10// them (sprintreview session fb5061e2) implemented a whole feature in 35 Bash
11// calls, reached the Stop gate with three MUST conflicts, and was never
12// briefed once, because there was nothing for PostToolUse:Read to hook.
13//
14// A settings hook cannot close that gap well: it would need a second
15// PostToolUse registration on Bash, a shell-side parse of an arbitrary command
16// string, and a CLI spawn per Bash call inside a 2-second timeout. A mod gets
17// the command string as data, runs in-process, and spends its CLI time inside
18// $.process.run, which the hook time limit explicitly excludes.
19//
20// Division of labour with rules-brief.sh: this mod hooks both Read and Bash,
21// and where it loads it stands the hook down for Read (STAND_DOWN_FIELD, below)
22// so one brief is delivered once, by the path that has a budget and a record.
23// The hook remains for versions of Claude Code that cannot load a mod. The mod deliberately
24// synthesizes a PostToolUse:Read envelope and calls `actual rules brief
25// --claude-hook` rather than the simpler `--file` direct mode, because
26// --claude-hook is what consults brief memory. Both paths therefore share one
27// dedup store and a file read with `cat` after a Read tool call is not briefed
28// twice. Direct mode is stateless (verified: same file twice returns the full
29// brief both times), so using it here would have meant a second, parallel
30// dedup store in the mod.
31//
32// Advisory by construction, like the hook. It never denies, never rewrites a
33// command, and never changes a tool result: a brief goes beside the result, in
34// the call's `context`. Every failure path leaves the result exactly as the
35// tool produced it. There is
36// deliberately no .catch handler: a .catch makes a hook fail closed, which is
37// right for a gate and wrong for this.
38
39// Phase 1 scope: the plumbing, plus the two facts a real session established
40// that no amount of reasoning would have. Both were found by probing an actual
41// `claude -p` run, and both corrected a guess:
42//
43//  1. Where a brief goes. A tool.call result's `context` is what the model
44//     reads after the tool's result. Probed on 2.1.287 with a marker for both
45//     Read and Bash: the model received each as its own system reminder
46//     ("tool.call hook additional context"), and the transcript recorded it
47//     as a hook_additional_context attachment beside an untouched tool result
48//     -- the channel a PostToolUse hook's additionalContext uses. An earlier
49//     version appended the brief to result.stdout and result.file.content
50//     instead, which put rule text inside the file as the model read it and
51//     inside the tool result the transcript kept.
52//  2. Claude Code already classifies each Bash command: `isReadOnly` is true
53//     for `cat a.txt` and `sed -n 1p a.txt`, and absent for `echo written >
54//     b.txt`. That is the read-vs-write signal Phase 2 was going to build a
55//     regex for, supplied by the permission system itself.
56//
57// So classification is not ours to guess. We brief a Bash call only when
58// Claude Code says it was read-only, which is both broader than a command
59// allowlist (it covers `grep -n file`, pipes, and spellings nobody enumerated)
60// and safer: a command Claude Code cannot classify leaves isReadOnly absent,
61// and absent means no brief.
62//
63// Briefing only read-only commands also settles a design question with
64// evidence. A write-shaped command has already composed its replacement text
65// by the time it exists, so briefing it could only prompt a revision -- which
66// is impl-gate.sh's job. Only a read can be briefed before the edit is
67// written, which is the whole point of delivering at read time.
68//
69// What Phase 2 still owes: better *path* extraction -- heredoc bodies, more
70// than one path per command -- not classification.
71//
72// Edit and Write are the one exception to "only a read", and a narrow one
73// (AK-794). Claude Code makes the agent Read a file before editing or
74// overwriting it, so for an existing file the Read brief has already landed
75// and the CLI answers the edit with silence. What is left is a file written
76// without a Read -- mostly a new one from Write -- which no read-time brief
77// could ever reach. Its brief lands with the tool result, after the write ran,
78// so it is a reminder that can prompt a revision, not a briefing ahead of the
79// work. The Bash write path stays unbriefed: a shell write names its file
80// inside an arbitrary command string, and the Stop gate covers it.
81
82// --- Path extraction ---------------------------------------------------
83//
84// What this has to get right, measured against the 35 Bash calls of one real
85// session (sprintreview fb5061e2) and a live probe of isReadOnly:
86//
87//   cat a.ts                              one path
88//   cat a.ts b.ts                         read-only, TWO paths
89//   cat a.ts | head -3                    read-only, pipe
90//   cd /abs/dir && cat rel/a.ts           read-only, path relative to /abs/dir
91//   grep -n class a.ts                    read-only
92//   sed -n 1,2p a.ts                      read-only
93//
94// 11 of those 35 calls (31%) began with `cd <dir> &&`, every one of them with
95// an absolute target, and the targets were not all the repo root -- one was
96// apps/actual. A path in such a command resolves against the cd target, not
97// the session's cwd, so ignoring the prefix means either missing the file or,
98// worse, resolving to a different file that happens to exist at the same
99// relative path.
100
101// A token that could be a path with an extension. No `*`, so a glob such as
102// `src/*.ts` is not mistaken for a file. No quotes, so `sed -n '1,2p'` and a
103// quoted path with a space are both passed over -- the latter is a known and
104// accepted miss.
105//
106// The extension must begin with a letter. Running this over 35 real Bash
107// commands, the version string in `echo "=== jwt 9.0.3 API evidence ==="`
108// matched a digits-allowed pattern and became a candidate path; `.ts`, `.md`,
109// `.json`, `.cjs`, `.tf`, `.yaml` and `.d.ts` all still match.
110//
111// A leading `/` or `~/` is allowed. Without it every absolute path was
112// rejected, because the first character had to be a word character or a dot --
113// and Claude Code's Bash tool writes absolute paths freely. A reported session
114// ran `sed -n '1,20p' /Users/.../app/lib/query-keys.ts` and two `cat`s of
115// absolute paths, and all three briefed nothing while the same reads written
116// relatively briefed correctly, which made the whole feature look intermittent.
117const PATH_TOKEN = /^(\/|~\/)?[\w.][\w./-]*\.[A-Za-z][A-Za-z0-9]{0,5}$/;
118
119// How many files one command may brief. A command reading six files would
120// otherwise inject six briefs at once, which is the volume question Phase 3
121// still owes an answer to; until then, cap it low.
122const MAX_PATHS_PER_COMMAND = 2;
123
124// Peel any leading `cd <dir> &&` chain off the front and report the directory
125// the rest of the command actually runs in. Only a leading chain counts: a
126// `cd` later in the pipeline changes the base partway through, and guessing
127// which half a path belongs to is worse than declining, so that returns null.
128function resolveBase(command) {
129  let rest = command.trim();
130  let base = null;
131  for (;;) {
132    // `&&` or `;`, and a `cd` that ends the command has neither. A bare
133    // `cd apps/web` was previously not a base at all: the regex needed a
134    // separator, so the loop never matched, the `cd` was still in `rest`, and
135    // the ambiguity check below threw the whole command away. The shell kept
136    // the new directory and this mod did not, so every later relative path
137    // resolved against the wrong base.
138    const m = /^cd\s+("[^"]+"|'[^']+'|[^\s&|;]+)\s*(?:&&|;|$)\s*/.exec(rest);
139    if (!m) break;
140    const target = m[1].replace(/^["']|["']$/g, '');
141    // Successive `cd`s compose, as the shell composes them: `cd a && cd b`
142    // ends in `a/b`, not `b`. Overwriting made the second one win, which put
143    // the base one directory too shallow for the rest of the session.
144    base = base === null || target.startsWith('/') ? target : joinPath(base, target);
145    rest = rest.slice(m[0].length);
146  }
147  // A `cd` anywhere in what is left makes the base ambiguous.
148  if (/(^|[\s&|;])cd\s/.test(rest)) return null;
149  // Everything from a heredoc marker on is the body of a document, not argv.
150  // Scanning it finds property accessors -- `jwk.kid`, `claims.jti` -- that
151  // look exactly like dotted paths. Those commands are never briefed anyway,
152  // because Claude Code does not mark a heredoc read-only (probed live, even
153  // for one that only reads and prints), but the extractor should not be
154  // inventing candidates it would then have to filter.
155  const heredoc = rest.indexOf('<<');
156  if (heredoc !== -1) rest = rest.slice(0, heredoc);
157  return { base, rest };
158}
159
160// Join a possibly-relative token onto a base directory, without importing
161// path: a mod's whole dependency surface is the mods API, same hygiene rule
162// the shell hooks follow for jq and python.
163function joinPath(base, token) {
164  if (token.startsWith('/')) return token;
165  return `${base.replace(/\/+$/, '')}/${token}`;
166}
167
168// Collapse `.` and `..` segments so containment can be checked as a string
169// prefix. A token that climbs above its base is left with leading `..`, which
170// no absolute root prefixes, so it is rejected by the containment test rather
171// than needing its own branch.
172function normalizePath(p) {
173  const out = [];
174  for (const seg of p.split('/')) {
175    if (seg === '' || seg === '.') continue;
176    if (seg === '..') out.pop();
177    else out.push(seg);
178  }
179  return (p.startsWith('/') ? '/' : '') + out.join('/');
180}
181
182function isWithin(root, candidate) {
183  const r = normalizePath(root).replace(/\/+$/, '');
184  if (r === '' || r === '/') return true;
185  return candidate === r || candidate.startsWith(`${r}/`);
186}
187
188// Why a file a Read, Edit or Write names outright is not worth a spawn, or
189// null when it is: the Bash path's two filters, for the tools that skip its
190// parsing. Without them every scratchpad write -- Claude Code keeps one under
191// TMPDIR -- and every edit of a rule document spawned the CLI to hear "no".
192//
193// Outside only when outside the root, the session's directory and the
194// session's project root alike. The root can be git's physical path while
195// Claude names files by the logical one a symlinked checkout was opened
196// through; a test against the root alone would skip governed files there,
197// silently. The cwd covers that until the shell runs `cd apps/web`, after
198// which a file elsewhere in the checkout is under neither; the project root
199// ($.session.root()) is the logical path the session was opened at, and a
200// shell `cd` does not move it. A file inside the rules directory is a rule
201// document, which is not governed by itself.
202function unbriefable(named, { repoRoot, cwd, sessionRoot, rulesDir }) {
203  if (![repoRoot, cwd, sessionRoot].filter(Boolean).some((base) => isWithin(base, named))) return 'outside-root';
204  if (rulesDir && isWithin(rulesDir, named)) return 'ungoverned';
205  return null;
206}
207
208// The Bash tool runs every command in ONE persistent shell, so a `cd` in any
209// call changes the directory every later call runs in. On Claude Code 2.1.296
210// `$.session.cwd()` follows that shell: after `cd apps/actual && ls lib/oauth`
211// the next call reads `<repo>/apps/actual`. That is why nothing is tracked
212// here -- the cwd read after a call is already where the shell ended up, and
213// the only arithmetic left is for the call that did the `cd`, whose relative
214// target must be joined onto the directory the call STARTED in (see the
215// tool.call handler). A copy of the shell's directory kept here drifted from
216// the real one on every failed `cd`, and doubled every relative one.
217//
218// Perfect shell emulation is not the goal and not achievable -- a `cd` buried
219// in a pipeline, a shell variable, a subshell -- so the resolver treats the
220// shell's directory as the first guess among several rather than the truth.
221
222// Candidate path tokens, plus the directory a leading `cd` chain moved to.
223// Resolution is left to the caller, which can check what exists; this stays a
224// pure function so the corpus of real commands can be replayed against it.
225function candidateTokens(command) {
226  if (typeof command !== 'string' || command.length === 0) return null;
227  const split = resolveBase(command);
228  if (!split) return null;
229
230  const tokens = [];
231  const seen = new Set();
232  // Skip argv[0] of each pipeline stage: `cat`, `head`, `grep` are not paths,
233  // and a tool name with a dot (`a.out`) would otherwise look like one.
234  for (const stage of split.rest.split(/\||&&|;/)) {
235    for (const token of stage.trim().split(/\s+/).slice(1)) {
236      if (token.startsWith('-')) continue;
237      if (!PATH_TOKEN.test(token)) continue;
238      if (seen.has(token)) continue;
239      seen.add(token);
240      tokens.push(token);
241    }
242  }
243  return { cdTarget: split.base, tokens };
244}
245
246// Under ACTUAL_HOOK_DEBUG, say what briefing decided -- including, and
247// especially, when it decided nothing. The first version of this reported only
248// from the point where a brief was already being appended, which left every
249// case worth debugging silent: a command that was not read-only, a token that
250// resolved to nothing, a spent budget, and a mod that never loaded all looked
251// identical from a transcript. Three sessions were spent unable to tell them
252// apart. The line rides out in the call's context, which the session
253// transcript records even under `claude -p`, where $.ui.log does not reach.
254// The model reads it too, so it is debug-gated.
255async function debugNote($, outcome, detail) {
256  const enabled = await $.env.get('ACTUAL_HOOK_DEBUG');
257  if (!enabled) return '';
258  return `actual rules brief [debug]: ${outcome}${detail ? ` -- ${detail}` : ''}`;
259}
260
261// Hand text to the model beside a tool's result, never inside it, so the
262// result is the same whatever the tool's shape. Entries already there are
263// kept: a hook beneath hands its context over whole. Returns the result
264// untouched when there is no text, so the quiet path allocates nothing.
265function withContext(result, text) {
266  if (!text) return result;
267  return { ...result, context: [...(result.context ?? []), text] };
268}
269
270// --- What the user sees ---------------------------------------------------
271//
272// The brief itself has to reach Claude, so it rides in the tool result and
273// costs context -- which is why there is a session budget at all. What a
274// *person* needs is different: whether a brief happened, what it covered, and
275// when one did not, why. A `$.ui.log` row or the pane costs nothing,
276// because they go to the terminal and the Desktop app and Claude never reads
277// them. So everything below is free, and none of it is in the budget.
278//
279// It is also not a substitute for the brief: nothing shows in `claude -p`, the
280// SDK, the VS Code chat panel or a cloud session (hooks still run there). The
281// report is for the person watching, and there is no person watching those.
282
283// One record per Bash tool call, keyed by its tool_use_id, so a call's
284// report and the pane's row are the same record. Bounded: a long session makes
285// thousands of tool calls and this is a convenience, not a log.
286const MAX_OUTCOMES = 200;
287// A quiet read's own outcome name, when every file was quiet for one reason.
288// `already-briefed` is left out on purpose: that is the `deduped` row, whose
289// line can also speak for a mixed call.
290const QUIET_OUTCOMES = {
291  ungoverned: 'ungoverned',
292  'nothing-to-show': 'no-text',
293  'outside-root': 'outside-root',
294  'no-rules': 'no-rule-set',
295};
296
297// Per session, not per process. `spent` and `repoRoots` are both
298// keyed by session and this was not, so a pane headed "this session" listed
299// reads from before a `/clear` -- and from any other session sharing the
300// process -- as though they were this one's.
301// Sessions that have already been told the budget is spent.
302const budgetAnnounced = new Set();
303
304const outcomes = new Map();
305
306function outcomesFor(sessionId) {
307  let own = outcomes.get(sessionId);
308  if (!own) {
309    own = new Map();
310    outcomes.set(sessionId, own);
311  }
312  return own;
313}
314
315function recordOutcome(sessionId, toolUseId, record) {
316  if (!toolUseId) return;
317  const own = outcomesFor(sessionId);
318  own.set(toolUseId, { at: Date.now(), ...record });
319  while (own.size > MAX_OUTCOMES) {
320    own.delete(own.keys().next().value);
321  }
322}
323
324// Read the shape back out of a brief for the one-line summary. Best effort by
325// construction: this parses the CLI's human-readable text, which is not a
326// contract, so every field is optional and a parse that finds nothing yields a
327// character count instead. A summary is cosmetic -- it must never be able to
328// affect whether the brief itself was delivered.
329function summarize(brief) {
330  const decisions = [];
331  let current = null;
332  for (const line of brief.split('\n')) {
333    if (line.startsWith('## ')) {
334      current = { title: line.slice(3).trim(), shown: 0, total: 0 };
335      decisions.push(current);
336    } else if (/^- \[/.test(line)) {
337      if (current) current.shown += 1;
338    } else {
339      // The CLI's own truncation note, which is per decision: the rules listed
340      // above it are `shown`, and `total` is how many that decision really has.
341      const more = /^- \((\d+) of (\d+) rules shown\)/.exec(line);
342      if (more && current) current.total = Number(more[2]);
343    }
344  }
345  // A decision with no note is shown in full, so its total is what it listed.
346  for (const d of decisions) if (!d.total) d.total = d.shown;
347  return {
348    decisions: decisions.map((d) => d.title),
349    shown: decisions.reduce((n, d) => n + d.shown, 0),
350    total: decisions.reduce((n, d) => n + d.total, 0),
351    chars: brief.length,
352  };
353}
354
355// `1 rule`, `2 rules`: the brief that names one rule says so.
356function counted(n, noun) {
357  return `${n} ${noun}${n === 1 ? '' : 's'}`;
358}
359
360// The transcript row for one call. Null for the calls worth saying nothing
361// about -- a command that read no governed file is the overwhelming majority,
362// and a line on every one of them would be noise rather than information.
363// The sentence for one call's outcome, without a prefix: $.ui.log adds the
364// plugin's name itself.
365//
366// Reported through $.ui.log alone. An earlier version also drew the line under
367// the tool call through `ui.render`, which looked better placed but could not
368// be relied on -- Claude Code collapses some calls into a summary row whose id
369// is not the call's, so the line vanished. Keeping both channels meant every
370// expanded call reported its brief twice, once in each, which a real session
371// showed six times over. One channel that always works beats two that
372// sometimes do.
373function names(files) {
374  return files.map((f) => f.split('/').pop()).join(', ');
375}
376
377// A row's three columns: a state in caps, what it amounted to, then the files.
378//
379// The host draws every one of these as `⏺ actual-cli: <text>` at one dim
380// weight -- $.ui.log carries text and nothing else, no colour and no bold -- so
381// the only way to make a row findable is its shape. A bar glyph gives a column
382// of rows a left edge, and a fixed-width state in caps puts the same word in
383// the same place every time, so a scan down the transcript lands on it without
384// reading the sentence.
385//
386// The state is padded to the longest of them, which is why the detail and the
387// files line up across rows of different kinds.
388const STATE_WIDTH = 9;
389
390function row(state, detail, files) {
391  const columns = [state.padEnd(STATE_WIDTH), detail];
392  if (files) columns.push(`  ${files}`);
393  return `\u258c ${columns.join('  ')}`;
394}
395
396function reportLine(record) {
397  if (!record) return null;
398  const files = names(record.files ?? []);
399  switch (record.outcome) {
400    case 'delivered': {
401      const s = record.summary ?? {};
402      const n = s.decisions?.length ?? 0;
403      // A brief whose text did not parse still reports its size: the count is
404      // the one thing always known, and "0 ADRs" reads as a failure.
405      if (n === 0) return row('BRIEFED', `${s.chars ?? 0} chars`, files);
406      const rules =
407        s.total && s.total > s.shown
408          ? `${s.shown} of ${counted(s.total, 'rule')}`
409          : counted(s.shown ?? 0, 'rule');
410      return row('BRIEFED', `${counted(n, 'ADR')} \u00b7 ${rules}`, files);
411    }
412    case 'deduped': {
413      // Only the files this row can speak for. `unknown` is a CLI too old to
414      // name a reason, which keeps both meanings rather than picking one.
415      const by = (want) =>
416        (record.files ?? []).filter((f) => ((record.reasons ?? {})[f] ?? 'unknown') === want);
417      const known = by('already-briefed');
418      const unsure = by('unknown');
419      const groups = [];
420      if (known.length) groups.push(`briefed earlier  ${names(known)}`);
421      if (unsure.length) groups.push(`briefed earlier, or ungoverned  ${names(unsure)}`);
422      if (groups.length === 0) return null;
423      return row('NO CHANGE', groups.join('  \u00b7  '));
424    }
425    case 'ungoverned':
426    case 'no-text':
427    case 'outside-root':
428    case 'no-rule-set':
429      // Quiet for a reason that is not about this read: see the pane, which
430      // names all four.
431      return null;
432    case 'budget':
433      // Reported once by deliverBrief when the budget runs out, not per read.
434      return null;
435    case 'cli-error':
436      return row('CLI ERROR', record.detail ?? 'CLI error', files);
437    default:
438      return null;
439  }
440}
441
442// --- Session budget -----------------------------------------------------
443//
444// Dedup (shared with the hook, via brief memory) stops the same decision being
445// briefed twice. What it cannot bound is accumulation: a session that touches
446// many governed files collects many distinct decisions. Measured over three
447// real rule sets -- 425, 183 and 219 documents -- a 40-file session accumulates
448// 12 to 18 distinct decisions, so roughly 20-30 KB of injected context, and
449// 87-100% of sampled files were governed by at least one rule.
450//
451// `--min-score` cannot substitute for a cap. Its scores form a fixed lattice
452// of 0.75 x {1, 1.5, 2, 3, 4} -- five rungs, identical in all three corpora --
453// and the score measures how narrowly a rule's `verify` glob was written
454// (`ai/**` scores 0.75, `ai/src/mastra/workflows/**/*.ts` scores 3.00), not how
455// relevant the rule is. So one threshold behaves differently everywhere: a
456// 1.50 floor cuts 70% in one repo, 23% in another and 0% in a third. It stays
457// a per-repo escape hatch (`config set --repo rules_min_score`), not a lever
458// this mod can set on anyone's behalf.
459//
460// Hence a hard per-session character cap. It stops rather than truncates: the
461// CLI's own --max-chars already drops whole rules from a single brief and says
462// how many it left out, and a brief cut mid-rule is worse than no brief -- a
463// half-printed MUST can read as permission.
464
465// Measured, not guessed. Against the 425-rule corpus with dedup active, 40
466// governed files injected 18,751 characters -- and only 8 of those 40 reads
467// produced a brief at all, the other 32 being decisions already delivered.
468// Accumulation over that run: 9,039 chars at 10 files, 10,841 at 20, 12,573 at
469// 30, 18,751 at 40.
470//
471// A first guess of 20,000 was wrong: it sits at 94% of the 40-file figure, so
472// it would have started dropping briefs in ordinary sessions rather than
473// catching runaway ones. This cap exists for pathology -- a session crossing
474// hundreds of governed files in many rule areas -- not for tuning relevance,
475// which is what dedup and the CLI's own limits are for. 48,000 is about 2.5x
476// the measured normal case: roughly 12,000 tokens, or 6% of a 200k context
477// window, which is a defensible ceiling for governance context and still stops
478// an unbounded climb.
479const DEFAULT_MAX_SESSION_CHARS = 48000;
480
481// Per-session totals, keyed by session id so a reload or a second session in
482// one process starts clean rather than inheriting a spent budget.
483const spent = new Map();
484
485function spendable(sessionId, cap) {
486  const used = spent.get(sessionId) ?? 0;
487  return Math.max(0, cap - used);
488}
489
490function spend(sessionId, chars) {
491  spent.set(sessionId, (spent.get(sessionId) ?? 0) + chars);
492}
493
494// Said once, on the call that exhausts the budget. Briefing that simply stops
495// is the silent-degradation failure this plugin already has one of too many:
496// a brief that never arrives is indistinguishable from a file no rule governs.
497// One short line makes the difference legible and tells the agent what to do.
498const EXHAUSTED_NOTICE =
499  'Actual rule briefing has reached its per-session context budget and will ' +
500  'stay quiet from here. Run `actual rules brief --file <path>` if you need ' +
501  'the rules for a specific file.';
502
503// The one allowlisted shape, as a parse rather than a byte match. The hook has
504// to do this with shell parameter expansion and needs five guards to pin the
505// shape (see rules-brief.sh); here it is a property read, and every other
506// field the CLI might emit -- a decision, continue, a permissionDecision --
507// is ignored because it is simply never looked at.
508function briefFrom(stdout) {
509  if (!stdout) return '';
510  let parsed;
511  try {
512    parsed = JSON.parse(stdout);
513  } catch {
514    return '';
515  }
516  const context = parsed?.hookSpecificOutput?.additionalContext;
517  return typeof context === 'string' ? context : '';
518}
519
520// hook_event_name is load-bearing: an envelope without it returns nothing at
521// all. session_id is load-bearing differently -- without it the CLI still
522// briefs, but silently stops consulting brief memory, so every read of a
523// governed file would brief again.
524//
525// agent_id is the third component of brief memory's key, and omitting it is
526// worse than a duplicate brief. A subagent runs with its own context, so a
527// decision briefed to the parent has not been shown to it -- which is exactly
528// why the CLI keys on it (see brief_memory.rs). Probed in a real subagent, the
529// Read hook's envelope carries `agent_id` and this mod's event carries the
530// same value as `e.agentId`. Leaving it out filed the subagent's reads under
531// the parent's slot, so the subagent would be denied a brief the parent had
532// already seen, and the parent would later miss one it never saw. All three
533// fields are asserted in the tests.
534//
535// The event and tool are what the call was: a Bash or Read call is a
536// PostToolUse:Read, an Edit or Write call is a PreToolUse on that tool -- the
537// two pairs the CLI answers, and the ones hooks/rules-brief.sh forwards. The
538// CLI keeps one brief memory across both, so a file briefed by either is not
539// briefed again by the other.
540function envelopeFor({ sessionId, agentId, cwd, filePath, tool = 'Read' }) {
541  const write = WRITE_TOOLS.includes(tool);
542  const envelope = {
543    session_id: sessionId,
544    cwd,
545    hook_event_name: write ? 'PreToolUse' : 'PostToolUse',
546    tool_name: write ? tool : 'Read',
547    tool_input: { file_path: filePath },
548  };
549  if (!write) envelope.tool_response = { type: 'text' };
550  // Omitted rather than sent empty for the main agent, matching the hook: the
551  // CLI treats an absent and an empty agent_id the same, but an envelope that
552  // mirrors the real one is easier to reason about.
553  if (agentId) envelope.agent_id = agentId;
554  return JSON.stringify(envelope);
555}
556
557// Every tunable is readable two ways: a `userConfig` field, which gives the
558// operator a labelled row in the plugin's settings, and an environment
559// variable, which an operator can set for one session without editing
560// settings. Env wins, the same precedence the three switches above use.
561//
562// Two reasons it is not userConfig alone. The plugin already established the
563// ACTUAL_* convention for exactly this, and `userConfig` values arrive in
564// register()'s `options` at load time, which no test harness in this build can
565// inject -- a tunable nothing can test is a tunable that quietly stops working.
566// Each env name is written as a literal at its call site, not composed from a
567// prefix, and the reads sit in one plain function rather than behind a table of
568// closures. Claude Code enforces both: `$.env.get` refuses a template with a
569// substitution, and `$` may only be passed to a function declared at the top of
570// the file. The point is that the variables a mod reads can be listed without
571// running it -- `claude plugin validate` prints them, which is the whole reason
572// that output is worth showing a security reviewer.
573async function readEnvSettings($) {
574  const [maxSessionChars, limit, rulesPerDecision, maxChars, minScore] = await Promise.all([
575    $.env.get('ACTUAL_RULES_BRIEF_MAX_SESSION_CHARS'),
576    $.env.get('ACTUAL_RULES_BRIEF_LIMIT'),
577    $.env.get('ACTUAL_RULES_BRIEF_RULES_PER_DECISION'),
578    $.env.get('ACTUAL_RULES_BRIEF_MAX_CHARS'),
579    $.env.get('ACTUAL_RULES_BRIEF_MIN_SCORE'),
580  ]);
581  return { maxSessionChars, limit, rulesPerDecision, maxChars, minScore };
582}
583
584// The rules directory to govern against. This has to agree with
585// bootstrap.sh's rules_dir() exactly, not approximately: brief memory is keyed
586// on session_id + agent_id + rules_dir, so any divergence means one read
587// briefs twice -- and in a monorepo the mod also governs against the wrong
588// rule set. Measured on a fixture whose root and subproject rule sets both
589// match one file, before this was forwarded at all: the hook briefed the
590// subproject's R-API-001 and the mod, same session, briefed the root's
591// R-ROOT-001.
592//
593// No single mods API call gives that agreement, which four probed layouts
594// settled:
595//
596//   layout                           hook        $.session.root()  repo().root
597//   worktree under the project root  worktree    worktree   OK     main repo  X
598//   worktree + CLAUDE_PROJECT_DIR    worktree    worktree   OK     main repo  X
599//   monorepo subproject as project   subproject  subproject OK     outer repo X
600//   launched in a subdirectory       that subdir that subdir OK     git root   X
601//
602// $.session.root() tracks the session's working directory, so it is right for
603// a worktree and right one directory down too: the hook resolves that
604// subdirectory as well, because CLAUDE_PROJECT_DIR names it. repo().root is
605// the main checkout, so it is wrong in every row.
606//
607// The last row was recorded the other way round here, on the assumption that
608// the hook picks the git root. It does not, and a test that asserted it kept
609// the error in place. Measured in a live session: with the subproject as the
610// project directory, a Read briefed the subproject's decision and a `cat` of
611// the same file briefed two of the root's, 3,296 characters, and the model
612// received both.
613//
614// So the mod asks git the same question the hook asks, and applies the same
615// rule: when CLAUDE_PROJECT_DIR and the git toplevel are nested, the deeper
616// path is the more specific context and wins; otherwise the active checkout
617// does. See resolve_repo_root in bootstrap.sh for why each case is that way.
618// One git spawn per session, cached, because a session does not change repo.
619
620const repoRoots = new Map();
621
622// Every spawn's timeout: the PostToolUse:Read hook's own budget in hooks.json.
623// $.process.run waits 30 seconds when given none, and the hook time limit does
624// not cover it, so a CLI that hangs would stall every read for that long. A
625// spawn that times out does not return: `$.process.run` rejects, as it does
626// when the command cannot start, so each caller catches it.
627const SPAWN_TIMEOUT_MS = 2000;
628
629function deeperOf(projectDir, gitRoot) {
630  if (!projectDir) return gitRoot;
631  if (!gitRoot) return projectDir;
632  const p = projectDir.replace(/\/+$/, '');
633  const g = gitRoot.replace(/\/+$/, '');
634  if (g === p) return g;
635  if (g.startsWith(`${p}/`)) return g; // worktree: nested under the project
636  if (p.startsWith(`${g}/`)) return p; // monorepo: subproject inside the repo
637  return g; // unrelated: the active checkout wins
638}
639
640// `~` expanded once per session. PATH_TOKEN accepts a `~/` token, and without
641// this `joinPath` made it `<base>/~/repos/...`, which exists nowhere -- so the
642// pattern offered a candidate that could never resolve. It matters because a
643// checkout usually does sit under the home directory, so `cat ~/repos/x/a.ts`
644// names a file this mod governs.
645const homeDirs = new Map();
646
647async function resolveHome($, sessionId) {
648  if (homeDirs.has(sessionId)) return homeDirs.get(sessionId);
649  let home = null;
650  try {
651    home = (await $.env.get('HOME')) || null;
652  } catch {
653    // No HOME: a `~` token simply stays unresolvable, as before.
654  }
655  homeDirs.set(sessionId, home);
656  return home;
657}
658
659// Whether the installed CLI has `rules brief` at all, asked once a session the
660// way preflight asks it (`have_rules_brief` in bootstrap.sh). actual-cli v0.6.2,
661// the latest release, answers `unrecognized subcommand` with exit 2 -- and
662// without this every read in a governed repository logged a CLI ERROR row for
663// a state the person cannot do anything about mid-turn. Preflight already
664// tells the agent once to upgrade.
665//
666// Only a definite answer is kept. A probe that times out says nothing about
667// the CLI, so the next read asks again.
668const briefAvailable = new Map();
669
670async function rulesBriefAvailable($, sessionId, cwd) {
671  if (briefAvailable.has(sessionId)) return briefAvailable.get(sessionId);
672  let ok = false;
673  let definite = true;
674  try {
675    const run = await $.process.run(['actual', 'rules', 'brief', '--help'], {
676      cwd,
677      timeoutMs: SPAWN_TIMEOUT_MS,
678    });
679    ok = run.exitCode === 0;
680  } catch {
681    // Cannot start (no `actual` on PATH) or still running at the timeout. The
682    // first is as definite as an exit code; the second is not distinguishable
683    // from it here, so neither is cached against the session for good.
684    definite = false;
685  }
686  if (definite) briefAvailable.set(sessionId, ok);
687  return ok;
688}
689
690async function resolveRepoRoot($, sessionId, cwd) {
691  if (repoRoots.has(sessionId)) return repoRoots.get(sessionId);
692  let gitRoot = null;
693  try {
694    const run = await $.process.run(['git', 'rev-parse', '--show-toplevel'], {
695      cwd,
696      timeoutMs: SPAWN_TIMEOUT_MS,
697    });
698    if (run.exitCode === 0) gitRoot = (run.stdout ?? '').trim() || null;
699  } catch {
700    // Not a repository, or no git. Fall through to the project dir.
701  }
702  // CLAUDE_PROJECT_DIR is set for hook processes, not for a mod, so this read
703  // returns nothing in a live session and `deeperOf` fell back to the git root
704  // every time -- which is how the mod came to brief a monorepo subproject
705  // from the root's rule set while the hook briefed it from the subproject's.
706  // $.session.root() is the same directory the variable would have named.
707  const projectDir = (await $.env.get('CLAUDE_PROJECT_DIR')) || (await $.session.root());
708  const root = deeperOf(projectDir || null, gitRoot) ?? cwd;
709  repoRoots.set(sessionId, root);
710  return root;
711}
712
713async function resolveRulesDir($, sessionId, cwd) {
714  const override = await $.env.get('ACTUAL_RULES_DIR');
715  if (override) return override;
716  const root = await resolveRepoRoot($, sessionId, cwd);
717  if (!root) return null;
718  return `${root.replace(/\/+$/, '')}/.actual/rules`;
719}
720
721// The rules directory, but only when it holds a rule file -- rules_present in
722// bootstrap.sh, counting the same way: top-level `*.md` entries. Null means
723// this repository has nothing to govern against, and every handler below must
724// then be a silent no-op: no CLI spawn, no record, no transcript line, no
725// pane. Installing the plugin must not be visible in unrelated repositories.
726//
727// Not cached: a session can commit its first rule file partway through, and
728// the hook re-checks on every Read for the same reason. A listing is in-process
729// and cheap; the repo root underneath it is already cached per session.
730async function governedRulesDir($, sessionId, cwd) {
731  const rulesDir = await resolveRulesDir($, sessionId, cwd);
732  if (!rulesDir) return null;
733  let entries;
734  try {
735    entries = await $.fs.list(rulesDir);
736  } catch {
737    return null;
738  }
739  return (entries ?? []).some((entry) => entry.name.endsWith('.md')) ? rulesDir : null;
740}
741
742// Which keys exist, and the CLI flag each one forwards to. maxSessionChars has
743// no flag: it is this mod's own budget, enforced here, not something the CLI
744// knows about.
745const CLI_FLAGS = {
746  limit: '--limit',
747  rulesPerDecision: '--rules-per-decision',
748  maxChars: '--max-chars',
749  minScore: '--min-score',
750};
751
752// Env wins over userConfig, the same precedence the three switches above use:
753// a settings row is the durable preference, an environment variable is the
754// override for one session.
755function mergeSettings(fromEnv, options) {
756  const resolved = {};
757  for (const key of ['maxSessionChars', ...Object.keys(CLI_FLAGS)]) {
758    const raw = fromEnv[key];
759    const parsed = raw === undefined || raw === null || raw === '' ? NaN : Number(raw);
760    if (Number.isFinite(parsed)) resolved[key] = parsed;
761    else if (Number.isFinite(options[key])) resolved[key] = options[key];
762  }
763  return resolved;
764}
765
766// Pass a limit through to the CLI only when the operator set one, so the CLI's
767// own defaults (2 decisions, 8 rules each, 4000 chars, no score floor) stay in
768// one place rather than being restated here. --min-score especially: its
769// default is the repository's own `rules_min_score`, then the user-wide key,
770// and overriding that from here would silently ignore a repo's setting.
771function cliLimits(resolved) {
772  const argv = [];
773  for (const [key, flag] of Object.entries(CLI_FLAGS)) {
774    if (Number.isFinite(resolved[key])) argv.push(flag, String(resolved[key]));
775  }
776  return argv;
777}
778
779// What Phase 3 of the plan asked for: the numbers needed to choose a cap,
780// rather than a cap chosen from unease.
781//
782// Two channels, because one is not enough. $.ui.log writes a dim transcript
783// line the model does not read, which is the right thing in an interactive
784// session -- measuring must not itself change what the session sees. But it
785// does not reach `claude -p` stdout, and headless is exactly where a scripted
786// measurement over many files would run, so the first attempt at this produced
787// nothing at all. The second channel adds the same line to the brief, where it
788// rides out in the call's context and the session transcript records it, even
789// headless. That one does enter the model's context, so it is strictly
790// debug-only.
791//
792// Neither channel writes a file. A durable log would mean declaring $.fs.write
793// on a governance plugin whose audit surface is the thing that makes it
794// reviewable, and a debug counter is not worth that.
795async function budgetLine($, sessionId, cap) {
796  try {
797    const used = spent.get(sessionId) ?? 0;
798    const usage = await $.session.usage();
799    const ctx = usage?.context;
800    const where = ctx ? ` context ${ctx.tokens}/${ctx.window} (${ctx.percent}%)` : '';
801    const line = `actual rules brief: injected ${used}/${cap} chars this session;${where}`;
802    $.ui.log(line);
803    return line;
804  } catch {
805    // Instrumentation must never be the reason a brief fails.
806    return '';
807  }
808}
809
810// Same three switches the shell hooks honour, read through $.env so a test can
811// set them. ACTUAL_CLI_SUBPROCESS is not an opt-out but a recursion guard:
812// actual-cli runs its conformance judge as a nested `claude -p`, and a judge
813// must weigh the rules it was handed, not rules a hook injected into its prompt
814// mid-run.
815//
816// Every mods API call is async, including $.env.get. Reading these without
817// awaiting compares a Promise to a string -- never equal, so every opt-out
818// silently stops working -- and leaks an unhandled rejection per call. Awaited
819// together rather than in sequence: three env reads are independent.
820async function optedOut($) {
821  const [planGate, briefSwitch, subprocess] = await Promise.all([
822    $.env.get('ACTUAL_PLAN_GATE'),
823    $.env.get('ACTUAL_RULES_BRIEF'),
824    $.env.get('ACTUAL_CLI_SUBPROCESS'),
825  ]);
826  return planGate === 'off' || briefSwitch === 'off' || subprocess === '1';
827}
828
829// Everything that happens once a set of governed files is in hand: the
830// budget, the CLI call per file, the record, and the context handed to the
831// model. Shared by the shell path and the Read path so neither can drift
832// from the other -- the divergence that produced every key bug in this mod.
833async function deliverBrief($, e, result, paths, ctx) {
834  const { cwd, sessionId, rulesDir, repoRoot, options } = ctx;
835  const resolved = mergeSettings(await readEnvSettings($), options);
836  const cap = Number.isFinite(resolved.maxSessionChars)
837    ? resolved.maxSessionChars
838    : DEFAULT_MAX_SESSION_CHARS;
839
840  // A cap of 0 turns briefing off, which `maxSessionChars` documents. Off has
841  // to mean silent: this used to fall through to the budget row below, so the
842  // switch that disables the feature made it report on every single read.
843  if (cap <= 0) return result;
844
845  let remaining = spendable(sessionId, cap);
846  if (remaining <= 0) {
847    recordOutcome(sessionId, e.tool_use_id, { outcome: 'budget', files: paths });
848    // Said once a session, not once a read. The budget is spent for the rest
849    // of the session, so every later read of any file in the repository --
850    // including the ungoverned ones this promises to stay quiet about --
851    // repeated the same row. The pane still lists each one.
852    if (!budgetAnnounced.has(sessionId)) {
853      budgetAnnounced.add(sessionId);
854      $.ui.log(row('BUDGET', `${cap} chars spent; quiet for the rest of this session`));
855    }
856    return withContext(result, await debugNote($, 'not briefed', `session budget of ${cap} chars is spent`));
857  }
858
859  if (!(await rulesBriefAvailable($, sessionId, cwd))) {
860    recordOutcome(sessionId, e.tool_use_id, { outcome: 'no-cli', files: paths });
861    return withContext(result, await debugNote($, 'not briefed', '`actual rules brief` is not available in this CLI'));
862  }
863
864  let appended = '';
865  let exhausted = false;
866  let cliError = null;
867  const summaries = [];
868  // Which files actually contributed, as opposed to which resolved. A
869  // command naming two governed files where only one has anything new to
870  // say must not be reported as having briefed both: `package.json,
871  // pnpm-lock.yaml` read as two sources when the lockfile contributed
872  // nothing, which is exactly the kind of thing this line exists to make
873  // visible and so must not itself misstate.
874  const briefedFiles = [];
875  // Why each quiet file was quiet, from the CLI's stderr. Hook-mode stdout
876  // cannot say -- it carries the brief the model reads -- so the reason is
877  // named on stderr as `no-brief=<token>`, which the model never sees.
878  const reasons = new Map();
879  for (const filePath of paths) {
880    let run;
881    try {
882      run = await $.process.run(
883        [
884          'actual', 'rules', 'brief', '--claude-hook',
885          // The root, stated rather than inferred. The CLI takes it from
886          // `--repo` first and from the envelope's `cwd` otherwise, and that cwd
887          // follows the agent's `cd`: measured on 2.1.296, after
888          // `cd apps/actual && ls lib/oauth` the next call ran in
889          // `<repo>/apps/actual`. The CLI then placed every file against that
890          // subdirectory -- files inside it matched as `lib/...` against
891          // repository-relative globs, files outside it refused as "not under"
892          // -- so four governed reads in one session briefed nothing while this
893          // mod had resolved all four correctly. 11 of the 35 Bash calls in the
894          // reference session began with `cd <dir> &&`.
895          '--repo', repoRoot,
896          '--rules-dir', rulesDir,
897          ...cliLimits(resolved),
898        ],
899        {
900          stdin: envelopeFor({ sessionId, agentId: e.agentId, cwd, filePath, tool: e.tool }),
901          cwd,
902          timeoutMs: SPAWN_TIMEOUT_MS,
903        }
904      );
905    } catch {
906      // Past the timeout, or the CLI could not start. `$.process.run` rejects
907      // for both rather than returning an exit code. Without this the
908      // rejection left for the handler's outer catch, which returned the
909      // untouched result: no row, and a brief already collected for an earlier
910      // file thrown away after the CLI had recorded it as shown.
911      cliError = 'timed out or did not start';
912      continue;
913    }
914    if (run.exitCode !== 0) {
915      cliError = `exit ${run.exitCode}`;
916      continue;
917    }
918    const reason = /no-brief=([a-z-]+)/.exec(run.stderr ?? '')?.[1];
919    if (reason) reasons.set(filePath, reason);
920    const brief = briefFrom(run.stdout);
921    if (!brief) continue;
922
923    // A brief that does not fit is dropped whole, and the budget is
924    // declared spent. Taking the next, smaller brief instead would make
925    // which rules an agent sees depend on the order it happened to read
926    // files in.
927    if (brief.length > remaining) {
928      exhausted = true;
929      break;
930    }
931    appended += (appended ? '\n\n' : '') + brief;
932    remaining -= brief.length;
933    summaries.push(summarize(brief));
934    briefedFiles.push(filePath);
935  }
936
937  if (exhausted) {
938    // Spend the rest of the budget so the notice is emitted once, not on
939    // every subsequent read.
940    spend(sessionId, cap);
941    // Claude is told as well as the user, deliberately. The drawn toast is
942    // for the person watching; Claude still needs to know that briefing has
943    // stopped, or it is back to not being able to tell silence from a file
944    // no rule governs.
945    appended += (appended ? '\n\n' : '') + EXHAUSTED_NOTICE;
946    $.ui.toast('Actual rule briefing has reached its per-session context budget.');
947  } else if (appended) {
948    spend(sessionId, appended.length);
949  }
950
951  if (appended) {
952    // One record for the call, merging what each file contributed.
953    recordOutcome(sessionId, e.tool_use_id, {
954      outcome: exhausted ? 'budget' : 'delivered',
955      files: briefedFiles,
956      // Kept so the pane can still show what resolved but said nothing.
957      resolved: paths,
958      summary: {
959        decisions: summaries.flatMap((x) => x.decisions),
960        shown: summaries.reduce((n, x) => n + x.shown, 0),
961        total: summaries.reduce((n, x) => n + x.total, 0),
962        chars: appended.length,
963      },
964    });
965  } else if (cliError) {
966    recordOutcome(sessionId, e.tool_use_id, { outcome: 'cli-error', files: paths, detail: cliError });
967  } else {
968    // Resolved a governed file and the CLI said nothing: every applicable
969    // decision has already been briefed this session. The single most
970    // useful thing to be able to see, because it is the one case where
971    // silence is correct.
972    // One outcome name, two very different facts, which is why the CLI now
973    // says which. A read whose files are all ungoverned is the common case
974    // and reports nothing: in a repo of any size most files are not governed,
975    // and a line on every one of them would bury the reads that matter.
976    // When every file was quiet for the same reason, the row says which. A
977    // mixed call stays `deduped`, whose line names each group.
978    const kinds = new Set(paths.map((f) => reasons.get(f) ?? 'unknown'));
979    const only = kinds.size === 1 ? [...kinds][0] : null;
980    recordOutcome(sessionId, e.tool_use_id, {
981      outcome: QUIET_OUTCOMES[only] ?? 'deduped',
982      files: paths,
983      reasons: Object.fromEntries(reasons),
984    });
985    // The reason the CLI gave, per file, rather than one guess for all of
986    // them. This said "already briefed" whatever the token was, so a debug
987    // session chasing an ungoverned or outside-root file was told the
988    // opposite of what the CLI had reported.
989    const why = paths
990      .map((f) => `${f}: ${reasons.get(f) ?? 'no reason given'}`)
991      .join('; ');
992    const note = await debugNote($, 'not briefed', `nothing new for ${why}`);
993    if (note) appended = note;
994  }
995
996  if (options.debug || (await $.env.get('ACTUAL_HOOK_DEBUG'))) {
997    const line = await budgetLine($, sessionId, cap);
998    if (line) appended += (appended ? '\n\n' : '') + line;
999  }
1000
1001  // Report through $.ui.log. An earlier version drew a line under the tool
1002  // result, which depends on the render site Claude Code uses and collapses
1003  // some calls into a summary row whose id is not any member call's
1004  // tool_use_id, so reports went missing. $.ui.log writes a dim transcript
1005  // line the model does not read and depends on no render site.
1006  //
1007  // This reverses an invariant added two commits earlier -- that the mod logs
1008  // nothing unless debugging. That rule was about leftover debug noise
1009  // reaching a user's transcript, which is still true of `EX=true`. One
1010  // deliberate line per brief is the report itself.
1011  // $.ui.log already prefixes the plugin's name, so the line carried its own
1012  // prefix twice: `actual-cli: actual: briefed 2 ADRs...`.
1013  //
1014  // An Edit or Write reports only what it delivered or what failed. Nearly
1015  // every edit is of a file already read, so its `NO CHANGE` row would be the
1016  // same line under every edit of a session; the pane still lists each one.
1017  const record = outcomesFor(sessionId).get(e.tool_use_id);
1018  const quietWrite =
1019    WRITE_TOOLS.includes(e.tool) && record?.outcome !== 'delivered' && record?.outcome !== 'cli-error';
1020  const report = quietWrite ? null : reportLine(record);
1021  if (report) $.ui.log(report);
1022
1023
1024  if (!appended) return result;
1025
1026  return withContext(result, appended);
1027}
1028
1029// One label per outcome, the one table the pane reads. Two
1030// copies of this drifted apart once already, which is how `no new` and
1031// `nothing new for` came to say the same thing two ways.
1032const OUTCOME_LABELS = {
1033  delivered: 'briefed  ',
1034  deduped: 'no new   ',
1035  ungoverned: 'no rules ',
1036  'no-text': 'no text  ',
1037  'outside-root': 'off-root ',
1038  'no-rule-set': 'no set   ',
1039  budget: 'budget   ',
1040  'cli-error': 'error    ',
1041  'no-cli': 'old CLI   ',
1042  unresolved: 'no match ',
1043  'no-paths': 'no path  ',
1044  'not-read-only': 'writes   ',
1045};
1046
1047const PANE = 'rules-brief';
1048
1049// Set on a PostToolUse:Read event this mod is briefing, so hooks/rules-brief.sh
1050// stands down for it. Read only: PreToolUse uses ACTUAL_SKILL_MOD_SESSION
1051// instead. The hook matches the literal bytes, so a rename here must be made
1052// there too.
1053const STAND_DOWN_FIELD = 'actual_rules_brief_by_mod';
1054
1055// The tools briefed as a reminder when written, and the ones whose
1056// PreToolUse registration of rules-brief.sh this mod stands down. Exactly the
1057// CLI's own list: NotebookEdit names its file as notebook_path and the CLI
1058// does not answer it.
1059const WRITE_TOOLS = ['Edit', 'Write'];
1060
1061// The session this load of the module last announced to rules-brief.sh,
1062// through the ACTUAL_SKILL_MOD_SESSION variable the hook compares with its own
1063// session_id. A hot reload starts it over, which costs one repeated set.
1064let announced;
1065
1066async function announce($) {
1067  const sessionId = await $.session.id();
1068  if (!sessionId || sessionId === announced) return;
1069  // The same gates as the tool.call path, so a repository with no rules, or
1070  // briefing switched off, sees nothing of this mod -- not even a variable in
1071  // every process it starts. Not the maxSessionChars cap: at 0 the mod is
1072  // still in charge and silent, and announcing is what keeps the hook silent
1073  // too. Skipping here never costs a brief: the hook runs its own checks.
1074  if (await optedOut($)) return;
1075  const cwd = await $.session.cwd();
1076  if (!cwd || !(await governedRulesDir($, sessionId, cwd))) return;
1077  // A literal name, so `claude plugin validate` lists exactly this one write.
1078  await $.env.set('ACTUAL_SKILL_MOD_SESSION', sessionId);
1079  announced = sessionId;
1080}
1081
1082// Rows for the pane, oldest first: what each Bash call did about briefing,
1083// including the calls that did nothing and why.
1084//
1085// In session order, because the rows explain each other -- a `no new` row only
1086// makes sense once the delivered row above it has stated the rules it is
1087// suppressing. Newest first put every explanation below the thing it
1088// explained.
1089function paneRows(elements, sessionId) {
1090  const { Box, Text } = elements;
1091  const rows = [...outcomesFor(sessionId).entries()];
1092  if (rows.length === 0) {
1093    return [Text({ dimColor: true, children: ['No reads yet in this session.'] })];
1094  }
1095  return rows.slice(-40).map(([id, record], i) => {
1096    const files = (record.files ?? []).map((f) => f.split('/').pop()).join(', ');
1097    // Files that resolved but contributed nothing, so a read that looks
1098    // unbriefed can be told from one that had nothing left to say.
1099    const quiet = (record.resolved ?? [])
1100      .filter((f) => !(record.files ?? []).includes(f))
1101      .map((f) => f.split('/').pop())
1102      .join(', ');
1103    const label = OUTCOME_LABELS[record.outcome] ?? record.outcome;
1104    const detail =
1105      record.outcome === 'delivered'
1106        ? `${counted(record.summary?.decisions?.length ?? 0, 'ADR')}, ${record.summary?.shown ?? 0} of ${counted(record.summary?.total || record.summary?.shown || 0, 'rule')}, ${record.summary?.chars ?? 0} chars`
1107        : record.detail ?? files ?? '';
1108    return Box({
1109      key: `row-${i}-${id}`,
1110      flexDirection: 'row',
1111      columnGap: 1,
1112      children: [
1113        Text({ dimColor: record.outcome !== 'delivered', children: [label] }),
1114        Text({ children: [files || '—'] }),
1115        Text({ dimColor: true, children: [detail && detail !== files ? `· ${detail}` : ''] }),
1116        Text({ dimColor: true, children: [quiet ? `· nothing new for ${quiet}` : ''] }),
1117      ],
1118    });
1119  });
1120}
1121
1122// ── the count above the prompt ──────────────────────────────────────────────
1123//
1124// "Actual AI: X of Y ADRs added to context": of the Y decisions the rules
1125// hold, the X briefed into this session so far, as `actual session summary`
1126// reads them from brief memory. Drawn in the band above the prompt, so the
1127// model never reads it.
1128//
1129// Read in the background at session start, after each main-loop turn, and on
1130// /clear, compaction, resume and fork. Nothing waits on the CLI, and a call
1131// that fails or outlasts SPAWN_TIMEOUT_MS keeps the count already shown.
1132
1133// Per session: the sentence its band shows, and the refresh that set it. An
1134// `undefined` sentence shows nothing (no rule files, or no decision in them).
1135const sessionCounts = new Map();
1136let countRefreshes = 0;
1137
1138function countSentence(x, y) {
1139  if (y === 0) return undefined;
1140  return `Actual AI: ${x} of ${y} ${y === 1 ? 'ADR' : 'ADRs'} added to context`;
1141}
1142
1143function isCount(n) {
1144  return Number.isInteger(n) && n >= 0;
1145}
1146
1147// One row: cut at the band's width, never wrapped onto a second.
1148function fitTo(text, columns) {
1149  if (!Number.isFinite(columns) || text.length <= columns) return text;
1150  return columns > 1 ? `${text.slice(0, columns - 1)}\u2026` : '';
1151}
1152
1153// A CLI too old to have the subcommand exits non-zero.
1154async function sessionSummary($, sessionId, rulesDir, repoRoot) {
1155  const run = await $.process.run(
1156    ['actual', 'session', 'summary', '--session', sessionId, '--rules-dir', rulesDir, '--json'],
1157    { cwd: repoRoot, timeoutMs: SPAWN_TIMEOUT_MS }
1158  );
1159  if (run.exitCode !== 0) return null;
1160  const { x, y } = JSON.parse(run.stdout);
1161  return isCount(x) && isCount(y) ? { x, y } : null;
1162}
1163
1164// Never rejects. The rules directory is the one the briefs are filed under,
1165// so X counts what this session was actually briefed. A refresh that lands
1166// after a newer one leaves the newer count, and the band is asked to redraw
1167// only when its sentence changes, because a render answer is cached until
1168// $.ui.invalidate.
1169async function refreshCount($) {
1170  const ticket = ++countRefreshes;
1171  try {
1172    if ((await $.env.get('ACTUAL_CLI_SUBPROCESS')) === '1') return;
1173    const [cwd, sessionId] = await Promise.all([$.session.cwd(), $.session.id()]);
1174    if (!sessionId) return;
1175    const rulesDir = await governedRulesDir($, sessionId, cwd);
1176    let text;
1177    if (rulesDir) {
1178      const repoRoot = await resolveRepoRoot($, sessionId, cwd);
1179      const counts = await sessionSummary($, sessionId, rulesDir, repoRoot);
1180      if (!counts) return;
1181      text = countSentence(counts.x, counts.y);
1182    }
1183    const last = sessionCounts.get(sessionId);
1184    if (last && ticket < last.ticket) return;
1185    sessionCounts.set(sessionId, { ticket, text });
1186    if (text !== last?.text) $.ui.invalidate('ui.render');
1187  } catch {
1188    // A spawn that rejected (no CLI, past the timeout) or output that did not
1189    // parse: the band keeps what it showed.
1190  }
1191}
1192
1193export function register(on, options = {}) {
1194  on('session.start', async ($, e, next) => {
1195    // The count's first reading belongs here: at startup classic.SessionStart
1196    // fires before the session binds.
1197    void refreshCount($);
1198    try {
1199      await $.command.register({
1200        name: 'rules-brief',