Work with the agent in one place: each edit checked against your CLAUDE.md as it is made and each turn's diff reviewed (/changes); the OpenSpec change read for…

A Claude Code mod for working with the agent, in three parts that know of each other, and a guard over them all:
CLAUDE.md and AGENTS.md as Claude makes it, and Claude is told what it broke before it moves on. After each turn that edits files, the diff across every repository it touched is reviewed against what you asked for and the same rules. /changes opens the diff, what each piece is for, the findings and a walkthrough.openspec/changes, /spec gives the change's gist, what changed since you last looked, what is left for you to decide, its tasks and its spec, and answers questions about it./router shows what it picked and why./changes guard says the limits./setup-project prepares any project for work with the agent, a step at a time, and keeps the files it writes from rotting: each is checked like code.It was four mods: review-lens, rules-watchdog, openspec-lens and skill-router.
One row, the change in view first, then what the agent's last turn changed:
◆ add-dark-mode · 2 to decide · tasks 7/12 │ ✎ 3 files +120 −30 · 1 likely bug · 2 findings /spec /changes
Each part opens its pane, on the tab that tells more of it. Where the row is too narrow for both, each has a row of its own. Red is broken, yellow wants a look, green is done. The rows go above those of the mods loaded after agent-lens, so a short band keeps them in view.
/spec names OpenSpec's skill. A task or an archiving the spec's pane sends tells Claude to load OpenSpec's own skill for it (openspec-apply-change or opsx:apply, openspec-archive-change or opsx:archive), where one is installed and not loaded yet, with no model asked. The router keeps it among its decisions.lang ru after any of the three commands switches all of them./setup-project needs Matt Pocock's skills (mattpocock-skills) and the OpenSpec CLI: missing, it says how to install them and does not run; found, it says so in a line. Then the steps go to Claude one at a time, each once the turn before has ended, in a pane that shows where each stands:
setup-matt-pocock-skills, run as if you typed it: the tracker, the labels, the domain layout.openspec init --tools claude, run by the mod, where the project has no openspec/.CLAUDE.md it wrote, AGENTS.md, a glossary by its old name CONTEXT.md, another agent's rules): each piece is put to you with the file it belongs in, and what you pick moves there word for word, leaving its old place; CONTEXT.md becomes GLOSSARY.md by git mv. Without such docs the step is done already.GLOSSARY.md in the form of Matt's domain-modeling, ARCHITECTURE.md, an ADR in docs/adr/ for each decision that qualifies, numbered on from those there.CODING_STANDARDS.md. Each says what, why (the mistake it prevents), where it shows, and what checks it (eslint: <rule> or review).docs/agents/workflow.md: how work goes (OpenSpec for the changes, Matt's skills at each step), when a task is done here, and how the work is shared between the main agent and its subagents. And CLAUDE.md: the language you are spoken to in first, pointers to the files (GLOSSARY.md imported with @GLOSSARY.md while it is short, so its terms hold on every turn), the checks in two tiers (what the change touches while working, everything before a commit or push).openspec/config.yaml: its context points to the files, its rules hold the artifacts to them.A step is done once its files are there and pass the check; where they fail, what failed goes back to Claude once, then waits for you (f sends it again, a the whole step, s skips it). Run again, it adds to what is there.
From then on the files are checked at each session's start, on each edit and at the end of each turn (an edit made through the shell included), as openspec validate --strict checks a change: a file past its size (CLAUDE.md with what it imports), a path it names that is not there, a glossary entry out of its form or naming code, a glossary by the old name Matt's skills no longer read, an ADR misnamed or misnumbered, a line kept in two files. Beside the session's start it also looks, with git, for what may have gone stale: a part of the map with 15 commits or more since the map was edited, a glossary term found nowhere else in its repository.
A turn that edits these files, by any tool, the shell too, has an agent (Explore, on sonnet) read the edits against the code: is it true of the code, in the file it belongs in, does it change what an agent does, does it repeat another file. A line added to CLAUDE.md that is neither a pointer nor a rule every turn needs is a blocker, and its fix names the file it belongs in. This one runs in any project: a turn that grows its CLAUDE.md has it read so. What the agent finds, blockers and minor ones, is on the pane; j sends it to Claude to fix. Its report is kept from Claude as the spec critic's is.
It is told the lines each file gained and lost, not both sides whole, and reads the rest itself; while /setup-project's steps go it waits, and reads what they all wrote once, at the run's end.
While a check fails, or the agent's blockers wait, the row above the prompt says docs: 2 issues; /setup-project check lists them. Why each file holds what it holds, and what comes next, is in docs/setup-project.md.
Each turn of every agent, Claude's own and each subagent's, the ones it starts and the ones a part of this mod starts, is held to limits counted from the turn's start, each loop on its own:
⏱ Claude · 34 min · 80 steps.■ Claude stopped. Your next prompt starts a new turn and a new count.The time is the agent's own work: a question put to you, a permission waiting on you, and a subagent it waits on (which keeps its own count) are left out. /changes guard says the limits and what is running; /changes guard 90 200 sets them, /changes guard off turns the guard off.
It works in any project: one repository, a folder of clones each with its own .git (__front, __backend), submodules, or no git at all; on Windows, macOS and Linux, and where an organization manages Claude Code.
Once a turn has edited a file:
✎ 3 files +120 −30 · 1 likely bug · 2 findings · 4 pieces not looked at /changes
3 files +120 −30 what the last turn with edits changed, a subagent's edits included.1 likely bug, in red, 2 findings, in yellow: what the review found, made by itself after the turn, and those of earlier reviews still open, with every rule an edit broke that is still in the code. A finding stays until it is sent to Claude or put aside, its file is back as it was before the session, or a later review that saw its line change does not find it again. A rule put aside and broken again shows again. A later review of the same file is told what was put aside or sent to Claude there, so it does not find it again in other words.reviewing…, in yellow, while the review runs.not reviewed: small change, as plain text: the turn touched a line or two, so it was not reviewed by itself; r on Findings reviews it.not reviewed: spec edits, /spec reads them, as plain text: the turn edited only the Markdown of openspec/; /spec reads that, and the review leaves it out of every turn it reviews, so the two do not pick at the same text. r on Findings reviews it anyway.review failed, in red: the model gave no review (the API failed, or its reply could not be read); not reviewed, in yellow: the review stopped before it finished, as a reload of the mod cuts one short. Either opens Findings, which says why, and r there runs it again.4 pieces not looked at, in yellow: hunks you have not marked as looked at.looked at, in green, once every piece is and nothing is left to fix.The ✎ is red while a likely bug stands or the review failed, yellow while anything waits, green otherwise.
/changes opens it. Four tabs, 1–4, and the diff's mode at the bottom: the last turn, the whole session, or what is uncommitted (git diff HEAD and untracked files) in every repository the session touched and the project's own. Before Claude has edited anything it opens on what is uncommitted, and the first turn with edits switches it to that turn. A piece marked looked at stays so in the other modes, where the turns are cut into pieces otherwise.
looked at, ask (a question about this piece), and take back (Claude removes this piece and puts the lines back). s marks the whole file.fix sends it to Claude, who fixes it or says why it is not a problem; not a bug puts it aside. f, fix all, sends every finding still open in one prompt, once two or more are. r reviews a diff not yet reviewed (the session's or the uncommitted one). What earlier reviews found and nobody has answered yet stands below, under its own heading: a later turn does not drop it. Under Caught while Claude edited, the rules each edit broke: still in the code, a finding like any other; else not told Claude yet, told Claude or edit refused in yellow while the turn runs, fixed in green once it is gone. A finding sent to Claude shows → sent to Claude in yellow, one put aside ✓ put aside in green.g makes it again.ask on it.Esc closes the pane.
Each Edit and Write is checked against the rules for its file by a small model (haiku), beside the turn, so the edit is not slowed down. Where it breaks one, a toast says so and Claude gets a note with the next tool result:
agent-lens: your edit to src/button.ts breaks the project's rules (CLAUDE.md, CODING_STANDARDS.md).
- Never write comments in code.
found: // primary button styles
fix: Remove the comment line above the buttonStyle export.
Fix this before moving on.
If Claude ends its turn first, the mod waits for the checks still running (up to 8 seconds) and sends Claude back once to fix what they caught; where an organization manages Claude Code, as a new prompt right after the turn. A subagent's note goes to the main conversation. A note that cannot go then (the turn was sent back already, or another hook keeps it going) is not dropped: it rides on the next tool result, and its rule shows as told only once it has reached Claude. Once the turn ends, what was caught is looked for again: still in the code, it joins the findings, and the review after the turn is told not to report it a second time.
Only rules about what files contain are judged (style, naming, comments, structure, forbidden patterns, hardcoded values); rules about git, workflow or how to talk are not. Edits to CLAUDE.md, AGENTS.md and CODING_STANDARDS.md themselves are not checked here (the docs agent of /setup-project reads them), nor the Markdown of an OpenSpec change, which /spec reads. A check that fails or answers something unreadable lets the edit be.
/changes rules says how edits are checked and which rule files apply in the project folder.
For each file changed, the CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, AGENTS.md and CODING_STANDARDS.md (the standards /setup-project writes, read here rather than on every turn) of its folder and of every folder above it, up to the project folder (for __front/src/app.ts: __front/src/, __front/ and the project's own), then those Claude Code read at the start from the folders above the project, each with its @ imports. Your own ~/.claude/CLAUDE.md only where the settings ask for it. Each is read up to 16k characters, all of them up to 48k.
The review, the lines on each piece, the walkthrough and the answers run on sonnet through Claude Code's own client, so they count against your plan's limits or your API spend like any request. The review runs once after each turn that edits more than a line or two; the rest only when you open the tab, once per version of the diff. Each call sends the rules, then the request, the changed files as they are now with their lines numbered (whole up to 300 lines and 12k characters each, else the 40 lines around each piece, 32k characters in all) and the diff, both marked for the prompt cache, and what it asks last: the calls on one diff within five minutes read the rules and the files from the cache. The review runs at high effort and may reason in a few sentences before its answer; the lines on each piece at low, the rest at medium. The check of each edit is one haiku call per Edit or Write, the rules sent first and cached, so the checks in one project read them from the cache. The foot of the pane says what the mod's own calls have cost this session, as the API counted it: input tokens, those the cache served, output. Sending a finding or a piece to Claude is a turn of the session itself, on its own model.
The diff of a turn is taken from the edit tools (Write, Edit, NotebookEdit) and from the shell: a file a command (sed -i, a generator) left uncommitted that was not before it is in the turn, from what it was before the turn, or from what is committed. That holds in the project's repository, in every repository a tool edited a file in, and in a repository of its own in a top folder of the project (dev-env's __front, __bff…); a file already uncommitted there before the command is not. The uncommitted mode shows every change in those repositories.
A diff keeps 2,000 lines of a file and 20,000 in all (400k and 2M characters): a turn that deletes or rewrites a big file (a lockfile, a data file) shows the rest as N lines left out on the file's row, in yellow, and the review is told so; its counts are whole, and a piece cut short has no take back. A file over 4 MiB, which Claude Code does not read, is left out of the diff.
In a project with an openspec/changes folder, one row shows the change in view (the one you picked, else the latest edited): first what wants a look, red before yellow, then its progress.
◆ add-dark-mode · 2 to decide · spec changed · tasks 7/12 · tests 9 auto, 2 manual /spec
tasks 7/12 the ticked boxes of tasks.md, followed as agents tick them; green once all are.tests 9 auto, 2 manual the test cases of test-cases.md by their Automated: and Manual: lines (bold or in a list item too, - Automated: e2e/a.spec.ts, e2e/b.spec.ts); 13 test cases where the file marks none of them. A case is any heading that starts with its id: ## TC-01: Title, ### TC-02. Title.TC-07 unmarked, in red, a case that says neither, where the file marks the others.2 cases with no test, in yellow, cases marked Automated: none of whose files holds a test with their id.2 to decide, in yellow, what the brief found left for a person to decide; what to decide unknown, in yellow, where the brief came without that list (g on Decide makes it again).1 decided, not sent, in yellow, a decision you made in the pane and have not sent to Claude yet.1 spec error, in red, 2 warnings, in yellow: what openspec validate --strict found, run on the change each time its text changes (where the openspec CLI is installed).3 scenarios with no case, in yellow, scenarios of the change's specs that no test case names on its Covers: line (Covers: Requirement: X / Scenario: A; Scenario: B; a requirement named alone counts whole). Where no case has such a line, nothing is counted.1 not matching the main spec, in yellow, a requirement the change adds that the main spec already has, or changes that it does not have: openspec archive refuses those.overlaps door-access-page, in yellow, another active change whose specs touch the same capability: the two will collide when the second is archived.spec changed, in yellow, the change was edited since you last read it. Ticking tasks off is progress, not a change, and does not count.ready to archive, in green: every task ticked, the check clean, nothing left to decide, every test case marked and with its test, every scenario covered, every requirement matching the main spec.The ◆ is red while a case is unmarked or the spec has errors, yellow while any part above waits for you, green otherwise. making the brief…, in yellow, while the model reads the change.
Each part opens the pane on the tab that tells more of it: 2 to decide on Decide, spec changed on Changes, 1 spec error on Spec. /spec at the end opens the pane where you left it. Where the row is too narrow, progress gives way first, and +3 more says how much was left out, in red or yellow where that holds a problem.
/spec opens it on the change in view, /spec <change> on another, and the pane's change list at the bottom switches between the active ones (your pick is kept). The change and its ◆ head the pane; under them seven tabs, 1–7, each with a count of what is in it to look at. A button pressed by a key shows the key before its label (g refresh), and the last row says the keys. The model reads for one tab at a time: what you ask for while it reads (a question during the brief, say) waits its turn, and the pane says what comes next.
g makes it again; a brief older than the change says so at its top.e asks the model what changed in substance, s marks it read. The first time you open a change counts as reading it.decide on a point opens two or three ways to settle it, offered by the model for all the points at once, and a field for your own; the one the model recommends comes first, marked ★, with why under it. Picking one opens the next point, change and clear take it back. a sends what you decided to the session as your prompt: Claude writes it into the change's files and asks you where a decision contradicts the spec. d, Claude decides all, sends every point not decided yet: Claude takes the answer the change and the code argue for, writes it in with its reason, and asks you about a point it cannot settle alone. Once Claude's turn has changed the spec, the brief is made again by itself, told the points already decided so it does not ask them again, and the tab shows what is left. A turn that ends with the spec as it was says so: w sends the decisions again, change the answers brings them back to the tab.The critic, off unless you turn it on (/spec critic button or auto, or in Settings): an agent (Explore, on sonnet) reads the change and the code it names or will touch, and lists up to five holes at the top of the tab, each with where it shows and how to close it: something the change names that is not there, code it will touch and does not mention, its files contradicting each other, a requirement no task builds or no case checks, a case the specs leave undefined. Each hole is a blocker (built as the files say, the work goes wrong or a requirement goes unchecked) or minor (a detail the code settles without asking anyone, such as an initial value), the way an inspection sorts major and minor defects. f sends it to look; h sends what it found to the session as your prompt, and Claude takes the holes one at a time: closes each in the change's file where it belongs, asks you where only you can decide (what you leave open goes under Open Questions in design.md), or declines it in a line of design.md where the code shows the agent is wrong, and ends with a table of what it did. In auto it also looks after each turn that changed the change's text (a task ticked is not a change), and the blockers it finds go to Claude by itself, once the turn running has ended with an answer: a turn you stop with Esc, or one an error ends, leaves them waiting and starts no critic. The minor ones stay on the tab for h. Each round is told what was decided and sent before, so it does not report it again. Auto mode stops at a look with no blocker (✓ No blocking holes), or after three rounds of the change, what the last one changed yours to look at; either way only f starts it again, the rounds counted anew. The report the engine hands back to the session as the agent ends is kept from Claude: the holes reach it only by that prompt, so none is closed twice.
tasks.md under their headings, ticked or not. n sends the next one to the session ahooks/register.tsx 246 lines1import type { Hook, Register, RenderChildren } from 'claude-code'
2
3import { configureLang, restoreLang } from './core/lang'
4import { checkedGuard, clearGuard, configureGuard, endGuardTurn, guardAfter, guardBefore, guardCommand, guardLines, restoreGuard, startGuardTurn } from './guard/guard'
5import {
6 aroundShell,
7 beforeEdit,
8 beforeNotebookEdit,
9 beforeWrite,
10 checkAfter,
11 checkBefore,
12 classicStop,
13 classicSubagentStop,
14 configureReview,
15 endReviewTurn,
16 PANE as REVIEW_PANE,
17 command as reviewCommand,
18 pane as reviewPane,
19 openPane as openReview,
20 restartReview,
21 reviewLine,
22 startReview,
23 turnStart,
24} from './review/review'
25import {
26 clearRouter,
27 configureRouter,
28 endRouterTurn,
29 promptAutocomplete,
30 promptSubmit,
31 restoreRouter,
32 command as routerCommand,
33 pane as routerPane,
34 sessionCompact,
35 skillPrompt,
36 startRouter,
37} from './router/router'
38import { PANE as ROUTER_PANE } from './router/state'
39import { afterSetupEdit, dismissRetro, endSetupAgent, endSetupTurn, judgeReport, retroLine, runRetro, openSetup, PANE as SETUP_PANE, pane as setupPane, setupCommand, setupLine, startSetup, startSetupTurn } from './setup/setup'
40import { langCommand } from './switch'
41import { afterSpecEdit, configureSpec, endSpecAgent, endSpecTurn, criticReport, openPane as openSpec, rescanSpec, PANE as SPEC_PANE, command as specCommand, pane as specPane, specLine, startSpec, startSpecTurn } from './spec/lens'
42
43// Four parts in one module, each in its own folder, sharing the language: the review of what the
44// agent changed (with the check of each edit as it is made), the OpenSpec change in view, the
45// skill each prompt gets, and the guard that holds each agent's turn to its limits. Every hook is registered here; on an event more than one part hears,
46// each part's work runs in turn, and one that fails does not stop the others.
47// Between the spec's part of the row and the review's; and the columns the spec keeps for itself
48// there, its name and a part or two, below which each goes on a row of its own.
49const SPLIT = ' │ '
50const MIN_SPEC = 40
51
52// What every part shows, read back into values a /clear or /resume emptied: the language, the change
53// in view, the router's mode, setup and skills.
54async function readBack($: Parameters<Hook<'session.end'>>[0]) {
55 await restoreLang($).catch(() => {})
56 await rescanSpec($).catch(() => {})
57 await restoreRouter($).catch(() => {})
58 await restoreGuard($).catch(() => {})
59 await startSetup($).catch(() => {})
60}
61
62// What the person is told such a report went to, or undefined for any other text.
63async function agentReport($: Parameters<Hook<'prompt.submit'>>[0], text: string) {
64 // The docs agent first: the critic's check knows every agent this mod started.
65 return (await judgeReport($, text).catch(() => undefined)) ?? (await criticReport($, text).catch(() => undefined))
66}
67
68export const register: Register = (on, options) => {
69 configureLang(options)
70 configureReview(options)
71 configureSpec(options)
72 configureRouter(options)
73 configureGuard(options)
74
75 on('session.start', async ($, e, next) => {
76 await restoreLang($).catch(() => {})
77 await restoreGuard($).catch(() => {})
78 await startReview($).catch(() => {})
79 await startSpec($, e.isInteractive).catch(() => {})
80 await startSetup($).catch(() => {})
81 const started = await next(e)
82 await startRouter($).catch(() => {})
83 return started
84 })
85
86 // A /clear or /resume starts a conversation with its values emptied and no session.start. The old
87 // one's end drops what each part's module kept of it; its values are emptied after that end, so
88 // what the parts show is read back on the classic SessionStart that follows. Where an organization
89 // manages Claude Code no classic hook runs: the end reads it back too, for as long as it lasts.
90 on('session.end', async ($, e, next) => {
91 const ended = await next(e)
92 if (e.reason === 'clear' || e.reason === 'resume') {
93 await restartReview($).catch(() => {})
94 await clearRouter($).catch(() => {})
95 await clearGuard($).catch(() => {})
96 await readBack($)
97 }
98 return ended
99 })
100
101 on('classic.SessionStart', async ($, e, next) => {
102 const started = await next(e)
103 if (e.source === 'clear' || e.source === 'resume') await readBack($)
104 return started
105 }).catch(($, e, next) => next(e))
106
107 on('session.compact', ($, e, next) => sessionCompact($, e, next))
108 // The report of an agent this mod started (the spec critic, the docs agent) is its pane's, not a
109 // message for Claude: the engine queues a subagent's hand-back to the main conversation as a
110 // peer's prompt, and a delivery from outside the session reaches session.receive.
111 on('prompt.submit', async ($, e, next) => {
112 const kept = e.origin.kind === 'peer' ? await agentReport($, e.text) : undefined
113 return kept ? { drop: kept } : promptSubmit($, e, next)
114 })
115 on('prompt.autocomplete', ($, e, next) => promptAutocomplete($, e, next))
116 on('skill.prompt', ($, e, next) => skillPrompt($, e, next))
117 on('turn.start', async ($, e, next) => {
118 if (e.agentId === undefined) {
119 startSpecTurn()
120 startSetupTurn($)
121 }
122 await startGuardTurn($, e.agentId).catch(() => {})
123 return turnStart($, e, next)
124 })
125
126 on('session.receive', async ($, e, next) => {
127 const kept = e.agentId === undefined && e.origin.kind === 'peer' ? await agentReport($, e.text) : undefined
128 return kept ? { consumed: kept } : next(e)
129 })
130
131 on('turn.complete', async ($, e, next) => {
132 endRouterTurn(e.agentId, e.answer)
133 if (e.agentId === undefined) {
134 endSpecTurn($, e.reason)
135 endSetupTurn($, e.reason)
136 } else {
137 endSpecAgent($, e.agentId, e.answer)
138 void endSetupAgent($, e.agentId, e.answer).catch(() => {})
139 }
140 await endGuardTurn($, e.agentId).catch(() => {})
141 const result = await next(e)
142 await endReviewTurn($, e.agentId, e.reason).catch(() => {})
143 return result
144 })
145
146 // What the guard learns of a permission put to the person keeps their wait out of the agent's time.
147 on('tool.check', async ($, e, next) => {
148 const checked = await next(e)
149 checkedGuard(e.tool_use_id, checked.decision)
150 return checked
151 })
152
153 // Every edit, a subagent's too, is taken before it runs: what the file was is what the turn and
154 // the session are compared with.
155 on('tool.call', { tool: 'Write' }, ($, e, next) => beforeWrite($, e, next)).catch(($, e, next) => next(e))
156 on('tool.call', { tool: 'Edit' }, ($, e, next) => beforeEdit($, e, next)).catch(($, e, next) => next(e))
157 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => beforeNotebookEdit($, e, next)).catch(($, e, next) => next(e))
158 on('tool.call', { tool: 'Bash' }, ($, e, next) => aroundShell($, e, next)).catch(($, e, next) => next(e))
159
160 // Then the guard holds the call to its loop's limits; each edit is checked against the rules, and
161 // the spec read again where the edit was to it.
162 on('tool.call', async ($, e, next) => {
163 const held = await guardBefore($, e).catch(() => undefined)
164 if (held?.deny !== undefined) return { deny: held.deny }
165 const refusal = await checkBefore($, e).catch(() => undefined)
166 if (refusal !== undefined) return { deny: refusal }
167 const ran = await next(e)
168 if ((e.tool === 'Write' || e.tool === 'Edit') && !ran.isError) {
169 afterSpecEdit($, e.file_path)
170 afterSetupEdit($, e.file_path)
171 }
172 const checked = checkAfter($, e, ran)
173 return guardAfter($, e, held, checked).catch(() => checked)
174 }).catch(($, e, next) => next(e))
175
176 on('classic.Stop', ($, e, next) => classicStop($, e, next)).catch(($, e, next) => next(e))
177 on('classic.SubagentStop', ($, e, next) => classicSubagentStop($, e, next)).catch(($, e, next) => next(e))
178
179 // `lang` after any of them switches every part.
180 on('command.run', { command: 'changes' }, async ($, e) => (await langCommand($, 'changes', e.args)) ?? (await guardCommand($, e.args)) ?? reviewCommand($, e))
181 on('command.run', { command: 'spec' }, async ($, e) => (await langCommand($, 'spec', e.args)) ?? specCommand($, e))
182 on('command.run', { command: 'setup-project' }, ($, e) => setupCommand($, e.args))
183 on('command.run', { command: 'router' }, async ($, e) => (await langCommand($, 'router', e.args)) ?? routerCommand($, e))
184
185 // One row above the prompt: the OpenSpec change in view, then what the agent's last turn changed,
186 // each opening its pane. Where the change cannot keep its name beside the turn, each has a row.
187 // They go before what the plugins beneath drew: a band taller than its room scrolls from the top,
188 // and rows of theirs that do not count their room would push these out of view.
189 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
190 const below = await next(e)
191 if (e.props.hasSurvey || e.props.maxRows < 2) return below
192 const { Box, Text, Button } = $.ui.resolve(e)
193 const review = await reviewLine($, e).catch(() => null)
194 const reviewButton = <Button key="open-changes" label="/changes" plain dimColor onPress={() => void openReview($).catch(() => {})} />
195 const specButton = <Button key="open-spec" label="/spec" plain dimColor onPress={() => void openSpec($).catch(() => {})} />
196 // Whether the spec keeps its room beside the review is settled first: on a row of its own it
197 // is laid out for the whole row, not squeezed as if the review were still beside it.
198 const taken = review ? ' /spec /changes'.length + SPLIT.length + review.width : ' /spec'.length
199 const fits = review !== null && e.props.bodyColumns - taken >= MIN_SPEC
200 const spec = await specLine($, e, fits ? taken : ' /spec'.length).catch(() => null)
201 const row = (key: string, items: RenderChildren, buttons: RenderChildren) => (
202 <Box key={key} flexDirection="row" gap={1} justifyContent="space-between">
203 <Box flexDirection="row" flexShrink={1} overflow="hidden">
204 {items}
205 </Box>
206 <Box flexDirection="row" gap={1} flexShrink={0}>
207 {buttons}
208 </Box>
209 </Box>
210 )
211 const rows =
212 spec && review && fits
213 ? [row('lens-row', [...spec, <Text dimColor>{SPLIT}</Text>, review.item], [specButton, reviewButton])]
214 : [...(spec ? [row('lens-row', spec, specButton)] : []), ...(review ? [row('review-row', review.item, reviewButton)] : [])]
215 // An agent past the guard's soft limit goes first, a row of its own: it is what wants a look now.
216 const guarded = (await guardLines($).catch(() => [])).map((g, i) => (
217 <Text key={`guard-row-${i}`} color={g.isStopped ? 'error' : 'warning'} wrap="truncate-end">
218 {g.text}
219 </Text>
220 ))
221 const retro = await retroLine($).catch(() => null)
222 if (retro)
223 rows.unshift(
224 row('retro-row', <Text color="warning">{retro}</Text>, [
225 <Button key="run-retro" label="/retro" plain onPress={() => void runRetro($).catch(() => {})} />,
226 <Button key="dismiss-retro" label="×" plain dimColor onPress={() => void dismissRetro($).catch(() => {})} />,
227 ]),
228 )
229 const docs = await setupLine($).catch(() => null)
230 if (docs) rows.unshift(row('setup-row', <Text color="warning">{docs}</Text>, <Button key="open-setup" label="/setup-project" plain dimColor onPress={() => void openSetup($).catch(() => {})} />))
231 rows.unshift(...guarded)
232 if (rows.length === 0) return below
233 return (
234 <Box flexDirection="column">
235 {rows}
236 {below}
237 </Box>
238 )
239 })
240
241 on('ui.render', { component: 'Pane', requestId: REVIEW_PANE }, ($, e) => reviewPane($, e))
242 on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, ($, e) => specPane($, e))
243 on('ui.render', { component: 'Pane', requestId: ROUTER_PANE }, ($, e) => routerPane($, e))
244 on('ui.render', { component: 'Pane', requestId: SETUP_PANE }, ($, e) => setupPane($, e))
245}
246hooks/core/lang.ts 40 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions } from 'claude-code'
3
4import type { Lang } from '../../types'
5
6// The language every part speaks and its model answers in: what `lang` set last, by any of the
7// commands, kept for every session; else the setting's; English where neither says.
8const langAtom = atom({ plugin: 'agent-lens', key: 'lang' } as const, null)
9const LANG_KEY = 'lang'
10
11// The language as last read, kept by the module too: a /clear empties the session's values and
12// the module lives on, so a read falls back here until the language is read again.
13let kept: Lang = 'en'
14let setting: Lang = 'en'
15
16export const langOf = (value: unknown): Lang | undefined => (value === 'en' || value === 'ru' ? value : undefined)
17
18export async function langIn($: EngineInterface): Promise<Lang> {
19 return (await read($, langAtom)) ?? kept
20}
21
22// The language saved, read again from the store: at the start, and after a /clear, which empties
23// the session's values and fires no session.start.
24export async function restoreLang($: EngineInterface) {
25 kept = langOf(await $.store.get(LANG_KEY).catch(() => undefined)) ?? setting
26 await update($, langAtom, () => kept)
27}
28
29export async function setLang($: EngineInterface, lang: Lang) {
30 kept = lang
31 await update($, langAtom, () => lang)
32 await $.store.set(LANG_KEY, lang)
33}
34
35export function configureLang(options: PluginOptions) {
36 setting = langOf(options.language) ?? 'en'
37 kept = setting
38}
39
40hooks/guard/guard.ts 218 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, PluginOptions } from 'claude-code'
3
4import type { Alert, Setting, Stuck } from '../../types/guard'
5import { langIn } from '../core/lang'
6import { WORDS } from './words'
7
8// The guard over each agent's turn, the main loop's and every subagent's alike: a call that repeats
9// with nothing changing is refused, as a stuck detector does; past the soft limit of time or tool
10// calls the agent is told to wrap up, and past the hard one every tool is refused, so it answers
11// with what it did. The limits are counted from the turn's start, each loop on its own.
12
13type CallEvent = Parameters<Hook<'tool.call'>>[1]
14type CallNext = Parameters<Hook<'tool.call'>>[2]
15
16const alertsAtom = atom({ plugin: 'agent-lens', key: 'guard.alerts' } as const, {})
17const SETTING_KEY = 'guard'
18const MAIN = 'main'
19
20// How many calls back a loop is read for a repeat: a repeat is refused the fourth time, a failure
21// the third, two calls taken in turn the sixth.
22const REPEATS = 3
23const FAILURES = 2
24const KEPT = 5
25// The tools that wait on the person: their time is not the agent's.
26const ASKING = new Set(['AskUserQuestion', 'ExitPlanMode'])
27// A tool that runs another loop, which keeps its own count: its time is not the caller's.
28const DELEGATING = new Set(['Agent', 'Task'])
29
30// One call a loop made and what came of it, by a key of the tool and its input and a hash of the result.
31type Done = { key: string; result: string; isError: boolean }
32// A loop's turn so far: when it started, its tool calls, the time a person or another loop took,
33// its last calls, and how far past its limits it went.
34type Loop = { at: number; steps: number; outside: number; done: Done[]; level: 'under' | 'soft' | 'hard' }
35
36const loops = new Map<string, Loop>()
37// The calls whose permission was put to the person, by id: the wait is theirs.
38const asked = new Set<string>()
39let setting: Setting = { isOn: true, minutes: 60, steps: 150 }
40let configured: Setting = setting
41
42async function wordsIn($: EngineInterface) {
43 return WORDS[await langIn($)]
44}
45
46const positive = (value: unknown, or: number) => {
47 const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
48 return Number.isFinite(n) && n > 0 ? Math.round(n) : or
49}
50
51export function configureGuard(options: PluginOptions) {
52 configured = { isOn: options.guard !== 'off' && options.guard !== false, minutes: positive(options.guardMinutes, 60), steps: positive(options.guardSteps, 150) }
53 setting = configured
54}
55
56// What /changes guard set, over the settings, for every session.
57export async function restoreGuard($: EngineInterface) {
58 const kept = (await $.store.get(SETTING_KEY).catch(() => undefined)) as Partial<Setting> | undefined
59 setting = kept ? { isOn: kept.isOn ?? configured.isOn, minutes: positive(kept.minutes, configured.minutes), steps: positive(kept.steps, configured.steps) } : configured
60}
61
62// A turn starts: its loop's count starts over. One whose start was missed (a reload) starts at its next call.
63export async function startGuardTurn($: EngineInterface, agentId: string | undefined) {
64 loops.set(agentId ?? MAIN, { at: await $.clock.now(), steps: 0, outside: 0, done: [], level: 'under' })
65 await clearAlert($, agentId ?? MAIN)
66}
67
68export async function endGuardTurn($: EngineInterface, agentId: string | undefined) {
69 loops.delete(agentId ?? MAIN)
70 await clearAlert($, agentId ?? MAIN)
71}
72
73// A /clear or /resume: no turn of the conversation before runs on.
74export async function clearGuard($: EngineInterface) {
75 loops.clear()
76 asked.clear()
77 await update($, alertsAtom, () => ({}))
78}
79
80async function clearAlert($: EngineInterface, id: string) {
81 if (id in (await read($, alertsAtom))) await update($, alertsAtom, ({ [id]: _gone, ...rest }) => rest)
82}
83
84// A permission put to the person: the call it is for waits on them.
85export function checkedGuard(toolUseId: string | undefined, decision: string) {
86 if (toolUseId && decision === 'ask') asked.add(toolUseId)
87}
88
89// The tool and its input, as the model gave them: the engine's own keys left out.
90const keyOf = (e: CallEvent) => {
91 const { tool_use_id: _id, agentId: _agent, requestMeta: _meta, consent: _consent, ...input } = e as CallEvent & Record<string, unknown>
92 return JSON.stringify(input)
93}
94
95const hashOf = (text: string) => {
96 let h = 5381
97 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
98 return `${text.length}:${h >>> 0}`
99}
100
101// Whether `key` called now repeats the loop's last calls with nothing changing, and how.
102export function stuckOf(done: readonly Done[], key: string): { why: Stuck; n: number } | undefined {
103 const last = done.slice(-REPEATS)
104 if (last.length === REPEATS && last.every(d => d.key === key && d.result === last[0]!.result)) return { why: 'repeat', n: REPEATS }
105 const failed = done.slice(-FAILURES)
106 if (failed.length === FAILURES && failed.every(d => d.key === key && d.isError)) return { why: 'error', n: FAILURES }
107 // B A B A B, the same results each time, and A now would be the sixth.
108 const turns = done.slice(-KEPT)
109 if (turns.length === KEPT) {
110 const [b, a] = [turns[KEPT - 1]!, turns[KEPT - 2]!]
111 const like = (i: number) => ((KEPT - 1 - i) % 2 === 0 ? b : a)
112 if (a.key === key && a.key !== b.key && turns.every((d, i) => d.key === like(i).key && d.result === like(i).result)) return { why: 'pingpong', n: KEPT + 1 }
113 }
114 return undefined
115}
116
117// Who a loop is to the person: Claude, or the subagent by the task it was given.
118async function whoIs($: EngineInterface, id: string) {
119 const w = await wordsIn($)
120 if (id === MAIN) return w.main
121 const agent = (await $.agent.list().catch(() => [])).find(a => a.id === id)
122 return agent?.description ? `«${agent.description}»` : id
123}
124
125const minutesOf = (ms: number) => Math.floor(ms / 60_000)
126
127// Every tool call of every loop, before all else that runs on it: a refusal, or what its end needs.
128export async function guardBefore($: EngineInterface, e: CallEvent): Promise<{ deny: string } | Held | undefined> {
129 if (!setting.isOn) return undefined
130 const id = e.agentId ?? MAIN
131 const now = await $.clock.now()
132 let loop = loops.get(id)
133 if (!loop) {
134 loop = { at: now, steps: 0, outside: 0, done: [], level: 'under' }
135 loops.set(id, loop)
136 }
137 const w = await wordsIn($)
138 const key = keyOf(e)
139 const stuck = stuckOf(loop.done, key)
140 if (stuck) return { deny: w.stuck(stuck.why, stuck.n) }
141 loop.steps++
142 const worked = now - loop.at - loop.outside
143 if (loop.level === 'hard' || minutesOf(worked) >= setting.minutes || loop.steps > setting.steps) {
144 if (loop.level !== 'hard') {
145 loop.level = 'hard'
146 await alert($, id, worked, loop.steps, true)
147 }
148 return { deny: w.hard(minutesOf(worked), loop.steps) }
149 }
150 return { id, key, at: now, loop }
151}
152
153// The call ran: its time and result are the loop's, and past the soft limit it carries the note.
154type Held = { id: string; key: string; at: number; loop: Loop; deny?: undefined }
155type Ran = Awaited<ReturnType<CallNext>>
156
157export async function guardAfter($: EngineInterface, e: CallEvent, held: Held | undefined, ran: Ran): Promise<Ran> {
158 if (!held || loops.get(held.id) !== held.loop) return ran
159 const { id, key, loop } = held
160 const after = await $.clock.now()
161 if (ASKING.has(e.tool) || DELEGATING.has(e.tool) || (e.tool_use_id && asked.delete(e.tool_use_id))) loop.outside += after - held.at
162 if (ran.deny !== undefined) return ran
163 loop.done = [...loop.done, { key, result: hashOf(ran.text ?? JSON.stringify(ran.result ?? null)), isError: ran.isError === true }].slice(-KEPT)
164 const worked = after - loop.at - loop.outside
165 if (loop.level === 'under' && (minutesOf(worked) >= Math.round(setting.minutes / 2) || loop.steps >= Math.round(setting.steps / 2))) {
166 loop.level = 'soft'
167 await alert($, id, worked, loop.steps, false)
168 const w = await wordsIn($)
169 return { ...ran, context: [...(ran.context ?? []), w.soft(minutesOf(worked), loop.steps, setting.minutes, setting.steps)] }
170 }
171 // Calls run side by side: another may have passed the hard limit meanwhile.
172 if (loop.level !== 'under') await alert($, id, worked, loop.steps, (loop.level as Loop['level']) === 'hard', true)
173 return ran
174}
175
176// The row above the prompt shows the loop from the soft limit on; the person is told as it passes
177// each limit, and by a notification once it is stopped, as they may be away.
178async function alert($: EngineInterface, id: string, worked: number, steps: number, isStopped: boolean, isQuiet = false) {
179 const w = await wordsIn($)
180 const was = (await read($, alertsAtom))[id]
181 const who = was?.who ?? (await whoIs($, id))
182 const minutes = minutesOf(worked)
183 const shown: Alert = { who, minutes, steps, ...(isStopped ? { isStopped: true as const } : {}) }
184 await update($, alertsAtom, all => ({ ...all, [id]: shown }))
185 if (isQuiet) return
186 if (isStopped) {
187 await $.ui.toast(w.hardToast(who, minutes, steps))
188 await $.ui.notify(w.hardToast(who, minutes, steps), { title: w.hardTitle }).catch(() => {})
189 } else await $.ui.toast(w.softToast(who, minutes, steps))
190}
191
192// The row's text for each loop past its soft limit, the stopped ones first.
193export async function guardLines($: EngineInterface) {
194 const w = await wordsIn($)
195 const alerts = Object.values(await read($, alertsAtom))
196 return alerts
197 .sort((a, b) => Number(b.isStopped ?? false) - Number(a.isStopped ?? false))
198 .map(a => ({ text: a.isStopped ? w.stopped(a.who, a.minutes, a.steps) : w.running(a.who, a.minutes, a.steps), isStopped: a.isStopped === true }))
199}
200
201// `/changes guard`, `on`, `off`, or `<minutes> <steps>`; undefined for any other verb.
202export async function guardCommand($: EngineInterface, args: string) {
203 const [verb = '', ...rest] = args.trim().split(/\s+/)
204 if (verb.toLowerCase() !== 'guard') return undefined
205 const w = await wordsIn($)
206 const arg = rest.join(' ').toLowerCase()
207 const numbers = arg.match(/^(\d+)\s+(\d+)$/)
208 if (arg === 'on' || arg === 'off') setting = { ...setting, isOn: arg === 'on' }
209 else if (numbers) setting = { isOn: true, minutes: positive(numbers[1], setting.minutes), steps: positive(numbers[2], setting.steps) }
210 else if (arg !== '') return { text: w.usage }
211 if (arg !== '') await $.store.set(SETTING_KEY, setting)
212 const now = await $.clock.now()
213 const running = await Promise.all(
214 [...loops].map(async ([id, loop]) => w.running(await whoIs($, id), minutesOf(now - loop.at - loop.outside), loop.steps)),
215 )
216 return { text: `${w.status(setting.isOn, setting.minutes, setting.steps)}${setting.isOn ? w.loops(running) : ''}` }
217}
218hooks/review/review.tsx 1693 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, MatchedHook, ModelEffort, ModelUsage, PluginOptions, ToolCallResult } from 'claude-code'
3
4import type { Busy, CaughtRule, Diff, FileDiff, Finding, Hunk, Line, Live, Mode, ShellRun, Spent, Tab } from '../../types/review'
5import type { AbovePromptEvent, PaneHook } from '../core/hooks'
6import { langIn } from '../core/lang'
7import { specInView } from '../spec/lens'
8import { below, canonical, capped, changedSpan, diffText, fileDiff, fileText, filesText, fingerprintOf, hash, hunkPatch, joinPath, keptFindings, nameOf, parentOf, parseFindings, parseNotes, parseStatus, parseUnified, samePath, shellText, whereIn, withWhere } from './diff'
9import type { Change, Violation } from './rules'
10import { caughtOf, describeEdit, describeWrite, isRuleFile, LIVE_SYSTEM, lineNear, noteFor, parseViolations, refusalFor, RULE_NAMES } from './rules'
11import { WORDS } from './words'
12
13const beforeAtom = atom({ plugin: 'agent-lens', key: 'review.before' } as const, {} as Record<string, string | null>)
14const turnBeforeAtom = atom({ plugin: 'agent-lens', key: 'review.turnBefore' } as const, {} as Record<string, string | null>)
15const requestsAtom = atom({ plugin: 'agent-lens', key: 'review.requests' } as const, [] as string[])
16const turnRequestAtom = atom({ plugin: 'agent-lens', key: 'review.turnRequest' } as const, '')
17const turnAtom = atom({ plugin: 'agent-lens', key: 'review.turn' } as const, null)
18const shownAtom = atom({ plugin: 'agent-lens', key: 'review.shown' } as const, null)
19const modeAtom = atom({ plugin: 'agent-lens', key: 'review.mode' } as const, 'turn' as Mode)
20const tabAtom = atom({ plugin: 'agent-lens', key: 'review.tab' } as const, 'changes' as Tab)
21const fileAtom = atom({ plugin: 'agent-lens', key: 'review.file' } as const, null)
22const reviewsAtom = atom({ plugin: 'agent-lens', key: 'review.reviews' } as const, {} as Record<string, Finding[]>)
23const notesAtom = atom({ plugin: 'agent-lens', key: 'review.notes' } as const, {} as Record<string, string>)
24const walkthroughsAtom = atom({ plugin: 'agent-lens', key: 'review.walkthroughs' } as const, {} as Record<string, string>)
25const answerAtom = atom({ plugin: 'agent-lens', key: 'review.answer' } as const, null)
26const askAboutAtom = atom({ plugin: 'agent-lens', key: 'review.askAbout' } as const, null)
27const busyAtom = atom({ plugin: 'agent-lens', key: 'review.busy' } as const, [] as Busy[])
28const errorAtom = atom({ plugin: 'agent-lens', key: 'review.error' } as const, null)
29const failedAtom = atom({ plugin: 'agent-lens', key: 'review.failed' } as const, {} as Record<string, string>)
30const seenAtom = atom({ plugin: 'agent-lens', key: 'review.seen' } as const, [] as string[])
31const seenLinesAtom = atom({ plugin: 'agent-lens', key: 'review.seenLines' } as const, [] as string[])
32const reviewedAtom = atom({ plugin: 'agent-lens', key: 'review.reviewed' } as const, {} as Record<string, { at: number; files: string[] }>)
33const sentAtom = atom({ plugin: 'agent-lens', key: 'review.sent' } as const, [] as string[])
34const dismissedAtom = atom({ plugin: 'agent-lens', key: 'review.dismissed' } as const, [] as string[])
35const expandedAtom = atom({ plugin: 'agent-lens', key: 'review.expanded' } as const, {} as Record<string, number>)
36const spentAtom = atom({ plugin: 'agent-lens', key: 'review.spent' } as const, { calls: 0, input: 0, cached: 0, output: 0 } as Spent)
37// Every file the session has changed as of the last turn with edits: its path and its shown path.
38const changedAtom = atom({ plugin: 'agent-lens', key: 'review.changed' } as const, [] as string[])
39const turnDirtyAtom = atom({ plugin: 'agent-lens', key: 'review.turnDirty' } as const, null)
40const turnShellAtom = atom({ plugin: 'agent-lens', key: 'review.turnShell' } as const, [] as ShellRun[])
41const caughtAtom = atom({ plugin: 'agent-lens', key: 'review.caught' } as const, [] as CaughtRule[])
42
43export const PANE = 'agent-lens-review'
44const TABS: readonly Tab[] = ['changes', 'findings', 'walkthrough', 'ask']
45const MODES: readonly Mode[] = ['turn', 'session', 'uncommitted']
46// Reading code wants care more than speed: the session's mid-sized model.
47const MODEL = 'sonnet'
48const TIMEOUT_MS = 180_000
49// Each edit is checked as it is made by a small model, quick and cheap: a call per edit.
50const LIVE_TIMEOUT_MS = 30_000
51// A turn's end waits this long for the checks of its edits still running.
52const STOP_WAIT_MS = 8_000
53// What was caught is kept, newest first, up to this many.
54const CAUGHT_LIMIT = 30
55// The findings put aside or sent to Claude a review is told of, the latest.
56const SETTLED_KEPT = 20
57// The main loop's key among the loops whose notes wait for Claude; a subagent's is its id.
58const MAIN = ''
59// A rule file is read up to this, and all of them together up to the second.
60const RULE_FILE_LIMIT = 16_000
61const RULES_LIMIT = 48_000
62// A changed file is sent whole up to this, else the lines around its hunks; all of them up to the
63// characters after it. Each call pays for them, the cache aside: a few thousand tokens a file.
64const FILE_LIMIT = { lines: 300, chars: 12_000 }
65const FILES_LIMIT = 32_000
66// A turn that touched no more lines than this, added or removed, is not reviewed by itself: the row
67// says so, and `r` on Findings reviews it.
68const SMALL_LINES = 2
69// A hunk shows this many lines before it says how many more it has, and this many more on each
70// press of `show more`.
71const HUNK_LINES = 40
72const HUNK_STEP = 200
73// Hunks a call on the Changes tab says what each is for, at most: their lines fit its answer.
74const NOTES_PER_CALL = 40
75// How each kind of finding is drawn: a likely bug red, a broken rule yellow, a note blue.
76const SEVERITY = {
77 bug: { icon: '✗', color: 'error' },
78 rule: { icon: '!', color: 'warning' },
79 note: { icon: '·', color: 'suggestion' },
80} as const
81
82// The line numbers before each line of code: old and new, four wide each.
83const GUTTER = 10
84// Untracked files read in the uncommitted diff, at most.
85const UNTRACKED_LIMIT = 40
86// Uncommitted files taken before a shell command, and found changed by it, at most, per repository.
87const SHELL_LIMIT = 100
88// An edit refused this many times in a turn for one rule goes through on the next try, in `block`:
89// a check that is wrong does not hold Claude up for good. Claude is not told so, and the count
90// starts over each turn and once an edit to the file passes clean.
91const REFUSALS = 2
92// The changed lines of hunks looked at kept, at most.
93const SEEN_LINES = 20_000
94// What of them is taken before the shell, at most, in characters: a file, and all of them.
95const SHELL_TAKE = { file: 200_000, total: 1_000_000 }
96// What a review reads beside the diff, so it does not call a change unasked for or a step undone:
97// the session's earlier prompts, the turn's shell commands with the end of their output, and the
98// files of each repository changed. Each up to this many, cut to these lengths.
99const EARLIER = { count: 12, chars: 1_500 }
100const SHELL_RUNS = { count: 40, command: 500, output: 400 }
101const REPO_FILES_LIMIT = 300
102
103// The repository each folder is in, by `git rev-parse`, each path in its one spelling (`canonical`);
104// null outside any.
105const repos = new Map<string, string | null>()
106// So many of the project's top folders looked in for a repository of their own, at most.
107const NESTED_LIMIT = 40
108// A review asked for while one runs: made once that one is done.
109let reviewNext: Diff | undefined
110// The model's context for the last few diffs, by `blocksOf`'s key.
111const contexts = new Map<string, Promise<{ rules: string; context: string }>>()
112const CONTEXTS_KEPT = 4
113// What was uncommitted being taken before the turn's first shell command: the commands run side by
114// side wait on the one taking, so none is counted before it.
115let shellTaking: Promise<void> | undefined
116// The plugin's settings, as `/config` holds them.
117let settings = settingsOf({})
118// What the check of each edit found for Claude, by loop, until a tool result or the turn's end
119// carries it; the checks running, by loop; the loops a classic Stop hook already sent back this
120// turn; and whether the main loop's last turn was sent back, so the next is let go.
121const notes = new Map<string, Note[]>()
122const pending = new Map<string, Set<Promise<void>>>()
123const stopped = new Set<string>()
124let sentBack = false
125// A note for Claude and the caught rules it tells of, marked told once it reaches Claude.
126type Note = { text: string; ids: string[] }
127
128// How often an edit to a file was refused for a rule, by file and rule; how many checks gave no
129// answer, and whether the turn was told of one yet.
130const refusals = new Map<string, number>()
131let checksFailed = 0
132let isFailureTold = false
133
134// Counted up by each /clear or /resume: a check still running from the conversation before finds
135// its note has no one to go to.
136let conversation = 0
137
138function settingsOf(options: PluginOptions) {
139 const live: Live = options.live === 'block' || options.live === 'off' ? options.live : 'warn'
140 const model = typeof options.liveModel === 'string' && options.liveModel.trim() !== '' ? options.liveModel.trim() : 'haiku'
141 return { live, liveModel: model, includeUserRules: options.includeUserRules === true }
142}
143
144async function wordsIn($: EngineInterface) {
145 return WORDS[await langIn($)]
146}
147const whyNot = (r: { reason: string; status?: number | null }) => (r.status ? `${r.reason} ${r.status}` : r.reason)
148
149// A file's text; null where it is not there, undefined where it is and cannot be read (past what
150// one read copies), which is no deletion and no text to compare.
151async function readNow($: EngineInterface, path: string): Promise<string | null | undefined> {
152 if (!(await $.fs.exists(path).catch(() => false))) return null
153 return ((await $.fs.read(path).catch(() => undefined)) as string | undefined) ?? undefined
154}
155
156async function readOrNull($: EngineInterface, path: string) {
157 return (await readNow($, path)) ?? null
158}
159
160const shownFrom = (root: string) => (path: string) => below(path, root) ?? path
161
162// Git prints a path with a byte past ASCII in it quoted and escaped, but for this.
163const GIT = ['git', '-c', 'core.quotePath=false']
164
165// The repository `dir` is in. Git on Windows says `D:/proj` where the engine says `D:\proj`: the
166// two spelled one way, so a file is one key in `before` however it was found.
167async function repoAt($: EngineInterface, dir: string) {
168 const key = canonical(dir)
169 if (!repos.has(key)) {
170 const r = await $.process.run([...GIT, '-C', key, 'rev-parse', '--show-toplevel'], { timeoutMs: 10_000 }).catch(() => undefined)
171 repos.set(key, r && r.exitCode === 0 && r.stdout.trim() !== '' ? canonical(r.stdout.trim()) : null)
172 }
173 return repos.get(key) ?? null
174}
175
176function repoOf($: EngineInterface, path: string) {
177 return repoAt($, parentOf(path))
178}
179
180// The project's own repository first, then the others by name; files by their path in each.
181const byGroup = (a: FileDiff, b: FileDiff) => (a.group === b.group ? a.rel.localeCompare(b.rel) : a.group === '' ? -1 : b.group === '' ? 1 : a.group.localeCompare(b.group))
182
183// What changed since `before` was taken: each file as it was against what is on disk now.
184async function snapshotDiff($: EngineInterface, before: Readonly<Record<string, string | null>>) {
185 const root = await $.session.root()
186 const files: FileDiff[] = []
187 for (const [path, was] of Object.entries(before)) {
188 const now = await readNow($, path)
189 if (now === undefined) continue
190 const f = fileDiff(path, whereIn(path, root, await repoOf($, path)), was, now)
191 if (f.hunks.length > 0) files.push(f)
192 }
193 return capped(files.sort(byGroup))
194}
195
196function git($: EngineInterface, repo: string, args: string[]) {
197 return $.process.run([...GIT, '-C', repo, ...args], { timeoutMs: 30_000 }).catch(() => undefined)
198}
199
200// Every repository the session edited a file in, and the project's own.
201async function sessionRepos($: EngineInterface) {
202 const found = new Set<string>()
203 const own = await repoAt($, await $.session.root())
204 if (own) found.add(own)
205 for (const path of Object.keys(await read($, beforeAtom))) {
206 const repo = await repoOf($, path)
207 if (repo) found.add(repo)
208 }
209 return [...found]
210}
211
212// Every repository of its own in a top folder of the project, as dev-env keeps its clones
213// (`__front`, ignored by its own repository): the shell may edit one no tool did. Looked for again
214// each turn, so a clone made since is found.
215async function nestedRepos($: EngineInterface, root: string) {
216 const dirs = (await $.fs.list(root).catch(() => [])).filter(x => x.kind === 'dir' && !x.name.startsWith('.')).slice(0, NESTED_LIMIT)
217 const found = await Promise.all(dirs.map(async d => ((await $.fs.exists(joinPath(root, `${d.name}/.git`)).catch(() => false)) ? joinPath(root, d.name) : null)))
218 return found.filter((d): d is string => d !== null).map(canonical)
219}
220
221// What is uncommitted in every repository the session edited a file in, and in the project's own:
222// `git diff HEAD`, and each untracked file whole.
223async function uncommittedDiff($: EngineInterface) {
224 const root = await $.session.root()
225 const files: FileDiff[] = []
226 for (const repo of await sessionRepos($)) {
227 const run = (args: string[]) => git($, repo, args)
228 // A repository with no commit yet has no HEAD to compare with: the index stands in for it.
229 const head = await run(['diff', 'HEAD', '--no-color', '--no-ext-diff', '-U3'])
230 const tracked = head?.exitCode === 0 ? head : await run(['diff', '--no-color', '--no-ext-diff', '-U3'])
231 if (tracked && tracked.exitCode === 0) files.push(...parseUnified(tracked.stdout, repo, p => whereIn(p, root, repo)))
232 const untracked = await run(['ls-files', '--others', '--exclude-standard'])
233 for (const rel of (untracked?.stdout ?? '').split(/\r?\n/).filter(Boolean).slice(0, UNTRACKED_LIMIT)) {
234 const path = joinPath(repo, rel)
235 const now = await readNow($, path)
236 if (now === undefined) continue
237 const f = fileDiff(path, whereIn(path, root, repo), null, now)
238 if (f.hunks.length > 0) files.push(f)
239 }
240 }
241 return capped(files.sort(byGroup))
242}
243
244async function diffFor($: EngineInterface, mode: Mode): Promise<Diff | null> {
245 if (mode === 'turn') {
246 const turn = await read($, turnAtom)
247 return turn && { ...turn, files: withWhere(turn.files, await $.session.root()) }
248 }
249 const files = mode === 'session' ? await snapshotDiff($, await read($, beforeAtom)) : await uncommittedDiff($)
250 return { mode, files, request: (await read($, requestsAtom)).join('\n\n'), fingerprint: fingerprintOf(files), at: await $.clock.now() }
251}
252
253// The pane follows the mode picked; the file picked stays while the diff still has it.
254async function refreshShown($: EngineInterface) {
255 const shown = await diffFor($, await read($, modeAtom))
256 await carrySeen($, shown)
257 await update($, shownAtom, () => shown)
258 const picked = await read($, fileAtom)
259 if (!shown?.files.some(f => f.path === picked)) await update($, fileAtom, () => shown?.files[0]?.path ?? null)
260}
261
262async function withBusy($: EngineInterface, kind: Busy, work: () => Promise<void>) {
263 if ((await read($, busyAtom)).includes(kind)) return
264 await update($, busyAtom, b => [...b, kind])
265 await update($, errorAtom, () => null)
266 try {
267 await work()
268 } finally {
269 await update($, busyAtom, b => b.filter(k => k !== kind))
270 }
271}
272
273// The personal CLAUDE.md, where Claude Code keeps it.
274async function userRulesPath($: EngineInterface) {
275 const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
276 if (config) return joinPath(config, 'CLAUDE.md')
277 const home = (await $.env.get('HOME').catch(() => undefined)) || (await $.env.get('USERPROFILE').catch(() => undefined))
278 return home ? joinPath(home, '.claude/CLAUDE.md') : undefined
279}
280
281// The rules in force for `paths`, nearest first, each once, as the engine reads them, its
282// `@include`s after each: the CLAUDE.md, AGENTS.md and CODING_STANDARDS.md of every folder from each file up to the
283// project folder (a file outside it up to its own repository's root, outside any its own folder
284// alone), then those the engine read at the start, from the project folder up. The personal
285// CLAUDE.md only where the settings ask for it.
286async function ruleFilesFor($: EngineInterface, paths: readonly string[]) {
287 const root = await $.session.root()
288 const found: { path: string; text: string }[] = []
289 const take = (walked: Awaited<ReturnType<EngineInterface['fs']['ancestors']>>) => {
290 // Root first, so turned round, each folder's names in their order.
291 const dirs = [...new Set(walked.map(a => canonical(a.dir)))].reverse()
292 for (const dir of dirs)
293 for (const rules of walked.filter(a => canonical(a.dir) === dir)) {
294 const path = canonical(rules.parts[0]?.path ?? joinPath(rules.dir, rules.name))
295 if (!found.some(f => f.path === path)) found.push({ path, text: rules.content })
296 }
297 }
298 for (const path of paths) {
299 const top = below(path, root) !== undefined ? root : ((await repoOf($, path)) ?? parentOf(path))
300 // The walk reads only the folders strictly inside `below`: from the one above the top, the
301 // top's own are read too.
302 take(await $.fs.ancestors({ names: RULE_NAMES, of: path, below: parentOf(top) }).catch(() => []))
303 }
304 take(await $.fs.ancestors({ names: RULE_NAMES }).catch(() => []))
305 const user = await userRulesPath($)
306 if (user && settings.includeUserRules && !found.some(f => samePath(f.path, user))) {
307 const text = await readOrNull($, user)
308 if (text !== null) found.push({ path: canonical(user), text })
309 }
310 return found.filter(f => f.text.trim() !== '' && (settings.includeUserRules || !user || !samePath(f.path, user)))
311}
312
313const rulesText = (found: readonly { path: string; text: string }[], shown: (path: string) => string) => {
314 let text = ''
315 for (const rules of found) {
316 if (text.length > RULES_LIMIT) break
317 text += `=== ${shown(rules.path)} ===\n${rules.text.replace(/\r\n/g, '\n').slice(0, RULE_FILE_LIMIT)}\n\n`
318 }
319 return text
320}
321
322async function rulesFor($: EngineInterface, files: readonly FileDiff[]) {
323 const w = await wordsIn($)
324 const text = rulesText(await ruleFilesFor($, files.map(f => f.path)), shownFrom(await $.session.root()))
325 return text === '' ? w.noRules : `${w.rulesHeading}\n\n${text}`
326}
327
328// The OpenSpec change's own Markdown: /spec reads it, so the review of a turn leaves it out, and a
329// turn that edited it alone is not reviewed.
330const isSpecPath = (path: string) => /(?:^|[\\/])openspec[\\/].+\.md$/i.test(path)
331const isSpecDoc = (f: FileDiff) => isSpecPath(f.path)
332const codeOf = (diff: Diff): Diff => ({ ...diff, files: diff.files.filter(f => !isSpecDoc(f)) })
333const isSpecOnly = (diff: Diff) => diff.files.length > 0 && diff.files.every(isSpecDoc)
334
335const isSmall = (diff: Diff) =>
336 Math.max(
337 diff.files.reduce((n, f) => n + f.added, 0),
338 diff.files.reduce((n, f) => n + f.removed, 0),
339 ) <= SMALL_LINES
340
341// Each changed file as it is on disk now, its lines numbered; a deleted one, or one edited since
342// the diff was taken, goes without.
343async function filesFor($: EngineInterface, files: readonly FileDiff[]) {
344 const texts: Record<string, string> = {}
345 for (const f of files) {
346 const now = f.isDeleted ? null : await readOrNull($, f.path)
347 const text = now === null ? undefined : fileText(f, now, FILE_LIMIT)
348 if (text !== undefined) texts[f.path] = text
349 }
350 return filesText(files, texts, FILES_LIMIT)
351}
352
353// The files of each repository the diff changed a file in, tracked or new and not ignored: what
354// a path in the diff is checked against.
355async function repoFilesFor($: EngineInterface, files: readonly FileDiff[]) {
356 const w = await wordsIn($)
357 const root = await $.session.root()
358 const shown = (repo: string) => (samePath(repo, root) ? '' : shownFrom(root)(repo))
359 const repos = new Set<string>()
360 for (const f of files) {
361 const repo = await repoOf($, f.path)
362 if (repo) repos.add(repo)
363 }
364 let text = ''
365 for (const repo of repos) {
366 const r = await git($, repo, ['ls-files', '--cached', '--others', '--exclude-standard'])
367 if (r?.exitCode !== 0) continue
368 const all = [...new Set(r.stdout.split(/\r?\n/).filter(Boolean))]
369 if (all.length === 0) continue
370 text += `${w.repoFilesHeading(shown(repo), all.length - REPO_FILES_LIMIT)}\n${all.slice(0, REPO_FILES_LIMIT).join('\n')}\n\n`
371 }
372 return text
373}
374
375// What every question about a diff reads: the requests, the commands run, the files changed and
376// those beside them, the diff.
377async function contextFor($: EngineInterface, diff: Diff) {
378 const w = await wordsIn($)
379 const files = await filesFor($, diff.files)
380 const earlier = diff.earlier?.length ? `${w.earlierHeading}\n\n${diff.earlier.map(r => `- ${r}`).join('\n')}\n\n` : ''
381 const shell = diff.shell?.length ? `${w.shellHeading}\n\n${shellText(diff.shell)}\n\n` : ''
382 // The OpenSpec change in view, where the project has one: what the diff may be working on.
383 const spec = await specInView($).catch(() => undefined)
384 const inView = spec ? `${w.specHeading(spec)}\n\n` : ''
385 return `${inView}${earlier}${w.requestHeading(diff.request)}\n\n${shell}${files ? `${w.filesHeading}\n\n${files}` : ''}${await repoFilesFor($, diff.files)}${w.diffHeading}\n\n${diffText(diff.files)}`
386}
387
388async function spend($: EngineInterface, u: ModelUsage) {
389 await update($, spentAtom, s => ({
390 calls: s.calls + 1,
391 input: s.input + u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
392 cached: s.cached + u.cache_read_input_tokens,
393 output: s.output + u.output_tokens,
394 }))
395}
396
397// The rules and the context of a diff, built once per version of it and language: the files on
398// disk and the git listing are read once, and every call on the diff sends the same text, which the
399// prompt cache then holds.
400function blocksOf($: EngineInterface, diff: Diff, lang: string) {
401 const key = `${lang}:${diff.mode}:${diff.fingerprint}:${diff.files.length}:${diff.request.length}`
402 let held = contexts.get(key)
403 if (!held) {
404 held = (async () => ({ rules: await rulesFor($, diff.files), context: await contextFor($, diff) }))()
405 contexts.set(key, held)
406 held.catch(() => contexts.delete(key))
407 if (contexts.size > CONTEXTS_KEPT) contexts.delete(contexts.keys().next().value!)
408 }
409 return held
410}
411
412// The rules, then the request, the files and the diff, then what is asked: the first two marked,
413// so the review, the explanations, the walkthrough and the answers on one diff read them from the
414// cache and only the ask is new.
415async function complete($: EngineInterface, diff: Diff, ask: string, maxTokens: number, effort: ModelEffort) {
416 const w = await wordsIn($)
417 const { rules, context } = await blocksOf($, diff, await langIn($))
418 const r = await $.model.complete({
419 model: MODEL,
420 system: w.system,
421 prompt: [{ text: rules, cache: true }, { text: context, cache: true }, { text: ask }],
422 maxTokens,
423 effort,
424 timeoutMs: TIMEOUT_MS,
425 })
426 await spend($, r.usage)
427 if (!r.isAnswered) await update($, errorAtom, () => w.failed(whyNot(r)))
428 return r.isAnswered ? r.text : undefined
429}
430
431// `whole`: the diff as shown; what is reviewed of it is its code, the change's Markdown aside, unless
432// that is all it holds. Kept by the fingerprint of the whole.
433async function reviewDiff($: EngineInterface, whole: Diff) {
434 const diff = isSpecOnly(whole) ? whole : codeOf(whole)
435 if (diff.files.length === 0 || (await read($, reviewsAtom))[diff.fingerprint]) return
436 if ((await read($, busyAtom)).includes('review')) {
437 reviewNext = whole
438 return
439 }
440 await withBusy($, 'review', async () => {
441 const w = await wordsIn($)
442 // The row says a review that gave nothing to show, red, until one does.
443 const failed = async (why: string) => {
444 await update($, errorAtom, () => why)
445 await update($, failedAtom, all => ({ ...all, [diff.fingerprint]: why }))
446 }
447 await update($, failedAtom, ({ [diff.fingerprint]: _was, ...rest }) => rest)
448 // What the check of each edit caught and the person sees already is not found again.
449 const names = diff.files.flatMap(f => [f.path, f.shown])
450 const flagged = (await openCaught($)).filter(c => isOn(c, names)).map(c => ({ where: findingAt(c), rule: c.rule ?? c.text }))
451 const isSpec = (await specInView($).catch(() => undefined)) !== undefined
452 const settled = await settledOn($, names)
453 const ask = [
454 w.reviewPrompt,
455 ...(isSpec ? [w.specPrompt] : []),
456 ...(flagged.length > 0 ? [w.flaggedPrompt(flagged)] : []),
457 ...(settled.length > 0 ? [w.settledPrompt(settled)] : []),
458 ].join('\n\n')
459 // Room for the few sentences it may reason in before the JSON.
460 const text = await complete($, diff, ask, 8000, 'high')
461 if (text === undefined) {
462 await failed((await read($, errorAtom)) ?? w.failed('no reply'))
463 return
464 }
465 const findings = parseFindings(text)
466 if (findings === undefined) {
467 await failed(w.failed('the reply was not JSON'))
468 return
469 }
470 // A second, doubting look at what the first found, over the same cached text: it drops a
471 // finding that an earlier request, a command run or a file listed shows to be no problem.
472 const checked = findings.length === 0 ? undefined : await complete($, diff, w.verifyPrompt(findings.map(f => ({ where: findingAt(f), text: f.text }))), 4000, 'medium')
473 const kept = await withLineText($, diff, (checked === undefined ? undefined : keptFindings(checked, findings)) ?? findings)
474 await update($, reviewsAtom, all => ({ ...all, [diff.fingerprint]: kept }))
475 const at = await $.clock.now()
476 const changed = [...new Set(diff.files.flatMap(f => f.hunks.flatMap(h => h.lines.filter(l => l.kind === '-').map(l => lineKey(l.text)))))]
477 await update($, reviewedAtom, all => ({ ...all, [diff.fingerprint]: { at, files: diff.files.flatMap(f => [f.path, f.shown]), changed } }))
478 })
479 const next = reviewNext
480 reviewNext = undefined
481 if (next) await reviewDiff($, next)
482}
483
484// A line as a later diff is matched against it: its text, spaces at the ends aside, hashed.
485const lineKey = (text: string) => hash(text.trim())
486
487// Each finding on a line, with the text of that line as the file holds it now.
488async function withLineText($: EngineInterface, diff: Diff, findings: readonly Finding[]) {
489 const texts = new Map<string, string[] | null>()
490 const out: Finding[] = []
491 for (const f of findings) {
492 const file = diff.files.find(x => isOn(f, [x.path, x.shown]))
493 if (f.line === undefined || !file) {
494 out.push(f)
495 continue
496 }
497 if (!texts.has(file.path)) texts.set(file.path, (await readOrNull($, file.path))?.split('\n') ?? null)
498 const text = texts.get(file.path)?.[f.line - 1]
499 out.push(text !== undefined && text.trim() !== '' ? { ...f, lineText: text } : f)
500 }
501 return out
502}
503
504async function explainHunks($: EngineInterface) {
505 const diff = await read($, shownAtom)
506 const notes = await read($, notesAtom)
507 const ids = (diff?.files ?? []).flatMap(f => f.hunks.map(hunk => hunk.id))
508 const missing = ids.filter(id => notes[id] === undefined)
509 if (!diff || missing.length === 0) return
510 await withBusy($, 'notes', async () => {
511 const w = await wordsIn($)
512 // The whole diff is sent, as to the review, so it is read from the cache; the ask names the
513 // hunks still unsaid, so many at a time as one answer holds.
514 for (let at = 0; at < missing.length; at += NOTES_PER_CALL) {
515 const part = missing.slice(at, at + NOTES_PER_CALL)
516 const text = await complete($, diff, w.notesPrompt(part.length < ids.length ? part : null), 3000, 'low')
517 if (text === undefined) return
518 // A hunk the model said nothing of is left without a note, not asked again on every visit.
519 const said = parseNotes(text, part)
520 await update($, notesAtom, all => ({ ...all, ...Object.fromEntries(part.map(id => [id, ''])), ...said }))
521 }
522 })
523}
524
525async function walkThrough($: EngineInterface, isAgain = false) {
526 const diff = await read($, shownAtom)
527 if (!diff || diff.files.length === 0 || (!isAgain && (await read($, walkthroughsAtom))[diff.fingerprint])) return
528 await withBusy($, 'walkthrough', async () => {
529 const text = await complete($, diff, (await wordsIn($)).walkthroughPrompt, 3000, 'medium')
530 if (text !== undefined) await update($, walkthroughsAtom, all => ({ ...all, [diff.fingerprint]: text }))
531 })
532}
533
534const hunkOf = (diff: Diff | null, id: string | null) => {
535 for (const f of diff?.files ?? []) for (const hunk of f.hunks) if (hunk.id === id) return { f, hunk }
536 return undefined
537}
538
539// A run of removed lines followed by as many added ones reads as each line rewritten: each pair is
540// split around what changed, so the change itself can be lit. By the line's index in `lines`.
541const pairedSpans = (lines: readonly Line[]) => {
542 const spans: (ReturnType<typeof changedSpan> | undefined)[] = []
543 for (let k = 0; k < lines.length; ) {
544 let removed = 0
545 while (lines[k + removed]?.kind === '-') removed++
546 let added = 0
547 while (lines[k + removed + added]?.kind === '+') added++
548 if (removed > 0 && removed === added)
549 for (let j = 0; j < removed; j++) spans[k + j] = spans[k + removed + j] = changedSpan(lines[k + j]!.text, lines[k + removed + j]!.text)
550 k += Math.max(1, removed + added)
551 }
552 return spans
553}
554
555const whereOf = (f: FileDiff, hunk: Hunk) => `${f.shown}:${hunk.newStart}`
556
557async function ask($: EngineInterface, question: string) {
558 const q = question.trim()
559 const diff = await read($, shownAtom)
560 if (!diff || q === '') return
561 const about = hunkOf(diff, await read($, askAboutAtom))
562 await update($, answerAtom, () => null)
563 await withBusy($, 'answer', async () => {
564 const w = await wordsIn($)
565 const text = await complete($, diff, w.askPrompt(q, about ? hunkPatch(about.f, about.hunk) : null), 2000, 'medium')
566 if (text !== undefined) await update($, answerAtom, () => ({ question: q, text }))
567 })
568}
569
570// Sends `text` to the session as the person's prompt and marks `ids` as sent.
571async function sendToClaude($: EngineInterface, ids: readonly string[], text: string) {
572 const w = await wordsIn($)
573 const r = await $.prompt.submit({ text, asUser: true })
574 if (r.drop !== undefined) {
575 const why = r.drop
576 await update($, errorAtom, () => why)
577 return
578 }
579 await update($, sentAtom, s => [...s, ...ids])
580 await $.ui.toast(w.sent)
581}
582
583const findingAt = (f: Finding) => (f.line ? `${f.file}:${f.line}` : f.file)
584
585async function fixFinding($: EngineInterface, f: Finding) {
586 const w = await wordsIn($)
587 await sendToClaude($, [f.id], w.fixPrompt(findingAt(f), f.text, f.rule))
588}
589
590// Every finding still open, in one prompt: one turn for Claude, not one per finding.
591async function fixAll($: EngineInterface, findings: readonly Finding[]) {
592 const w = await wordsIn($)
593 await sendToClaude($, findings.map(f => f.id), w.fixAllPrompt(findings.map(f => ({ where: findingAt(f), text: f.text, rule: f.rule }))))
594}
595
596// A hunk cut short is not taken back: its patch would take back the part kept.
597async function revertHunk($: EngineInterface, f: FileDiff, hunk: Hunk) {
598 if (hunk.cut) return
599 const w = await wordsIn($)
600 await sendToClaude($, [hunk.id], w.revertPrompt(f.shown, hunkPatch(f, hunk)))
601}
602
603// A hunk's changed lines, each hashed with its file.
604const changedKeys = (f: FileDiff, hunk: Hunk) => hunk.lines.filter(l => l.kind !== ' ').map(l => hash(`${f.path}\0${l.kind}${l.text}`))
605
606async function markSeen($: EngineInterface, ids: readonly string[]) {
607 await update($, seenAtom, seen => [...new Set([...seen, ...ids])])
608 const keys = ((await read($, shownAtom))?.files ?? []).flatMap(f => f.hunks.filter(h => ids.includes(h.id)).flatMap(h => changedKeys(f, h)))
609 if (keys.length > 0) await update($, seenLinesAtom, lines => [...new Set([...(lines ?? []), ...keys])].slice(-SEEN_LINES))
610 // The row counts the last turn, whatever mode the pane is in: what was looked at here counts there.
611 if (keys.length > 0 && (await read($, modeAtom)) !== 'turn') await carrySeen($, await read($, turnAtom))
612}
613
614// A diff coming into view takes "looked at" from the hunks of other modes: a hunk every line of
615// which changes as one looked at did is looked at too. A line taken away that the file did not have
616// before the session, an earlier turn wrote: it is in no other mode's diff, so is not asked for.
617// Done as the diff changes, not as it is drawn, so drawing reads no more than it did.
618async function carrySeen($: EngineInterface, diff: Diff | null) {
619 const lines = new Set((await read($, seenLinesAtom)) ?? [])
620 if (!diff || lines.size === 0) return
621 const seen = new Set(await read($, seenAtom))
622 const before = await read($, beforeAtom)
623 const carried = diff.files.flatMap(f => {
624 // The file's lines before the session; a file not kept by then is taken as having them all.
625 const key = keyIn(before, f.path)
626 const was = key in before ? before[key] : undefined
627 const old = typeof was === 'string' ? new Set(was.split(/\r?\n/)) : null
628 const had = (text: string) => was === undefined || (old?.has(text) ?? false)
629 return f.hunks
630 .filter(hunk => {
631 const keys = changedKeys(f, { ...hunk, lines: hunk.lines.filter(l => l.kind !== '-' || had(l.text)) })
632 return !seen.has(hunk.id) && keys.length > 0 && keys.every(k => lines.has(k))
633 })
634 .map(hunk => hunk.id)
635 })
636 if (carried.length > 0) await update($, seenAtom, all => [...new Set([...all, ...carried])])
637}
638
639// Each tab asks the model only when it is looked at, and once per version of the diff.
640async function openTab($: EngineInterface, tab: Tab) {
641 await update($, tabAtom, () => tab)
642 // The ring goes with the tab, so what is lit is what is open; on Ask, into the question.
643 void $.ui.focus({ requestId: PANE, key: tab === 'ask' ? 'question' : `tab-${tab}` }).catch(() => {})
644 if (tab === 'changes') await explainHunks($)
645 else if (tab === 'walkthrough') await walkThrough($)
646}
647
648// From a finding to its code: the file picked on Changes, the ring on the hunk that holds its line.
649async function goToCode($: EngineInterface, f: Finding) {
650 const find = (diff: Diff | null) => diff?.files.find(x => x.shown === f.file || x.rel === f.file)
651 // A finding of an earlier review may be on a file the diff in view does not hold: the session's does.
652 if (!find(await read($, shownAtom)) && (await read($, modeAtom)) !== 'session') await setMode($, 'session')
653 const file = find(await read($, shownAtom))
654 if (!file) return
655 await update($, fileAtom, () => file.path)
656 await openTab($, 'changes')
657 const hunk = file.hunks.find(h => f.line !== undefined && f.line >= h.newStart && f.line < h.newStart + h.lines.filter(l => l.kind !== '-').length) ?? file.hunks[0]
658 if (hunk) void $.ui.focus({ requestId: PANE, key: `ask-${hunk.id}` }).catch(() => {})
659}
660
661async function setMode($: EngineInterface, mode: Mode) {
662 await update($, modeAtom, () => mode)
663 await update($, answerAtom, () => null)
664 await update($, askAboutAtom, () => null)
665 await refreshShown($)
666 await openTab($, await read($, tabAtom))
667}
668
669export async function openPane($: EngineInterface, tab?: Tab) {
670 if (tab) await update($, tabAtom, () => tab)
671 await refreshShown($)
672 const w = await wordsIn($)
673 await $.ui.open({ id: PANE, title: w.title, focus: true, closeOnEscape: true })
674 void openTab($, await read($, tabAtom)).catch(() => {})
675}
676
677// What a turn leaves for its diff: the files as they were, what was uncommitted before its first
678// shell command, the commands, the request.
679type TurnTaken = {
680 before: Readonly<Record<string, string | null>>
681 dirty: Readonly<Record<string, readonly string[]>> | null
682 shell: ShellRun[]
683 request: string
684 requests: string[]
685}
686
687// Taken at the turn's end and emptied in one write each, before anything is awaited: the next turn
688// may start, and edit, while this one's diff is read from the disk.
689async function takeTurn($: EngineInterface): Promise<TurnTaken> {
690 const taken: TurnTaken = { before: {}, dirty: null, shell: [], request: await read($, turnRequestAtom), requests: await read($, requestsAtom) }
691 await update($, turnBeforeAtom, b => {
692 taken.before = b
693 return {}
694 })
695 await update($, turnDirtyAtom, d => {
696 taken.dirty = d
697 return null
698 })
699 await update($, turnShellAtom, s => {
700 taken.shell = s
701 return []
702 })
703 shellTaking = undefined
704 return taken
705}
706
707// After a turn that edited files, what it changed becomes the last turn's diff and is reviewed,
708// unless it touched a line or two.
709async function afterTurn($: EngineInterface, taken: TurnTaken) {
710 const before = await afterShell($, taken.dirty, taken.before)
711 await resolveCaught($)
712 const { shell, request, requests } = taken
713 const at = requests.lastIndexOf(request)
714 const earlier = (at >= 0 ? requests.slice(0, at) : requests).slice(-EARLIER.count).map(r => r.slice(0, EARLIER.chars))
715 if (Object.keys(before).length === 0) return
716 const files = await snapshotDiff($, before)
717 // The session's changed files, worked out again for the files this turn touched alone: the rest
718 // stand as the last turn left them.
719 const root = await $.session.root()
720 const all = await read($, beforeAtom)
721 const touched = Object.keys(before).map(path => keyIn(all, path))
722 const session = await snapshotDiff($, Object.fromEntries(touched.map(path => [path, all[path] ?? null])))
723 const again = new Set<string>()
724 for (const path of touched) again.add(path).add(whereIn(path, root, await repoOf($, path)).shown)
725 await update($, changedAtom, names => [...names.filter(n => !again.has(n)), ...session.flatMap(f => [f.path, f.shown])])
726 if (files.length === 0) return
727 const turn: Diff = { mode: 'turn', files, request, earlier, shell, fingerprint: fingerprintOf(files), at: await $.clock.now() }
728 const isFirst = (await read($, turnAtom)) === null
729 await update($, turnAtom, () => turn)
730 if (isFirst && (await read($, modeAtom)) === 'uncommitted') await update($, modeAtom, () => 'turn')
731 await carrySeen($, turn)
732 if ((await read($, modeAtom)) === 'turn') await update($, shownAtom, () => turn)
733 await refreshShown($)
734 if (!isSmall(codeOf(turn))) await reviewDiff($, turn)
735}
736
737// The key `path` is kept by in `kept`: one there that is the same path spelled otherwise (Windows
738// folds case: the model's `D:\Proj\a.ts` is git's `D:\proj\a.ts`), else its one spelling.
739const keyIn = (kept: Readonly<Record<string, unknown>>, path: string) => Object.keys(kept).find(k => samePath(k, path)) ?? canonical(path)
740
741// A file is kept by one key however the edit tools and git name it.
742// `room`: the most characters it is kept up to, else it is let be; resolves to what it took.
743async function remember($: EngineInterface, named: string, room = Infinity) {
744 const path = keyIn(await read($, beforeAtom), named)
745 if (path in (await read($, turnBeforeAtom)) && path in (await read($, beforeAtom))) return 0
746 const was = await readNow($, path)
747 if (was === undefined || (was?.length ?? 0) > room) return 0
748 // Only the first taking of a file counts: an edit made since is not what it was.
749 await update($, turnBeforeAtom, b => (path in b ? b : { ...b, [path]: was }))
750 await update($, beforeAtom, b => (path in b ? b : { ...b, [path]: was }))
751 return was?.length ?? 0
752}
753
754// Every file uncommitted in `repo`, by path: modified, staged, deleted or untracked.
755async function uncommittedIn($: EngineInterface, repo: string) {
756 const r = await git($, repo, ['status', '--porcelain', '-z', '--untracked-files=all'])
757 return r?.exitCode === 0 ? parseStatus(r.stdout).map(rel => joinPath(repo, rel)) : []
758}
759
760// A shell command edits files no tool call names. Before the turn's first, what is uncommitted is
761// taken as an edit would take it, and named: what is uncommitted after and was not, the shell did.
762async function beforeShell($: EngineInterface) {
763 if ((await read($, turnDirtyAtom)) !== null) return
764 shellTaking ??= (async () => {
765 const dirty: Record<string, string[]> = {}
766 // Each file goes into the session's values whole: so much of them, and none past a size.
767 let room = SHELL_TAKE.total
768 const edited = await sessionRepos($)
769 for (const repo of edited) {
770 dirty[repo] = await uncommittedIn($, repo)
771 for (const path of dirty[repo].slice(0, SHELL_LIMIT)) if (room > 0) room -= await remember($, path, Math.min(room, SHELL_TAKE.file))
772 }
773 // A repository of its own in the project that the session has not edited is only named: a file
774 // uncommitted there after the command and not before, the shell changed, from what is committed.
775 const others = (await nestedRepos($, await $.session.root())).filter(repo => !edited.some(r => samePath(r, repo)))
776 await Promise.all(others.map(async repo => (dirty[repo] = await uncommittedIn($, repo))))
777 await update($, turnDirtyAtom, d => d ?? dirty)
778 })()
779 await shellTaking
780}
781
782// After a turn that ran the shell: each file uncommitted now and not before it, and edited by no
783// tool, the shell changed; it was as committed, or not there. The turn's files with them.
784async function afterShell($: EngineInterface, dirty: TurnTaken['dirty'], turnBefore: TurnTaken['before']) {
785 const edited: Record<string, string | null> = { ...turnBefore }
786 for (const [repo, was] of Object.entries(dirty ?? {})) {
787 const found = (await uncommittedIn($, repo)).filter(path => !was.includes(path) && !(keyIn(edited, path) in edited)).slice(0, SHELL_LIMIT)
788 for (const named of found) {
789 const head = await git($, repo, ['show', `HEAD:${below(named, repo) ?? ''}`])
790 // Cut short, it is not what the file was.
791 if (head?.isStdoutTruncated) continue
792 const before = head?.exitCode === 0 ? head.stdout : null
793 const path = keyIn(await read($, beforeAtom), named)
794 if (!(path in edited)) edited[path] = before
795 await update($, beforeAtom, b => (path in b ? b : { ...b, [path]: before }))
796 }
797 }
798 return edited
799}
800
801// A shell command of the turn, for the review to read: what a push or an install did is in no diff.
802async function noteShell($: EngineInterface, command: string, ran: ToolCallResult<'Bash'>) {
803 const output = ran.deny !== undefined ? ran.deny : ran.isError ? (ran.text ?? '') : [ran.result.stdout, ran.result.stderr].filter(Boolean).join('\n')
804 const run: ShellRun = {
805 command: command.slice(0, SHELL_RUNS.command),
806 output: output.trim().slice(-SHELL_RUNS.output),
807 isError: ran.deny !== undefined || ran.isError === true,
808 }
809 await update($, turnShellAtom, s => [...s, run].slice(-SHELL_RUNS.count))
810}
811
812// One edit checked against the rules in force for its file by the small model; an edit to a rule
813// file, to the OpenSpec change (/spec reads it), or to a file no rule applies to, is let be.
814async function inspect($: EngineInterface, change: Change) {
815 if (isRuleFile(change.file) || isSpecPath(change.file)) return []
816 const rules = rulesText(await ruleFilesFor($, [change.file]), shownFrom(await $.session.root()))
817 if (rules === '') return []
818 const r = await $.model.complete({
819 model: settings.liveModel,
820 system: LIVE_SYSTEM,
821 prompt: [{ text: rules, cache: true }, { text: `\n\n# The change\n\n${change.text}` }],
822 maxTokens: 1024,
823 effort: 'low',
824 timeoutMs: LIVE_TIMEOUT_MS,
825 })
826 await spend($, r.usage)
827 if (!r.isAnswered) await checkFailed($, whyNot(r))
828 return r.isAnswered ? (parseViolations(r.text) ?? []) : []
829}
830
831// A check that gave no answer let its edit through unchecked: counted, and said once a turn.
832async function checkFailed($: EngineInterface, why: string) {
833 checksFailed++
834 if (isFailureTold) return
835 isFailureTold = true
836 await $.ui.toast((await wordsIn($)).checkFailed(why))
837}
838
839// What the check caught joins the findings, a toast says so; caught again, it is told again.
840async function keepCaught($: EngineInterface, change: Change, violations: readonly Violation[], state: CaughtRule['state']) {
841 const path = canonical(change.file)
842 const shown = whereIn(path, await $.session.root(), await repoOf($, path)).shown
843 const now = state === 'refused' ? null : await readOrNull($, path)
844 const caught = violations.map(v => caughtOf(path, shown, v, change, now, state))
845 await update($, caughtAtom, all => [...caught, ...all.filter(c => !caught.some(n => n.id === c.id))].slice(0, CAUGHT_LIMIT))
846 // Put aside or sent to Claude before, and broken again: it is open again, not hidden.
847 const ids = new Set(caught.map(c => c.id))
848 if ((await read($, dismissedAtom)).some(id => ids.has(id))) await update($, dismissedAtom, d => d.filter(id => !ids.has(id)))
849 if ((await read($, sentAtom)).some(id => ids.has(id))) await update($, sentAtom, s => s.filter(id => !ids.has(id)))
850 await $.ui.toast((await wordsIn($)).caughtToast(violations.length, nameOf(path)))
851 return caught.map(c => c.id)
852}
853
854const takeNotes = (loop: string) => {
855 const list = notes.get(loop) ?? []
856 notes.delete(loop)
857 return list
858}
859
860// Notes taken and not delivered after all go back first in their loop, for its next tool result or
861// the end of its turn.
862const putBack = (loop: string, list: readonly Note[]) => {
863 if (list.length > 0) notes.set(loop, [...list, ...(notes.get(loop) ?? [])])
864}
865
866const textOf = (list: readonly Note[]) => list.map(n => n.text)
867
868// The rules a note told of are told once it reaches Claude.
869async function markTold($: EngineInterface, list: readonly Note[]) {
870 const ids = new Set(list.flatMap(n => n.ids))
871 if (ids.size > 0) await update($, caughtAtom, all => all.map(c => (c.state === 'found' && ids.has(c.id) ? { ...c, state: 'told' as const } : c)))
872}
873
874// The check runs beside the turn, so an edit is not held for it: its note rides on the loop's next
875// tool result, or goes with the turn's end.
876function checkInBackground($: EngineInterface, loop: string, change: Change) {
877 const running = pending.get(loop) ?? new Set<Promise<void>>()
878 pending.set(loop, running)
879 const at = conversation
880 const done = new Promise<void>(resolve => {
881 $.clock.after(0, () => {
882 inspect($, change)
883 .then(async violations => {
884 if (violations.length === 0 || at !== conversation) return
885 const note = { text: noteFor(change.file, violations), ids: [] as string[] }
886 notes.set(loop, [...(notes.get(loop) ?? []), note])
887 note.ids.push(...(await keepCaught($, change, violations, 'found')))
888 })
889 .catch(() => undefined)
890 .finally(resolve)
891 })
892 })
893 running.add(done)
894 void done.then(() => running.delete(done))
895}
896
897// The notes of `loop`, once the checks it still runs are done or the wait is up.
898async function settle($: EngineInterface, loop: string) {
899 const running = pending.get(loop)
900 if (running?.size) await Promise.race([Promise.all(running), $.clock.sleep(STOP_WAIT_MS).catch(() => undefined)])
901 return takeNotes(loop)
902}
903
904// Where no classic Stop hook ran, as where an organization manages Claude Code: a main turn that
905// ended with notes unread gets them as a prompt of its own, once; a subagent's go to the main loop.
906async function settleTurn($: EngineInterface, agentId: string | undefined, reason: string) {
907 const loop = agentId ?? MAIN
908 if (stopped.delete(loop)) return
909 if (loop !== MAIN) {
910 const delivered = await settle($, loop)
911 if (delivered.length > 0) notes.set(MAIN, [...(notes.get(MAIN) ?? []), ...delivered.map(note => ({ ...note, text: `A subagent left this unfixed:\n${note.text}` }))])
912 return
913 }
914 const wasSentBack = sentBack
915 sentBack = false
916 if (reason !== 'answer') return
917 const delivered = await settle($, MAIN)
918 if (delivered.length === 0) return
919 // The last turn was sent back already: this one goes, its notes kept for the next.
920 if (wasSentBack) {
921 putBack(MAIN, delivered)
922 return
923 }
924 sentBack = true
925 // Not awaited: the prompt runs once the session is idle, after this turn's end.
926 void $.prompt
927 .submit({ text: textOf(delivered).join('\n\n') })
928 .then(r => (r.drop === undefined ? markTold($, delivered) : putBack(MAIN, delivered)))
929 .catch(() => putBack(MAIN, delivered))
930}
931
932// A caught rule that may still be in its file: found, told or open.
933const isLive = (c: CaughtRule) => c.state === 'found' || c.state === 'told' || c.state === 'open'
934
935// After a turn, what was caught while it ran is looked for again: still in its file, it is open as
936// any finding; gone, Claude fixed it. One whose evidence the edit did not hold as quoted stays told,
937// and the review after the turn reads its file.
938async function resolveCaught($: EngineInterface) {
939 const now = new Map<string, string | null>()
940 for (const c of await read($, caughtAtom))
941 if (c.evidence !== undefined && isLive(c) && !now.has(c.path)) now.set(c.path, await readOrNull($, c.path))
942 await update($, caughtAtom, all =>
943 all.map(c => {
944 const text = now.get(c.path)
945 if (c.evidence === undefined || text === undefined || !isLive(c)) return c
946 const line = text === null ? undefined : lineNear(text, c.evidence, c.line)
947 const { line: _was, ...rest } = c
948 return line === undefined ? { ...rest, state: 'fixed' as const } : { ...rest, line, state: 'open' as const }
949 }),
950 )
951}
952
953const open = (f: Finding, sent: readonly string[], dismissed: readonly string[]) => !sent.includes(f.id) && !dismissed.includes(f.id)
954
955// What was caught as Claude edited and is still in the code, neither sent to Claude nor put aside.
956async function openCaught($: EngineInterface) {
957 const sent = await read($, sentAtom)
958 const dismissed = await read($, dismissedAtom)
959 return (await read($, caughtAtom)).filter(c => c.state === 'open' && open(c, sent, dismissed))
960}
961
962// The findings on `names` the person put aside or sent to Claude, each once: the review is told of
963// them, so what was answered is not found again in other words as new.
964async function settledOn($: EngineInterface, names: readonly string[]) {
965 const sent = new Set(await read($, sentAtom))
966 const dismissed = new Set(await read($, dismissedAtom))
967 const all = [...Object.values(await read($, reviewsAtom)).flat(), ...(await read($, caughtAtom))]
968 const taken = new Set<string>()
969 return all
970 .filter(f => (sent.has(f.id) || dismissed.has(f.id)) && isOn(f, names) && !taken.has(f.id) && taken.add(f.id))
971 .slice(-SETTLED_KEPT)
972 .map(f => ({ where: findingAt(f), text: f.text, isSent: sent.has(f.id) }))
973}
974
975// Whether a finding is on one of `names`, paths and shown paths, as the model named its file: by
976// `/`, as a Windows path may be named either way.
977const isOn = (f: Finding, names: readonly string[]) => {
978 const file = f.file.replace(/\\/g, '/').replace(/^\.\//, '')
979 return names.some(n => n === f.file || n.replace(/\\/g, '/').endsWith(`/${file}`))
980}
981
982// The findings of every review but `diff`'s still open, each once: a finding stays until it is sent
983// to Claude or put aside, while its file is still changed in the session (or uncommitted, in view).
984async function earlierFindings($: EngineInterface, diff: Diff | null) {
985 const reviews = await read($, reviewsAtom)
986 const reviewed = (await read($, reviewedAtom)) ?? {}
987 const sent = await read($, sentAtom)
988 const dismissed = await read($, dismissedAtom)
989 const names = [...(await read($, changedAtom)), ...(diff?.mode === 'uncommitted' ? diff.files.flatMap(f => [f.path, f.shown]) : [])]
990 const taken = new Set((diff ? (reviews[diff.fingerprint] ?? []) : []).map(f => f.id))
991 // A later review whose diff changed the finding's line read it again: what it did not find again
992 // there is gone. A finding on a line no later diff touched, or on no line, stays.
993 const isOutdated = (f: Finding, fingerprint: string) => {
994 const at = reviewed[fingerprint]?.at
995 if (at === undefined || f.lineText === undefined) return false
996 const key = lineKey(f.lineText)
997 return Object.values(reviewed).some(r => r.at > at && isOn(f, r.files) && (r.changed ?? []).includes(key))
998 }
999 return Object.entries(reviews)
1000 .filter(([fingerprint]) => fingerprint !== diff?.fingerprint)
1001 .flatMap(([fingerprint, findings]) => findings.filter(f => !isOutdated(f, fingerprint)))
1002 .filter(f => {
1003 if (taken.has(f.id) || !open(f, sent, dismissed) || !isOn(f, names)) return false
1004 taken.add(f.id)
1005 return true
1006 })
1007}
1008
1009export async function registerCommand($: EngineInterface) {
1010 await $.command.register({ name: 'changes', description: (await wordsIn($)).description, argumentHint: '[rules|lang en|ru]' })
1011}
1012
1013export function configureReview(options: PluginOptions) {
1014 settings = settingsOf(options)
1015}
1016
1017export async function startReview($: EngineInterface) {
1018 // A reload runs this again with nothing in flight: a review the old module was running when it
1019 // went would hold `busy` for good, and every review after it would wait on it.
1020 await update($, busyAtom, () => [])
1021 await registerCommand($)
1022}
1023
1024// A /clear or /resume starts another conversation: what this module kept of the one before (notes
1025// for Claude, checks running, a review waiting its turn, what the shell was taken as) is dropped,
1026// and the language read again.
1027export async function restartReview($: EngineInterface) {
1028 conversation++
1029 notes.clear()
1030 pending.clear()
1031 stopped.clear()
1032 sentBack = false
1033 reviewNext = undefined
1034 shellTaking = undefined
1035 contexts.clear()
1036 refusals.clear()
1037}
1038
1039export async function turnStart($: Parameters<Hook<'turn.start'>>[0], e: Parameters<Hook<'turn.start'>>[1], next: Parameters<Hook<'turn.start'>>[2]) {
1040 if (e.agentId === undefined) {
1041 isFailureTold = false
1042 refusals.clear()
1043 await update($, turnBeforeAtom, () => ({}))
1044 await update($, turnDirtyAtom, () => null)
1045 await update($, turnShellAtom, () => [])
1046 shellTaking = undefined
1047 await update($, turnRequestAtom, () => e.text)
1048 if (e.text.trim() !== '') await update($, requestsAtom, r => [...r, e.text])
1049 }
1050 return next(e)
1051}
1052
1053// Every edit, a subagent's too, is taken before it runs: what the file was is what the turn
1054// and the session are compared with.
1055export async function beforeWrite($: Parameters<MatchedHook<'tool.call', { tool: 'Write' }>>[0], e: Parameters<MatchedHook<'tool.call', { tool: 'Write' }>>[1], next: Parameters<MatchedHook<'tool.call', { tool: 'Write' }>>[2]) {
1056 await remember($, e.file_path).catch(() => {})
1057 return next(e)
1058}
1059
1060export async function beforeEdit($: Parameters<MatchedHook<'tool.call', { tool: 'Edit' }>>[0], e: Parameters<MatchedHook<'tool.call', { tool: 'Edit' }>>[1], next: Parameters<MatchedHook<'tool.call', { tool: 'Edit' }>>[2]) {
1061 await remember($, e.file_path).catch(() => {})
1062 return next(e)
1063}
1064
1065export async function beforeNotebookEdit($: Parameters<MatchedHook<'tool.call', { tool: 'NotebookEdit' }>>[0], e: Parameters<MatchedHook<'tool.call', { tool: 'NotebookEdit' }>>[1], next: Parameters<MatchedHook<'tool.call', { tool: 'NotebookEdit' }>>[2]) {
1066 await remember($, e.notebook_path).catch(() => {})
1067 return next(e)
1068}
1069
1070export async function aroundShell($: Parameters<MatchedHook<'tool.call', { tool: 'Bash' }>>[0], e: Parameters<MatchedHook<'tool.call', { tool: 'Bash' }>>[1], next: Parameters<MatchedHook<'tool.call', { tool: 'Bash' }>>[2]) {
1071 await beforeShell($).catch(() => {})
1072 const ran = await next(e)
1073 await noteShell($, e.command, ran).catch(() => {})
1074 return ran
1075}
1076
1077type ToolCallEvent = Parameters<Hook<'tool.call'>>[1]
1078type ToolCallDone = Awaited<ReturnType<Parameters<Hook<'tool.call'>>[2]>>
1079
1080const changeOf = (e: ToolCallEvent) =>
1081 settings.live === 'off' ? undefined
1082 : e.tool === 'Edit' ? describeEdit(e.file_path, e.old_string, e.new_string)
1083 : e.tool === 'Write' ? describeWrite(e.file_path, e.content)
1084 : undefined
1085
1086// Each edit checked against the rules as it is made: before it lands, to refuse it, in `block`.
1087export async function checkBefore($: EngineInterface, e: ToolCallEvent) {
1088 const change = changeOf(e)
1089 if (!change || settings.live !== 'block') return undefined
1090 const found = await inspect($, change).catch(() => [])
1091 const file = `${canonical(change.file)}\0`
1092 const keyOf = (v: Violation) => `${file}${v.rule}`
1093 if (found.length === 0) for (const key of [...refusals.keys()]) if (key.startsWith(file)) refusals.delete(key)
1094 const tries = (v: Violation) => refusals.get(keyOf(v)) ?? 0
1095 const violations = found.filter(v => tries(v) < REFUSALS)
1096 // Refused for these as often as it may be: let through, for the person to judge on Findings.
1097 const passed = found.filter(v => tries(v) >= REFUSALS)
1098 if (passed.length > 0) {
1099 for (const v of passed) refusals.delete(keyOf(v))
1100 await keepCaught($, change, passed, 'open').catch(() => {})
1101 await $.ui.toast((await wordsIn($)).letThrough(nameOf(change.file)))
1102 }
1103 if (violations.length === 0) return undefined
1104 for (const v of violations) refusals.set(keyOf(v), tries(v) + 1)
1105 await keepCaught($, change, violations, 'refused').catch(() => {})
1106 return refusalFor(change.file, violations)
1107}
1108
1109// Or beside the turn once it has landed, in `warn`. What the checks found rides on the loop's next
1110// tool result, whatever the tool.
1111export function checkAfter($: EngineInterface, e: ToolCallEvent, ran: ToolCallDone): ToolCallDone {
1112 if (ran.deny !== undefined) return ran
1113 const loop = e.agentId ?? MAIN
1114 const change = changeOf(e)
1115 if (change && settings.live === 'warn' && !ran.isError) checkInBackground($, loop, change)
1116 const delivered = takeNotes(loop)
1117 if (delivered.length === 0) return ran
1118 void markTold($, delivered).catch(() => {})
1119 return { ...ran, context: [...(ran.context ?? []), ...textOf(delivered)] }
1120}
1121
1122// The turn's end waits for the checks of its edits, so what they caught is told to Claude and
1123// looked for again before the turn's diff is read.
1124export async function endReviewTurn($: EngineInterface, agentId: string | undefined, reason: string) {
1125 const taken = agentId === undefined ? await takeTurn($).catch(() => undefined) : undefined
1126 await settleTurn($, agentId, reason).catch(() => {})
1127 if (taken) void afterTurn($, taken).catch(() => {})
1128}
1129
1130// Where classic hooks run, they keep the same turn going instead of starting a new one.
1131// A stop already kept going once, or kept going by another hook, carries no notes: they wait for the
1132// loop's next tool result, or, a subagent let go, for the main loop.
1133export async function classicStop($: Parameters<Hook<'classic.Stop'>>[0], e: Parameters<Hook<'classic.Stop'>>[1], next: Parameters<Hook<'classic.Stop'>>[2]) {
1134 stopped.add(MAIN)
1135 const delivered = await settle($, MAIN)
1136 const result = await next(e)
1137 if (delivered.length === 0) return result
1138 if (e.stop_hook_active || result.block !== undefined) {
1139 putBack(MAIN, delivered)
1140 return result
1141 }
1142 await markTold($, delivered)
1143 return { ...result, block: textOf(delivered).join('\n\n') }
1144}
1145
1146export async function classicSubagentStop($: Parameters<Hook<'classic.SubagentStop'>>[0], e: Parameters<Hook<'classic.SubagentStop'>>[1], next: Parameters<Hook<'classic.SubagentStop'>>[2]) {
1147 stopped.add(e.agent_id)
1148 const delivered = await settle($, e.agent_id)
1149 const result = await next(e)
1150 if (delivered.length === 0) return result
1151 if (result.block !== undefined) {
1152 putBack(e.agent_id, delivered)
1153 return result
1154 }
1155 if (e.stop_hook_active) {
1156 putBack(MAIN, delivered.map(note => ({ ...note, text: `A subagent left this unfixed:\n${note.text}` })))
1157 return result
1158 }
1159 await markTold($, delivered)
1160 return { ...result, block: textOf(delivered).join('\n\n') }
1161}
1162
1163export async function command($: Parameters<MatchedHook<'command.run', { command: 'changes' }>>[0], e: Parameters<MatchedHook<'command.run', { command: 'changes' }>>[1]) {
1164 const [verb = ''] = e.args.trim().split(/\s+/)
1165 const w = await wordsIn($)
1166 if (verb.toLowerCase() === 'rules') {
1167 const found = await ruleFilesFor($, [])
1168 const running = [...pending.values()].reduce((n, r) => n + r.size, 0)
1169 const files = found.length > 0 ? `${w.rulesHere}\n${found.map(f => ` ${f.path}`).join('\n')}` : w.noRulesHere
1170 const failed = checksFailed > 0 ? `\n${w.checksFailed(checksFailed)}` : ''
1171 return { text: `${w.liveHeader(settings.live, settings.liveModel, running)}${failed}\n\n${files}` }
1172 }
1173 // Before Claude edits anything, what is uncommitted is what there is to look at.
1174 const hasEdits = (await read($, turnAtom)) !== null || Object.keys(await read($, beforeAtom)).length > 0
1175 if (!hasEdits) {
1176 await update($, modeAtom, () => 'uncommitted')
1177 if ((await uncommittedDiff($)).length === 0) return { text: w.nothing }
1178 }
1179 await openPane($)
1180 return { text: w.opened }
1181}
1182
1183// One row above the prompt: the last turn's edits, what the review found, what is left to look at.
1184// Red while it found a likely bug, yellow while findings or unseen pieces remain, green otherwise.
1185// The last turn with edits as the row above the prompt says it, and how many columns that takes:
1186// what it changed, what the review found, what is left to look at. Pressed, it opens the pane.
1187export async function reviewLine($: EngineInterface, e: AbovePromptEvent) {
1188 const turn = await read($, turnAtom)
1189 if (!turn) return null
1190 const { Text, Button } = $.ui.resolve(e)
1191 const w = await wordsIn($)
1192 const review = (await read($, reviewsAtom))[turn.fingerprint]
1193 const sent = await read($, sentAtom)
1194 const dismissed = await read($, dismissedAtom)
1195 const seen = await read($, seenAtom)
1196 const findings = [...(review ?? []).filter(f => open(f, sent, dismissed)), ...(await earlierFindings($, turn)), ...(await openCaught($))]
1197 const bugs = findings.filter(f => f.severity === 'bug').length
1198 const unseen = turn.files.flatMap(f => f.hunks).filter(hunk => !seen.includes(hunk.id)).length
1199 const isReviewing = (await read($, busyAtom)).includes('review') && !review
1200 const isSkipped = !review && !isReviewing && isSmall(codeOf(turn))hooks/router/router.tsx 301 lines1import type { EngineInterface, Hook, MatchedHook, PluginOptions } from 'claude-code'
2
3import type { Change, Decision, Pick, Setup, Skill } from '../../types/router'
4import type { PaneHook } from '../core/hooks'
5import { catalogNow, changesIn, isLoaded, KEPT_DECISIONS, lookAround, manualNow, oneLine } from './catalog'
6import { drawPane } from './pane'
7import { SYSTEM, directiveOf, isTerse, namesSkill, parseReply, promptOf, resolve, ruleHits, shortOf } from './route'
8import type { Failure } from './route'
9import {
10 COMMAND,
11 PANE,
12 addDecision,
13 flash,
14 forgetLoaded,
15 loadedIn,
16 markLoaded,
17 modeIn,
18 modeOf,
19 ownSetup,
20 refreshHeld,
21 registerCommand,
22 saveSetup,
23 setMode,
24 showMode,
25 setTab,
26 wordsIn,
27} from './state'
28import { WORDS } from './words'
29
30// The prompt waits on the model, so the model gets a short while: a pick that comes later is worth
31// less than the wait. It answers in one to two seconds, the cache cold or warm, so four leave room
32// for a slow one. One that failed twice in a row (offline, rate-limited, overloaded) is left to
33// rest, so the prompts after it are not kept waiting on it too; once is a blip.
34const MODEL_TIMEOUT_MS = 4_000
35const MODEL_REST_MS = 5 * 60_000
36const FAILS_TO_REST = 2
37
38// What the router reads besides the prompt: the person's previous prompt and the end of Claude's last
39// answer, so a "now the tests" is read as part of the work under way.
40let previous = ''
41let answer = ''
42// Until when the model is not asked, after it failed; how many calls in a row failed, and how the
43// last one did.
44let restUntil = 0
45let failsInRow = 0
46let lastFailure: Failure = { kind: 'unread' }
47
48async function failed($: EngineInterface, failure: Failure, canRest: boolean) {
49 lastFailure = failure
50 failsInRow += 1
51 if (canRest && failsInRow >= FAILS_TO_REST) restUntil = (await $.clock.now()) + MODEL_REST_MS
52
53 return { picks: [] as Pick[], note: 'failed', failure }
54}
55
56// What the router picks for a prompt: the person's rules first, and a rule that fires settles it,
57// its skill loaded already or not; where none fires, the model, unless the prompt says too little to
58// go on or the person named a skill themselves.
59async function routeOf($: EngineInterface, text: string, useModel: boolean, model: string, signal: AbortSignal) {
60 const { skills, setup } = await lookAround($)
61 if (skills.length === 0) return { picks: [] as Pick[], note: 'no skills' }
62 if (namesSkill(text, skills)) return { picks: [] as Pick[], note: 'named' }
63 const loaded = await loadedIn($)
64 const fresh = (picks: Pick[]) => picks.filter(p => !isLoaded(p.skill, loaded))
65 const hits = ruleHits(text, setup.rules, skills)
66 if (hits.length > 0) return fresh(hits).length > 0 ? { picks: fresh(hits) } : { picks: [] as Pick[], note: 'loaded' }
67 if (!useModel || isTerse(text)) return { picks: [] as Pick[] }
68 if ((await $.clock.now()) < restUntil) return { picks: [] as Pick[], note: 'resting' }
69
70 const changes = await changesIn($).catch(() => null)
71 const prompt = promptOf(text, { skills, hints: setup.hints, loaded, changes, previous, answer })
72 const reply = await $.model.complete(
73 {
74 model,
75 system: SYSTEM,
76 prompt: [{ text: prompt.skills, cache: true }, { text: prompt.rest }],
77 maxTokens: 300,
78 effort: 'low',
79 timeoutMs: MODEL_TIMEOUT_MS,
80 },
81 { signal },
82 )
83 if (!reply.isAnswered) {
84 // Cut by the person (escape on the prompt) the model did not fail; by its time, it did.
85 if (signal.aborted) return { picks: [] as Pick[] }
86 if (reply.reason === 'aborted') return failed($, { kind: 'timeout', seconds: MODEL_TIMEOUT_MS / 1000 }, true)
87 if (reply.reason === 'api-error') return failed($, { kind: 'api', status: reply.status, error: reply.error }, true)
88 return failed($, { kind: 'unread' }, true)
89 }
90 restUntil = 0
91 const picks = parseReply(reply.text, skills)
92 // An answer not read is the model's slip, not an outage: it is asked again on the next prompt.
93 if (picks === undefined) return failed($, { kind: 'unread' }, false)
94 failsInRow = 0
95
96 return { picks: fresh(picks) }
97}
98
99// Whether a model picks where no rule does, and which.
100let useModel = true
101let model = 'haiku'
102
103export function configureRouter(options: PluginOptions) {
104 useModel = options.routerUseModel !== false
105 model = typeof options.routerModel === 'string' && options.routerModel.trim() !== '' ? options.routerModel.trim() : 'haiku'
106}
107
108export async function startRouter($: EngineInterface) {
109 await refreshHeld($)
110 if ((await modeIn($)) === 'off') await showMode($, 'off')
111 await registerCommand($)
112 void lookAround($).catch(() => undefined)
113}
114
115export async function promptSubmit($: Parameters<Hook<'prompt.submit'>>[0], e: Parameters<Hook<'prompt.submit'>>[1], next: Parameters<Hook<'prompt.submit'>>[2]) {
116 // The person's own prompt: typed here, through Remote Control, or through an SDK host (`claude -p`,
117 // the desktop app, an IDE); not a plugin's, a peer's, a notification or a scheduled run.
118 const isPersons = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
119 if (e.command || e.isMeta || !isPersons) return next(e)
120 const mode = await modeIn($)
121 if (mode === 'off') return next(e)
122
123 const startedAt = await $.clock.now()
124 // The router's own slip (the commands not listed, the store not read) is not the model's.
125 const routed: { picks: Pick[]; note?: string; failure?: Failure } = await routeOf($, e.text, useModel, model, next.signal).catch(() => ({
126 picks: [] as Pick[],
127 note: 'error',
128 }))
129 previous = e.text.slice(0, 1500)
130 const ms = (await $.clock.now()) - startedAt
131 const decision: Decision = {
132 at: startedAt,
133 prompt: oneLine(e.text, 200),
134 picks: routed.picks,
135 ms,
136 ...(routed.note ? { note: routed.note } : {}),
137 }
138 const words = await wordsIn($)
139 if (routed.failure) decision.why = words.failure(routed.failure)
140 if (routed.note === 'resting') decision.why = words.failure(lastFailure)
141 // Kept for the pane; a write refused mid-render costs the history a line, not the prompt its skill.
142 await addDecision($, decision, KEPT_DECISIONS).catch(() => {})
143 // A model that failed says so where the pick would be, and why, so no pick is not read as nothing
144 // fitting; one left to rest, when it is asked again.
145 const note = routed.note
146 $.ui.status(
147 routed.picks.length > 0
148 ? words.status(routed.picks.map(p => shortOf(p.skill)).join(', '))
149 : note === 'failed'
150 ? words.status(words.failed(decision.why ?? ''))
151 : note === 'resting'
152 ? words.status(words.resting(decision.why ?? '', Math.ceil((restUntil - ms - startedAt) / 60_000)))
153 : note === 'error'
154 ? words.status(words.broken)
155 : undefined,
156 )
157 if (routed.picks.length === 0) return next(e)
158
159 return next({ ...e, context: [...(e.context ?? []), directiveOf(mode, routed.picks)] })
160}
161
162// A skill loaded by any road (the router's word, the person's `/name`, Claude's own choice) is in the
163// conversation from then on.
164export async function skillPrompt($: Parameters<Hook<'skill.prompt'>>[0], e: Parameters<Hook<'skill.prompt'>>[1], next: Parameters<Hook<'skill.prompt'>>[2]) {
165 const expanded = await next(e)
166 const name = e.pluginName && !e.skill.includes(':') ? `${e.pluginName}:${e.skill}` : e.skill
167 await markLoaded($, name)
168
169 return expanded
170}
171
172export function endRouterTurn(agentId: string | undefined, said: string) {
173 if (agentId === undefined && said) answer = said.slice(-800)
174}
175
176// A compaction can drop a skill's text from the conversation, and a /clear drops everything: such a
177// skill may be picked again.
178export async function sessionCompact($: Parameters<Hook<'session.compact'>>[0], e: Parameters<Hook<'session.compact'>>[1], next: Parameters<Hook<'session.compact'>>[2]) {
179 const compacted = await next(e)
180 if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in compacted && compacted.skip)) {
181 await forgetLoaded($)
182 }
183
184 return compacted
185}
186
187// A /clear or /resume starts another conversation: nothing said before it, no skill loaded, no pick
188// on the status line.
189export async function clearRouter($: EngineInterface) {
190 previous = ''
191 answer = ''
192 $.ui.status(undefined)
193 await forgetLoaded($)
194}
195
196// Its values emptied, the mode, the setup and the skills are read again, and off says so.
197export async function restoreRouter($: EngineInterface) {
198 await refreshHeld($)
199 await showMode($, await modeIn($))
200 void lookAround($, true).catch(() => undefined)
201}
202
203export async function command($: Parameters<MatchedHook<'command.run', { command: 'router' }>>[0], e: Parameters<MatchedHook<'command.run', { command: 'router' }>>[1]) {
204 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
205 const arg = rest.join(' ')
206 const words = await wordsIn($)
207 const mode = modeOf(verb)
208 if (mode) {
209 await setMode($, mode)
210
211 return { text: words.modeSet(words.modes[mode]) }
212 }
213 if (verb === '' || verb === 'rules' || verb === 'skills' || verb === 'history') {
214 await lookAround($).catch(() => undefined)
215 await refreshHeld($)
216 await setTab($, verb === '' ? 'overview' : verb)
217 await flash($, null)
218 await $.ui.open({ id: PANE, title: words.title, focus: true, closeOnEscape: true })
219
220 return { text: words.opened }
221 }
222 // The skills as installed now, whatever the mode: a skill installed while the router was off is
223 // found, and the project's own left-out ones are known.
224 const around = await lookAround($).catch(() => undefined)
225 const byProject = around?.project.ignored ?? []
226 if (verb === 'list') {
227 const own = await ownSetup($)
228 const lines = catalogNow().map(s => {
229 const isOut = own.ignored.includes(s.name) || byProject.includes(s.name)
230
231 return `${isOut ? '✗' : '·'} ${s.name} — ${oneLine(s.description, 80)}${byProject.includes(s.name) ? ` (${words.leftOutByProject})` : ''}`
232 })
233 const yours = manualNow().map(s => `− ${s.name} — ${oneLine(s.description, 80)} (${words.yoursToRun(s.name)})`)
234
235 return { text: words.list([...lines, ...yours]) }
236 }
237
238 const own = await ownSetup($)
239
240 if (verb === 'rm') {
241 const n = Number(arg)
242 if (!Number.isInteger(n) || n < 1 || n > own.rules.length) return { text: words.noSuchRule(arg) }
243 await saveSetup($, { ...own, rules: own.rules.filter((_, i) => i !== n - 1) })
244
245 return { text: words.removed(n) }
246 }
247
248 if (verb === 'add' || verb === 'when' || verb === 'ignore' || verb === 'unignore') {
249 const [name = '', ...tail] = rest
250 const skill = resolve(name, catalogNow())
251 const yours = skill === undefined ? resolve(name, manualNow()) : undefined
252 if (yours !== undefined) return { text: words.notLoadable(yours) }
253 if (skill === undefined) return { text: words.unknownSkill(name) }
254 const said = tail.join(' ').trim()
255 if (verb === 'add') {
256 const when = said.split(',').map(w => w.trim()).filter(Boolean)
257 if (when.length === 0) return { text: words.usage }
258 await saveSetup($, { ...own, rules: [...own.rules, { skill, when }] })
259
260 return { text: words.added(shortOf(skill), when.join(', ')) }
261 }
262 if (verb === 'when') {
263 if (said === '') return { text: words.usage }
264 await saveSetup($, { ...own, hints: { ...own.hints, [skill]: said } })
265
266 return { text: words.hinted(shortOf(skill)) }
267 }
268 if (verb === 'unignore' && byProject.includes(skill)) return { text: words.ignoredByProject(shortOf(skill)) }
269 const ignored = own.ignored.filter(s => s !== skill)
270 await saveSetup($, { ...own, ignored: verb === 'ignore' ? [...ignored, skill] : ignored })
271
272 return { text: verb === 'ignore' ? words.ignoredNow(shortOf(skill)) : words.unignoredNow(shortOf(skill)) }
273 }
274
275 return { text: words.usage }
276}
277
278// Skill names after `/router add`, `when`, `ignore`, `unignore`: the short name where it is unique.
279export async function promptAutocomplete($: Parameters<Hook<'prompt.autocomplete'>>[0], e: Parameters<Hook<'prompt.autocomplete'>>[1], next: Parameters<Hook<'prompt.autocomplete'>>[2]) {
280 const offered = await next(e)
281 const typed = /^\/router\s+(?:add|when|ignore|unignore)\s+(\S*)$/.exec(e.text.slice(0, e.cursor))
282 if (typed === null) return offered
283 if (catalogNow().length === 0) await lookAround($).catch(() => undefined)
284 const start = (typed[1] ?? '').toLowerCase()
285 const mine = catalogNow()
286 .filter(s => s.name.toLowerCase().startsWith(start) || shortOf(s.name).toLowerCase().startsWith(start))
287 .slice(0, 8)
288 .map(s => {
289 const short = shortOf(s.name)
290
291 return { text: resolve(short, catalogNow()) === s.name ? short : s.name, description: oneLine(s.description, 60) }
292 })
293
294 return { suggestions: [...offered.suggestions, ...mine] }
295}
296
297export async function pane($: Parameters<PaneHook>[0], e: Parameters<PaneHook>[1]) {
298 // The pane's own width, not the terminal's: it may sit beside the chat.
299 return drawPane($, $.ui.resolve(e), { columns: e.props.bodyColumns, rows: e.viewport?.rows ?? 24, surface: e.surface })
300}
301hooks/router/state.ts 177 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Decision, Mode, Setup, Skill, Stats, Tab } from '../../types/router'
5import { langIn } from '../core/lang'
6import { setupOf } from './route'
7import { WORDS } from './words'
8import type { Words } from './words'
9
10// The session's values live here alone, each read and written through the functions below.
11
12// The router's last few decisions, newest first, for the pane.
13const decisionsAtom = atom({ plugin: 'agent-lens', key: 'router.decisions' } as const, [] as Decision[])
14// Every prompt routed this session, counted: the decisions keep the last few alone.
15const statsAtom = atom({ plugin: 'agent-lens', key: 'router.stats' } as const, null)
16// The skills loaded in this conversation so far: the router does not pick them again.
17const loadedAtom = atom({ plugin: 'agent-lens', key: 'router.loaded' } as const, [] as string[])
18// The mode and the person's own setup, as last read from the store, for the pane, which only reads;
19// null until read.
20const modeAtom = atom({ plugin: 'agent-lens', key: 'router.mode' } as const, null)
21const setupAtom = atom({ plugin: 'agent-lens', key: 'router.setup' } as const, null)
22// Every skill the Skill tool runs, ignored ones too, and the project's setup, as last looked at.
23const catalogAtom = atom({ plugin: 'agent-lens', key: 'router.catalog' } as const, [] as Skill[])
24const projectAtom = atom({ plugin: 'agent-lens', key: 'router.project' } as const, null)
25// The pane: its tab, the skill and texts in the new-rule form, and the line saying what the last press did.
26const tabAtom = atom({ plugin: 'agent-lens', key: 'router.tab' } as const, 'overview')
27const formSkillAtom = atom({ plugin: 'agent-lens', key: 'router.formSkill' } as const, null)
28const formWordsAtom = atom({ plugin: 'agent-lens', key: 'router.formWords' } as const, '')
29const formHintAtom = atom({ plugin: 'agent-lens', key: 'router.formHint' } as const, '')
30const flashAtom = atom({ plugin: 'agent-lens', key: 'router.flash' } as const, null)
31const flashToneAtom = atom({ plugin: 'agent-lens', key: 'router.flashTone' } as const, 'success' as 'success' | 'warning')
32
33export const PANE = 'agent-lens-router'
34export const COMMAND = 'router'
35const MODE_KEY = 'mode'
36const SETUP_KEY = 'setup'
37export const MODES: readonly Mode[] = ['auto', 'suggest', 'off']
38export const TABS: readonly Tab[] = ['overview', 'rules', 'skills', 'history']
39
40export const modeOf = (value: unknown): Mode | undefined => MODES.find(m => m === value)
41
42export async function wordsIn($: EngineInterface): Promise<Words> {
43 return WORDS[await langIn($)]
44}
45
46export async function registerCommand($: EngineInterface) {
47 await $.command.register({
48 name: COMMAND,
49 description: (await wordsIn($)).summary,
50 argumentHint: '[auto|suggest|off|add|when|ignore|unignore|rm|list|lang]',
51 })
52}
53
54export async function modeIn($: EngineInterface): Promise<Mode> {
55 const held = await read($, modeAtom)
56 if (held !== null) return held
57 const mode = modeOf(await $.store.get(MODE_KEY)) ?? 'auto'
58 await update($, modeAtom, () => mode)
59
60 return mode
61}
62
63export async function setMode($: EngineInterface, mode: Mode) {
64 await $.store.set(MODE_KEY, mode)
65 await update($, modeAtom, () => mode)
66 await showMode($, mode)
67}
68
69// Off, the router says so on the status line, so routing is not taken to be on; on, the line is the
70// pick's again.
71export async function showMode($: EngineInterface, mode: Mode) {
72 $.ui.status(mode === 'off' ? (await wordsIn($)).offStatus : undefined)
73}
74
75export async function ownSetup($: EngineInterface): Promise<Setup> {
76 return setupOf(await $.store.get(SETUP_KEY))
77}
78
79export async function saveSetup($: EngineInterface, setup: Setup) {
80 await $.store.set(SETUP_KEY, setup)
81 await update($, setupAtom, () => setup)
82}
83
84// What the store holds read into the session's values the pane draws from.
85export async function refreshHeld($: EngineInterface) {
86 const [mode, own] = await Promise.all([$.store.get(MODE_KEY), ownSetup($)])
87 await update($, modeAtom, () => modeOf(mode) ?? 'auto')
88 await update($, setupAtom, () => own)
89}
90
91// The skills and the project's setup as `lookAround` last found them, for the pane.
92export async function holdCatalog($: EngineInterface, catalog: Skill[], project: Setup) {
93 await update($, catalogAtom, () => catalog)
94 await update($, projectAtom, () => project)
95}
96
97const NO_STATS: Stats = { prompts: 0, withSkill: 0, byRule: 0, byModel: 0, bySkill: {} }
98
99// The counts with `d` in them.
100const counted = (s: Stats, d: Decision): Stats => ({
101 prompts: s.prompts + 1,
102 withSkill: s.withSkill + (d.picks.length > 0 ? 1 : 0),
103 byRule: s.byRule + d.picks.filter(p => p.by === 'rule').length,
104 byModel: s.byModel + d.picks.filter(p => p.by === 'model').length,
105 bySkill: d.picks.reduce((all, p) => ({ ...all, [p.skill]: (all[p.skill] ?? 0) + 1 }), s.bySkill),
106})
107
108// The counts as kept, else as the decisions kept say them (oldest first).
109export const statsOf = (stats: Stats | null, decisions: readonly Decision[]) => stats ?? [...decisions].reverse().reduce(counted, NO_STATS)
110
111export async function addDecision($: EngineInterface, decision: Decision, kept: number) {
112 const before = await read($, decisionsAtom)
113 await update($, statsAtom, s => counted(statsOf(s, before), decision))
114 await update($, decisionsAtom, list => [decision, ...list].slice(0, kept))
115}
116
117export async function loadedIn($: EngineInterface): Promise<string[]> {
118 return read($, loadedAtom)
119}
120
121export async function markLoaded($: EngineInterface, skill: string) {
122 await update($, loadedAtom, list => (list.includes(skill) ? list : [...list, skill]))
123}
124
125export async function forgetLoaded($: EngineInterface) {
126 await update($, loadedAtom, () => [])
127}
128
129export async function setTab($: EngineInterface, tab: Tab) {
130 await update($, tabAtom, () => tab)
131}
132
133export async function setFormSkill($: EngineInterface, skill: string) {
134 await update($, formSkillAtom, () => skill)
135}
136
137export async function setFormWords($: EngineInterface, text: string) {
138 await update($, formWordsAtom, () => text)
139}
140
141export async function setFormHint($: EngineInterface, text: string) {
142 await update($, formHintAtom, () => text)
143}
144
145// Everything the pane draws, read at once; drawing only reads.
146export async function paneState($: EngineInterface) {
147 const [tab, mode, said, own, project, catalog, decisions, loaded, lang, formSkill, formWords, formHint, stats, saidTone] = await Promise.all([
148 read($, tabAtom),
149 read($, modeAtom),
150 read($, flashAtom),
151 read($, setupAtom),
152 read($, projectAtom),
153 read($, catalogAtom),
154 read($, decisionsAtom),
155 read($, loadedAtom),
156 langIn($),
157 read($, formSkillAtom),
158 read($, formWordsAtom),
159 read($, formHintAtom),
160 read($, statsAtom),
161 read($, flashToneAtom),
162 ])
163
164 return { tab, mode, said, own, project, catalog, decisions, loaded, lang, formSkill, formWords, formHint, stats: statsOf(stats, decisions), saidTone: saidTone ?? 'success' }
165}
166
167export async function flash($: EngineInterface, text: string | null, tone: 'success' | 'warning' = 'success') {
168 await update($, flashAtom, () => text)
169 await update($, flashToneAtom, () => tone)
170}
171
172// The person's setup changed from the pane: saved, and the line under the pane says what was done.
173export async function changeSetup($: EngineInterface, change: (own: Setup) => Setup, said: string) {
174 await saveSetup($, change(await ownSetup($)))
175 await flash($, said)
176}
177hooks/setup/setup.tsx 584 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, TurnCompleteReason } from 'claude-code'
3
4import type { DocIssue, Finding, Judge, Run, StepId, StepState } from '../../types/setup'
5import type { PaneHook } from '../core/hooks'
6import { langIn } from '../core/lang'
7import { joinPath, parentOf } from '../review/diff'
8import { diffLines } from '../spec/spec'
9import { ADR_DIR, checkDocs, DOC_FILES, firstOf, LIMITS, mappedGlossaries, OLD_GLOSSARIES, parseFindings, pathsIn, pathsNamed, termsOf } from './docs'
10import { WORDS } from './words'
11
12// /setup-project: prepares a project for work with the agent, one step at a time, each sent to the
13// session once the turn before it has ended, and keeps the files those steps write to their form.
14// The way of it is docs/setup-project.md.
15
16const runAtom = atom({ plugin: 'agent-lens', key: 'setup.run' } as const, null)
17const issuesAtom = atom({ plugin: 'agent-lens', key: 'setup.issues' } as const, null)
18const judgeAtom = atom({ plugin: 'agent-lens', key: 'setup.judge' } as const, null)
19const retroAtom = atom({ plugin: 'agent-lens', key: 'setup.retro' } as const, null)
20export const PANE = 'agent-lens-setup'
21const runKey = (root: string) => `setup:${root}`
22
23const STEPS: StepId[] = ['matt', 'openspec', 'existing', 'map', 'standards', 'workflow', 'config']
24// The skills of Matt's the way of working stands on; the first sets the project up for the rest.
25const MATT = 'mattpocock-skills'
26const MATT_SKILLS = ['setup-matt-pocock-skills', 'domain-modeling', 'writing-for-agents', 'grilling', 'tdd', 'retro']
27// A project is set up once this file is there: from then on its files are checked.
28const MARK = 'docs/agents/workflow.md'
29// Docs a project wrote for its agents before /setup-project, beside a CLAUDE.md of its own: where
30// one is there, what is written is sorted before the steps write more.
31const OWN_DOCS = ['AGENTS.md', ...Object.keys(OLD_GLOSSARIES), '.cursorrules', '.cursor/rules', '.github/copilot-instructions.md']
32
33// The files each step writes: the step is done once they are there and pass the check. Matt's
34// skill writes its own and keeps them to its own form. Sorting what is written writes none of its
35// own: it is done with the turn that answers it.
36const WRITES: Record<StepId, string[]> = {
37 matt: ['docs/agents/issue-tracker.md', 'docs/agents/domain.md'],
38 openspec: ['openspec/config.yaml'],
39 existing: [],
40 map: ['GLOSSARY.md', 'ARCHITECTURE.md'],
41 standards: ['CODING_STANDARDS.md'],
42 workflow: ['docs/agents/workflow.md', 'CLAUDE.md'],
43 config: ['openspec/config.yaml'],
44}
45const CHECKED: Record<StepId, (file: string) => boolean> = {
46 matt: () => false,
47 openspec: () => false,
48 existing: f => f in OLD_GLOSSARIES,
49 map: f => /(^|\/)GLOSSARY(-MAP)?\.md$/.test(f) || f === 'ARCHITECTURE.md' || f.startsWith(`${ADR_DIR}/`),
50 standards: f => f === 'CODING_STANDARDS.md',
51 workflow: f => f === 'docs/agents/workflow.md' || f === 'CLAUDE.md',
52 config: () => false,
53}
54
55// Whether the main loop is in a turn: a step waits for its end.
56let isMainTurn = false
57// The files as the turn found them, by their path from the root: all of them in a project set up,
58// CLAUDE.md alone elsewhere. What the docs agent is shown the turn changed, whatever wrote them
59// (Write, Edit, or the shell, which no edit hook sees).
60let turnBefore: Promise<Record<string, string>> | null = null
61// While /setup-project's steps go, the files as the run found them, each as it was before the first
62// turn that changed it (undefined: not there): the docs agent reads them once, at the run's end.
63const runBefore = new Map<string, string | undefined>()
64// What may have gone stale, as last looked for: at the session's start and on /setup-project check.
65let drift: DocIssue[] = []
66// The docs agents this module started, by id, as long as it runs.
67const judges = new Set<string>()
68const JUDGE_MODEL = 'sonnet'
69const JUDGE_STALE_MS = 20 * 60_000
70// What each file the agent is shown keeps of each side of an edit.
71const EDIT_LIMIT = 12_000
72// A part of the map with this many commits since the map was edited may have moved on from it.
73const STALE_COMMITS = 15
74// How many of the map's paths and the glossary's terms are looked up, at most.
75const DRIFT_PATHS = 40
76const DRIFT_TERMS = 80
77const DOC_PATH = /(?:^|[\\/])(?:CLAUDE|AGENTS|GLOSSARY|GLOSSARY-MAP|CONTEXT|CONTEXT-MAP|ARCHITECTURE|CODING_STANDARDS|workflow)\.md$|[\\/]docs[\\/]adr[\\/][^\\/]+\.md$/
78
79async function wordsIn($: EngineInterface) {
80 return WORDS[await langIn($)]
81}
82
83async function exists($: EngineInterface, root: string, rel: string) {
84 return $.fs.exists(joinPath(root, rel)).catch(() => false)
85}
86
87// The CLI's version, or null where it does not run. npm installs it on Windows as `openspec.cmd`.
88async function openspecVersion($: EngineInterface, root: string) {
89 const run = (cli: string) => $.process.run([cli, '--version'], { cwd: root, timeoutMs: 15_000 })
90 const r = await run('openspec')
91 .catch(() => run('openspec.cmd'))
92 .catch(() => null)
93 return r && r.exitCode === 0 ? r.stdout.trim().split('\n').at(-1)!.trim() : null
94}
95
96// What /setup-project needs: Matt's skills by the name each runs by, and the OpenSpec CLI.
97async function preflight($: EngineInterface, root: string) {
98 const commands = await $.command.list().catch(() => [])
99 const named = (skill: string) => commands.find(c => c.name === `${MATT}:${skill}` || (c.plugin === MATT && c.name.endsWith(skill)))?.name
100 const missing = MATT_SKILLS.filter(s => !named(s))
101 const openspec = await openspecVersion($, root)
102 return { missing, setupCommand: named('setup-matt-pocock-skills') ?? `${MATT}:setup-matt-pocock-skills`, openspec, isInitialised: await exists($, root, 'openspec') }
103}
104
105// The files the check reads: those named, the glossaries the map links, the ADRs.
106async function readDocs($: EngineInterface, root: string) {
107 const files: Record<string, string> = {}
108 const take = async (rel: string) => {
109 const text = await $.fs.read(joinPath(root, rel)).catch(() => undefined)
110 if (text !== undefined) files[rel] = text
111 }
112 await Promise.all(DOC_FILES.map(take))
113 if (files['GLOSSARY-MAP.md']) await Promise.all(mappedGlossaries(files['GLOSSARY-MAP.md']).map(take))
114 const adrs = await $.fs.list(joinPath(root, ADR_DIR)).catch(() => [])
115 await Promise.all(adrs.filter(f => f.kind === 'file' && f.name.endsWith('.md')).map(f => take(`${ADR_DIR}/${f.name}`)))
116 return files
117}
118
119async function issuesIn($: EngineInterface, root: string) {
120 const files = await readDocs($, root)
121 // Missing: not there, where it claims the project's own folder.
122 const named = pathsNamed(files)
123 const missing = await Promise.all(named.map(async p => !(await exists($, root, p)) && (!p.includes('/') || (await exists($, root, firstOf(p))))))
124 return checkDocs(files, new Set(named.filter((_, i) => missing[i])))
125}
126
127// The check of the files, kept for the row and the pane, in a project set up or being set up.
128export async function checkProject($: EngineInterface) {
129 const root = await $.session.root()
130 const run = await read($, runAtom)
131 if (!run && !(await exists($, root, MARK))) {
132 await update($, issuesAtom, () => null)
133 return null
134 }
135 const issues = [...(await issuesIn($, root)), ...drift]
136 await update($, issuesAtom, () => issues)
137 return issues
138}
139
140// What may have gone stale: each part of the map git shows much changed since the map was last
141// edited, and each glossary term found nowhere else in its repository. Looked for beside the
142// session's start, as it runs git once per path and term.
143async function driftIn($: EngineInterface, root: string) {
144 const files = await readDocs($, root)
145 const git = (cwd: string, args: string[]) => $.process.run(['git', '-C', cwd, ...args], { cwd, timeoutMs: 15_000 }).catch(() => null)
146 const found: DocIssue[] = []
147 if (files['ARCHITECTURE.md']) {
148 const since = (await git(root, ['log', '-1', '--format=%H', '--', 'ARCHITECTURE.md']))?.stdout.trim()
149 if (since) {
150 const paths = pathsIn(files['ARCHITECTURE.md'])
151 .map(p => p.replace(/^\.\//, '').replace(/\/$/, ''))
152 .filter(p => p.includes('/') || !p.includes('.'))
153 .slice(0, DRIFT_PATHS)
154 for (const path of paths) {
155 const n = Number((await git(root, ['rev-list', '--count', `${since}..HEAD`, '--', path]))?.stdout.trim())
156 if (n >= STALE_COMMITS) found.push({ file: 'ARCHITECTURE.md', kind: 'stale-map', detail: `${path} (${n})` })
157 }
158 }
159 }
160 for (const [file, text] of Object.entries(files).filter(([f]) => /(^|\/)GLOSSARY\.md$/.test(f)))
161 for (const term of termsOf(text).slice(0, DRIFT_TERMS)) {
162 const at = parentOf(joinPath(root, file))
163 const r = await git(at, ['grep', '-I', '-q', '-i', '-F', '-e', term, '--', '.', ':(exclude,glob)**/GLOSSARY*.md'])
164 // 1: none found; anything else (no repository, an error) says nothing.
165 if (r?.exitCode === 1) found.push({ file, kind: 'stale-term', detail: term })
166 }
167 return found
168}
169
170async function lookForDrift($: EngineInterface) {
171 const root = await $.session.root()
172 if (!(await read($, runAtom)) && !(await exists($, root, MARK))) return
173 drift = await driftIn($, root)
174 await checkProject($)
175}
176
177async function keepRun($: EngineInterface, run: Run) {
178 await update($, runAtom, () => run)
179 await $.store.set(runKey(run.root), run)
180}
181
182const setStep = (run: Run, step: StepId, state: StepState): Run => ({ ...run, steps: { ...run.steps, [step]: state } })
183
184// Whether the project has docs of its own to sort: one of another tool's, or a CLAUDE.md the steps
185// did not write.
186async function hasOwnDocs($: EngineInterface, root: string) {
187 const there = await Promise.all(OWN_DOCS.map(f => exists($, root, f)))
188 return there.some(Boolean) || ((await exists($, root, 'CLAUDE.md')) && !(await exists($, root, MARK)))
189}
190
191async function isWritten($: EngineInterface, root: string, step: StepId) {
192 if (step === 'existing') return !(await hasOwnDocs($, root))
193 if (step === 'config') return (await $.fs.read(joinPath(root, 'openspec/config.yaml')).catch(() => '')).includes('GLOSSARY.md')
194 const there = await Promise.all(WRITES[step].map(f => exists($, root, f)))
195 return there.every(Boolean)
196}
197
198// Sends the next step not done, unless one is with Claude or a turn runs; openspec init the mod
199// runs itself.
200async function advance($: EngineInterface) {
201 if (isMainTurn) return
202 let run = await read($, runAtom)
203 if (!run || (run.current && run.steps[run.current].status === 'sent')) return
204 const step = STEPS.find(s => run!.steps[s].status === 'waiting')
205 if (!step) {
206 await keepRun($, { ...run, current: null })
207 return
208 }
209 const w = await wordsIn($)
210 run = { ...setStep(run, step, { status: 'sent' }), current: step }
211 await keepRun($, run)
212 if (step === 'openspec') {
213 const r = await $.process
214 .run(['openspec', 'init', '--tools', 'claude', '--no-animation'], { cwd: run.root, timeoutMs: 120_000 })
215 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
216 const isDone = r.exitCode === 0 && (await exists($, run.root, 'openspec'))
217 const why = (r.stderr || r.stdout).trim().split('\n').at(-1) || `exit ${r.exitCode}`
218 await keepRun($, setStep(run, step, isDone ? { status: 'done' } : { status: 'failed', error: w.openspecFailed(why) }))
219 if (isDone) await advance($)
220 return
221 }
222 if (step === 'matt') {
223 const command = (await preflight($, run.root)).setupCommand
224 // In the mod's language, which the skill does not know of.
225 const ran = await $.command.run({ command, ...(w.speak ? { args: w.speak } : {}) }).then(
226 () => true,
227 () => false,
228 )
229 if (!ran) await keepRun($, setStep(run, step, { status: 'failed', error: `/${command}` }))
230 return
231 }
232 const r = await $.prompt.submit({ text: w.prompts[step](), asUser: true })
233 if (r.drop !== undefined) await keepRun($, setStep(run, step, { status: 'failed', error: r.drop }))
234}
235
236// A main turn ended with an answer: the step with Claude is done once its files are there and pass
237// the check; where they fail, what failed goes back once, then it waits for the person.
238async function afterTurn($: EngineInterface) {
239 const issues = await checkProject($)
240 await settleStep($, issues)
241 await startJudge($).catch(() => {})
242}
243
244async function settleStep($: EngineInterface, issues: DocIssue[] | null) {
245 const run = await read($, runAtom)
246 const step = run?.current
247 if (!run || !step || run.steps[step].status !== 'sent') return void (await advance($))
248 if (step !== 'existing' && !(await isWritten($, run.root, step))) return
249 const failing = (issues ?? []).filter(i => CHECKED[step](i.file))
250 if (failing.length === 0) {
251 await keepRun($, setStep(run, step, { status: 'done' }))
252 return void (await advance($))
253 }
254 if (run.steps[step].isFixSent) {
255 await keepRun($, setStep(run, step, { status: 'failed', isFixSent: true }))
256 return
257 }
258 await sendFix($, run, step, failing)
259}
260
261async function sendFix($: EngineInterface, run: Run, step: StepId, failing: DocIssue[]) {
262 const w = await wordsIn($)
263 await keepRun($, setStep(run, step, { status: 'sent', isFixSent: true }))
264 const r = await $.prompt.submit({ text: w.prompts.fix(w.steps[step], failing), asUser: true })
265 if (r.drop !== undefined) await keepRun($, setStep(run, step, { status: 'failed', error: r.drop }))
266}
267
268export function startSetupTurn($: EngineInterface) {
269 isMainTurn = true
270 turnBefore = watched($).catch(() => ({}))
271}
272
273export function endSetupTurn($: EngineInterface, reason: TurnCompleteReason) {
274 isMainTurn = false
275 if (reason === 'answer') void afterTurn($).catch(() => {})
276 else turnBefore = null
277}
278
279// An edit to one of the files is checked beside the turn.
280export function afterSetupEdit($: EngineInterface, path: string) {
281 if (DOC_PATH.test(path)) void checkProject($).catch(() => {})
282}
283
284// The files the docs agent watches: all of them in a project set up; elsewhere CLAUDE.md, kept
285// from growing.
286async function watched($: EngineInterface): Promise<Record<string, string>> {
287 const root = await $.session.root()
288 if (await isSetUp($)) return readDocs($, root)
289 const claude = await $.fs.read(joinPath(root, 'CLAUDE.md')).catch(() => undefined)
290 return claude === undefined ? {} : { 'CLAUDE.md': claude }
291}
292
293const lineCount = (text: string) => text.trimEnd().split('\n').length
294
295// A step with Claude: the run goes on. One stopped on a failed check, waiting for the person, does not.
296const isRunning = (run: Run | null) => run !== null && STEPS.some(s => run.steps[s].status === 'sent')
297
298const clipped = (text: string) => (text.length > EDIT_LIMIT ? `${text.slice(0, EDIT_LIMIT)}\n[... ${text.length - EDIT_LIMIT} more characters]` : text)
299
300// Once a turn edited the files of a project set up, an agent reads the edits against the code:
301// one at a time, a run past its time taken as gone.
302async function startJudge($: EngineInterface) {
303 // A turn whose start was not seen (a reload under it) has nothing to compare with.
304 const snapshot = turnBefore
305 turnBefore = null
306 if (!snapshot) return
307 const turn = await snapshot
308 const root = await $.session.root()
309 const isProject = await isSetUp($)
310 const after = await watched($)
311 const changed = [...new Set([...Object.keys(turn), ...Object.keys(after)])].filter(rel => turn[rel] !== after[rel])
312 for (const rel of changed) if (!runBefore.has(rel)) runBefore.set(rel, turn[rel])
313 // Steps still to go write more of the files: read once the run has ended, not after each.
314 if (isRunning(await read($, runAtom))) return
315 const before = new Map(runBefore)
316 runBefore.clear()
317 // Elsewhere, only a CLAUDE.md that grew.
318 const edited = [...before.keys()]
319 .filter(rel => before.get(rel) !== after[rel])
320 .filter(rel => isProject || (rel === 'CLAUDE.md' && lineCount(after[rel] ?? '') > lineCount(before.get(rel) ?? '')))
321 .sort()
322 if (edited.length === 0) return
323 const at = await $.clock.now()
324 const was = await read($, judgeAtom)
325 if (was?.status === 'running' && at - was.at < JUDGE_STALE_MS) return
326 const w = await wordsIn($)
327 // What changed, line by line, rather than both sides whole: the agent reads the file for the rest.
328 const edits = edited
329 .map(rel => {
330 const [then, now] = [before.get(rel), after[rel]]
331 const head = `=== ${rel} ===${LIMITS[rel] !== undefined && now !== undefined ? ` (${lineCount(now)} lines of its ${LIMITS[rel]})` : ''}`
332 if (then === undefined) return `${head}\nWritten anew:\n<<<\n${clipped(now ?? '')}\n>>>`
333 if (now === undefined) return `${head}\nDeleted.`
334 const { added, removed } = diffLines(then, now)
335 const said = (list: string[], mark: string) => list.filter(l => l.trim() !== '').map(l => `${mark} ${l}`)
336 const [plus, minus] = [said(added, '+'), said(removed, '-')]
337 return [head, ...(plus.length > 0 ? ['Added:', clipped(plus.join('\n'))] : []), ...(minus.length > 0 ? ['Removed:', clipped(minus.join('\n'))] : [])].join('\n')
338 })
339 .join('\n\n')
340 const task = w.judgeTask
341 const spawned = await $.agent.spawn({ prompt: w.judgePrompt(root, edits), description: task, subagentType: 'Explore', model: JUDGE_MODEL }).catch(() => undefined)
342 const agentId =
343 spawned?.agentId ?? (spawned && !spawned.deny ? (await $.agent.list().catch(() => [])).findLast(a => a.spawnedBy === 'agent-lens' && a.description === task)?.id : undefined)
344 if (agentId) judges.add(agentId)
345 await update($, judgeAtom, (): Judge => ({ status: agentId ? 'running' : 'failed', at, findings: [], ...(agentId ? { agentId } : {}) }))
346}
347
348// A subagent's run ended: the docs agent's, its findings are read.
349export async function endSetupAgent($: EngineInterface, agentId: string, answer: string) {
350 const judge = await read($, judgeAtom)
351 if (judge?.status !== 'running' || judge.agentId !== agentId) return
352 const findings = parseFindings(answer)
353 await update($, judgeAtom, (): Judge => ({ ...judge, status: findings ? 'done' : 'failed', findings: findings ?? [] }))
354 if (findings && findings.some(f => !f.isMinor)) await $.ui.toast((await wordsIn($)).band(findings.filter(f => !f.isMinor).length))
355}
356
357// The docs agent's report the engine hands back as it ends is the pane's, not Claude's to read.
358export async function judgeReport($: EngineInterface, text: string) {
359 const kept = (await read($, judgeAtom))?.agentId
360 return [...judges, ...(kept ? [kept] : [])].some(id => text.includes(id)) ? (await wordsIn($)).judgeTitle : undefined
361}
362
363async function sendFindings($: EngineInterface) {
364 const judge = await read($, judgeAtom)
365 if (!judge || judge.status !== 'done' || judge.isSent || judge.findings.length === 0) return
366 await update($, judgeAtom, () => ({ ...judge, isSent: true as const }))
367 const r = await $.prompt.submit({ text: (await wordsIn($)).judgeFix(judge.findings), asUser: true })
368 if (r.drop !== undefined) await update($, judgeAtom, () => judge)
369}
370
371export async function registerSetup($: EngineInterface) {
372 await $.command.register({ name: 'setup-project', description: (await wordsIn($)).description, argumentHint: '[check]' })
373}
374
375// At the session's start, and after a /clear: the run of this project back, its files checked.
376export async function startSetup($: EngineInterface) {
377 await registerSetup($)
378 const root = await $.session.root()
379 const kept = (await $.store.get(runKey(root)).catch(() => undefined)) as Run | undefined
380 // A run kept by a version with fewer steps: the steps it has not, skipped.
381 const steps = kept && (Object.fromEntries(STEPS.map(s => [s, kept.steps[s] ?? { status: 'skipped' }])) as Run['steps'])
382 await update($, runAtom, () => (kept && steps ? { ...kept, steps } : null))
383 await checkProject($).catch(() => {})
384 void lookForDrift($).catch(() => {})
385}
386
387async function openPane($: EngineInterface) {
388 await $.ui.open({ id: PANE, title: (await wordsIn($)).title, focus: true, closeOnEscape: true })
389}
390
391export async function openSetup($: EngineInterface) {
392 await checkProject($).catch(() => {})
393 await openPane($)
394}
395
396// `/setup-project`: what it needs, said in a line; the run started, or the one under way shown.
397// `/setup-project check`: the check of the files.
398export async function setupCommand($: EngineInterface, args: string) {
399 const w = await wordsIn($)
400 const root = await $.session.root()
401 if (args.trim().toLowerCase() === 'check') {
402 drift = await driftIn($, root).catch(() => [])
403 const issues = (await checkProject($)) ?? [...(await issuesIn($, root)), ...drift]
404 return { text: w.checked(issues) }
405 }
406 const found = await preflight($, root)
407 if (found.missing.length > 0) return { text: w.noMatt(found.missing) }
408 if (found.openspec === null) return { text: w.noOpenspec }
409 let run = await read($, runAtom)
410 if (!run || STEPS.every(s => ['done', 'skipped'].includes(run!.steps[s].status))) {
411 const steps = {} as Record<StepId, StepState>
412 for (const s of STEPS) steps[s] = { status: (await isWritten($, root, s)) ? 'done' : 'waiting' }
413 run = { root, steps, current: null }
414 await keepRun($, run)
415 }
416 await openSetup($)
417 void advance($).catch(() => {})
418 return { text: `${w.ready(found.openspec, found.isInitialised)}\n${w.opened}` }
419}
420
421// Whether this project was prepared by /setup-project, or is being.
422export async function isSetUp($: EngineInterface) {
423 return (await read($, runAtom)) !== null || (await exists($, await $.session.root(), MARK))
424}
425
426// A change was archived: where Matt's retro is installed, the row offers it, the person's to run.
427export async function offerRetro($: EngineInterface, change: string) {
428 const command = (await $.command.list().catch(() => [])).find(c => c.name === `${MATT}:retro` || (c.plugin === MATT && c.name.endsWith('retro')))?.name
429 if (!command) return
430 await update($, retroAtom, () => ({ change, command }))
431 await $.ui.toast((await wordsIn($)).retroOffer(change))
432}
433
434// Matt's retro, run as if the person typed it, on the work of the change; or the offer let go.
435export async function runRetro($: EngineInterface) {
436 const retro = await read($, retroAtom)
437 if (!retro) return
438 await update($, retroAtom, () => null)
439 await $.command.run({ command: retro.command, args: (await wordsIn($)).retroArgs(retro.change) }).catch(async () => {
440 await update($, retroAtom, () => retro)
441 })
442}
443
444export async function dismissRetro($: EngineInterface) {
445 await update($, retroAtom, () => null)
446}
447
448export async function retroLine($: EngineInterface) {
449 const retro = await read($, retroAtom)
450 return retro ? (await wordsIn($)).retroOffer(retro.change) : null
451}
452
453// The row above the prompt: the files' issues and the docs agent's blockers not sent, while there are some.
454export async function setupLine($: EngineInterface) {
455 const issues = (await read($, issuesAtom)) ?? []
456 const judge = await read($, judgeAtom)
457 const blockers = judge?.status === 'done' && !judge.isSent ? judge.findings.filter(f => !f.isMinor).length : 0
458 const n = issues.length + blockers
459 return n > 0 ? (await wordsIn($)).band(n) : null
460}
461
462export async function pane($: Parameters<PaneHook>[0], e: Parameters<PaneHook>[1]) {
463 const { Box, Text, Button } = $.ui.resolve(e)
464 const w = await wordsIn($)
465 const run = await read($, runAtom)
466 const issues = (await read($, issuesAtom)) ?? []
467 const judge = await read($, judgeAtom)
468 const act = (fn: () => Promise<unknown>) => () => void fn().catch(() => {})
469 const keyed = (key: string, text: string, hotkey: string, onPress: () => void, isPrimary = false) => (
470 <Button key={key} label={text} hotkey={hotkey} {...(isPrimary ? { variant: 'primary' as const } : { dimColor: true as const })} onPress={onPress}>
471 <Text dimColor>{hotkey}</Text>
472 {` ${text}`}
473 </Button>
474 )
475 const icon: Record<StepState['status'], [string, 'success' | 'warning' | 'error' | undefined]> = {
476 waiting: ['○', undefined],
477 sent: ['◌', 'warning'],
478 done: ['✓', 'success'],
479 skipped: ['–', undefined],
480 failed: ['✗', 'error'],
481 }
482 const current = run?.current ?? null
483 const state = current ? run!.steps[current] : null
484 const isIdle = !state || state.status !== 'sent'
485 const hasWaiting = run ? STEPS.some(s => run.steps[s].status === 'waiting') : false
486 const skip = (step: StepId) => act(async () => {
487 const now = await read($, runAtom)
488 if (!now) return
489 await keepRun($, { ...setStep(now, step, { status: 'skipped' }), current: null })
490 await advance($)
491 })
492 const again = (step: StepId) => act(async () => {
493 const now = await read($, runAtom)
494 if (!now) return
495 await keepRun($, { ...setStep(now, step, { status: 'waiting' }), current: null })
496 await advance($)
497 })
498 const fix = (step: StepId) => act(async () => {
499 const now = await read($, runAtom)
500 if (!now) return
501 await sendFix($, now, step, ((await checkProject($)) ?? []).filter(i => CHECKED[step](i.file)))
502 })
503 return (
504 <Box flexDirection="column" gap={1}>
505 {run ? (
506 <Box flexDirection="column">
507 {STEPS.map((step, i) => {
508 const s = run.steps[step]
509 const [mark, color] = icon[s.status]
510 return (
511 <Box key={`step-${step}`} flexDirection="column">
512 <Box flexDirection="row" gap={1}>
513 <Text color={color}>{mark}</Text>
514 <Text bold={step === current}>{`${i + 1}. ${w.steps[step]}`}</Text>
515 <Text dimColor>{`· ${w.status[s.status]}${s.isFixSent && s.status === 'sent' ? ` · ${w.fixSent}` : ''}`}</Text>
516 </Box>
517 {s.error ? (
518 <Text color="error" wrap="wrap">
519 {` ${s.error}`}
520 </Text>
521 ) : null}
522 </Box>
523 )
524 })}
525 </Box>
526 ) : (
527 <Text dimColor>{w.notSetUp}</Text>
528 )}
529 {run ? (
530 <Box flexDirection="row" gap={1}>
531 {isIdle && hasWaiting ? keyed('setup-next', w.next, 'n', act(() => advance($)), true) : null}
532 {current && state?.status === 'failed' ? keyed('setup-fix', w.fix, 'f', fix(current), true) : null}
533 {current && state && state.status !== 'done' && state.status !== 'skipped' ? keyed('setup-again', w.again, 'a', again(current)) : null}
534 {current && state && state.status !== 'done' && state.status !== 'skipped' ? keyed('setup-skip', w.skip, 's', skip(current)) : null}
535 </Box>
536 ) : null}
537 {run && STEPS.every(s => ['done', 'skipped'].includes(run.steps[s].status)) ? (
538 <Box flexDirection="column">
539 <Text color="success">{`✓ ${w.allDone}`}</Text>
540 <Box flexDirection="row" gap={1}>
541 {keyed('setup-commit', w.commit, 'c', act(async () => $.prompt.submit({ text: w.commitPrompt, asUser: true })))}
542 <Text dimColor>{w.commitHint}</Text>
543 </Box>
544 </Box>
545 ) : null}
546 {judge ? (
547 <Box flexDirection="column">
548 <Box flexDirection="row" gap={1}>
549 <Text bold>{w.judgeTitle}</Text>
550 {judge.status === 'done' && judge.findings.length > 0 && !judge.isSent ? keyed('setup-judge-send', w.judgeSend(judge.findings.length), 'j', act(() => sendFindings($)), true) : null}
551 {judge.isSent ? <Text dimColor>{`→ ${w.judgeSent}`}</Text> : null}
552 </Box>
553 {judge.status === 'running' ? (
554 <Text color="warning">{`◌ ${w.judgeRunning}`}</Text>
555 ) : judge.status === 'failed' ? (
556 <Text color="error">{`✗ ${w.judgeFailed}`}</Text>
557 ) : judge.findings.length === 0 ? (
558 <Text color="success">{`✓ ${w.judgeNone}`}</Text>
559 ) : (
560 judge.findings.map((f: Finding, i) => (
561 <Box key={`finding-${i}`} flexDirection="column" paddingLeft={2}>
562 <Text color={f.isMinor ? undefined : 'warning'} wrap="wrap">{`• ${f.file}: ${f.text}${f.isMinor ? ` (${w.minor})` : ''}`}</Text>
563 {f.fix ? <Text dimColor wrap="wrap">{` → ${f.fix}`}</Text> : null}
564 </Box>
565 ))
566 )}
567 </Box>
568 ) : null}
569 <Box flexDirection="column">
570 <Text bold>{w.checkTitle}</Text>
571 {issues.length === 0 ? (
572 <Text color="success">{`✓ ${w.checkClean}`}</Text>
573 ) : (
574 issues.map((issue, i) => (
575 <Text key={`issue-${i}`} color="warning" wrap="wrap">
576 {`• ${w.issue(issue)}`}
577 </Text>
578 ))
579 )}
580 </Box>
581 </Box>
582 )
583}
584hooks/switch.ts 38 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Lang } from '../types'
4import { langIn, langOf, setLang } from './core/lang'
5import { registerCommand as registerReviewCommand } from './review/review'
6import { WORDS as REVIEW_WORDS } from './review/words'
7import { registerCommand as registerRouterCommand } from './router/state'
8import { WORDS as ROUTER_WORDS } from './router/words'
9import { registerSetup } from './setup/setup'
10import { forgetSaid, registerCommand as registerSpecCommand } from './spec/lens'
11import { WORDS as SPEC_WORDS } from './spec/words'
12
13// One language for every part: switched by any of the three commands or the router's pane, each
14// command is said again in it, and what the spec's model said in the other goes.
15export async function switchLang($: EngineInterface, lang: Lang) {
16 await setLang($, lang)
17 await registerReviewCommand($).catch(() => {})
18 await registerSpecCommand($).catch(() => {})
19 await registerRouterCommand($).catch(() => {})
20 await registerSetup($).catch(() => {})
21 await forgetSaid($).catch(() => {})
22}
23
24// `/changes lang ru`, `/spec lang ru`, `/router lang ru`; undefined for anything else the command
25// is given, which is the part's own to answer.
26export async function langCommand($: EngineInterface, command: 'changes' | 'spec' | 'router', args: string) {
27 const [verb = '', arg = ''] = args.trim().split(/\s+/)
28 if (verb.toLowerCase() !== 'lang') return undefined
29 const lang = langOf(arg.toLowerCase())
30 const words = (l: Lang) => (command === 'changes' ? REVIEW_WORDS[l] : command === 'spec' ? SPEC_WORDS[l] : ROUTER_WORDS[l])
31 if (lang === undefined) {
32 const now = words(await langIn($))
33 return { text: now.langUsage }
34 }
35 await switchLang($, lang)
36 return { text: words(lang).langSet }
37}
38hooks/spec/lens.tsx 1826 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, MatchedHook, PluginOptions, TurnCompleteReason } from 'claude-code'
3
4import type { Brief, Busy, Critic, CriticMode, Deciding, Failed, Hole, Lens, Overlap, Tab, Task, Verdict, TestCase } from '../../types/spec'
5import type { AbovePromptEvent, PaneHook } from '../core/hooks'
6import { langIn } from '../core/lang'
7import { sentWithSkill, specSkill, tddSkill } from '../router/catalog'
8import { isSetUp, offerRetro } from '../setup/setup'
9import {
10 annotateRequirements,
11 blocksArchive,
12 capabilityOf,
13 changesText,
14 criticPrint,
15 deltasOf,
16 fingerprintOf,
17 hasCase,
18 lf,
19 parseAdvice,
20 parseCovers,
21 parseIssues,
22 parseHoles,
23 parseOptions,
24 parseRequirements,
25 parseTaskList,
26 parseTestCases,
27 parseTasks,
28 parseTests,
29 requirementNames,
30 specText,
31 splitBrief,
32 taskKey,
33} from './spec'
34import type { Files } from './spec'
35import { WORDS } from './words'
36import type { Words } from './words'
37
38// The change in view and what was read of it; null where the project has no OpenSpec change.
39const lensAtom = atom({ plugin: 'agent-lens', key: 'spec.lens' } as const, null)
40// The brief of the change in view, in the language in force, as kept in the store.
41const briefAtom = atom({ plugin: 'agent-lens', key: 'spec.brief' } as const, null)
42const explainAtom = atom({ plugin: 'agent-lens', key: 'spec.explain' } as const, null)
43const answerAtom = atom({ plugin: 'agent-lens', key: 'spec.answer' } as const, null)
44const decidingAtom = atom({ plugin: 'agent-lens', key: 'spec.deciding' } as const, null)
45const refreshingAtom = atom({ plugin: 'agent-lens', key: 'spec.refreshing' } as const, null)
46const sentTasksAtom = atom({ plugin: 'agent-lens', key: 'spec.sentTasks' } as const, [] as string[])
47const sentCasesAtom = atom({ plugin: 'agent-lens', key: 'spec.sentCases' } as const, [] as string[])
48const pickedCasesAtom = atom({ plugin: 'agent-lens', key: 'spec.pickedCases' } as const, [] as string[])
49const checkedAtom = atom({ plugin: 'agent-lens', key: 'spec.checked' } as const, null)
50const archiveAtom = atom({ plugin: 'agent-lens', key: 'spec.archive' } as const, null)
51const busyAtom = atom({ plugin: 'agent-lens', key: 'spec.busy' } as const, null)
52const queuedAtom = atom({ plugin: 'agent-lens', key: 'spec.queued' } as const, [] as Busy[])
53const errorAtom = atom({ plugin: 'agent-lens', key: 'spec.error' } as const, null)
54const failedAtom = atom({ plugin: 'agent-lens', key: 'spec.failed' } as const, null)
55const tabAtom = atom({ plugin: 'agent-lens', key: 'spec.tab' } as const, 'brief')
56const criticAtom = atom({ plugin: 'agent-lens', key: 'spec.critic' } as const, {} as Record<string, Critic>)
57const criticModeAtom = atom({ plugin: 'agent-lens', key: 'spec.criticMode' } as const, null)
58
59export const PANE = 'agent-lens-spec'
60const TABS: readonly Tab[] = ['brief', 'changes', 'decide', 'tasks', 'tests', 'spec', 'ask']
61// A brief and an answer want reading, not speed: the session's mid-sized model.
62const MODEL = 'sonnet'
63const TIMEOUT_MS = 180_000
64// The most decided points of a change the next brief is told of.
65const DECIDED_KEPT = 40
66// The critic agent: how many times in a row what it finds goes to Claude to close, and how long one
67// that never answered is taken as still reading (a session that ended took it with it).
68const CRITIC_ROUNDS = 3
69const CRITIC_STALE_MS = 20 * 60_000
70
71// Store keys, per project folder and change: the change picked in the pane, the text read last
72// time (what changes are counted from), and the brief in each language.
73const pickedKey = (root: string) => `picked:${keyOf(root)}`
74const seenKey = (root: string, change: string) => `seen:${keyOf(root)}:${change}`
75const briefKey = (root: string, lang: string, change: string) => `brief:${lang}:${keyOf(root)}:${change}`
76// The points of a change sent to Claude decided, or for it to decide, the latest last.
77const decidedKey = (root: string, change: string) => `decided:${keyOf(root)}:${change}`
78// The critic agent's last look at a change, and the mode /spec critic set, for every project.
79const criticKey = (root: string, change: string) => `critic:${keyOf(root)}:${change}`
80const CRITIC_MODE_KEY = 'critic-mode'
81
82// The files of the change in view as last read: what the model is sent. Kept out of state, as
83// they can run to a few hundred kilobytes.
84let files: Files = {}
85// The scan running, and whether something asked for one meanwhile: it then reads again before it
86// resolves, so whoever waits on it gets what is on disk now.
87let scanning: Promise<void> | undefined
88let isScanStale = false
89// Whether a scan asked for meanwhile wants the test files read again, not only the change's.
90let isTestsStale = false
91// What the scans read that rarely changes between them: the test files the cases name, kept from
92// one full scan to the next (an edit to the spec does not touch them), and the last deltas, by the
93// version read and the version on disk.
94const testTexts = new Map<string, string | null>()
95let deltasHeld: { key: string; deltas: Lens['deltas'] } | undefined
96// The change and fingerprint `openspec validate` is running on, so a scan does not start it twice.
97let checking = ''
98// The pane's model calls run one at a time, so a question asked during the brief reads the change
99// from its cache: each waits its turn, and one asked again while it waits is the same call.
100let queue: Promise<void> = Promise.resolve()
101const waiting = new Map<string, Promise<void>>()
102let jobs = 0
103// The brief being made and the version it reads: asked for that version again, it is the same call.
104let briefing: { fingerprint: string; done: Promise<void> } | undefined
105
106// The critic's mode as /config sets it; /spec critic overrides it.
107let criticDefault: CriticMode = 'off'
108// Whether the main loop is in a turn: holes found meanwhile wait for its end to go to Claude, for
109// the change named here.
110let isMainTurn = false
111let holesWaiting: string | undefined
112// The critic agents this mod started, by id, as long as it runs.
113const critics = new Set<string>()
114// A critic being started: its record is written once the spawn resolves, and a press or a turn's
115// end meanwhile would start another for the same text. A press meanwhile is kept for after it, as
116// the start under way may be a turn's end that started none.
117let critiquing = false
118let isAskedAgain = false
119// What the pane's buttons sent and is on its way, by button: until the prompt is in the queue the
120// button is still there, so a press again is told so and sends nothing.
121const pressing = new Set<string>()
122
123async function pressOnce($: EngineInterface, key: string, fn: () => Promise<unknown>) {
124 if (pressing.has(key)) {
125 const w = await wordsIn($)
126 await $.ui.toast(key === 'critic' ? w.criticStarting : w.sentAlready)
127 return
128 }
129 pressing.add(key)
130 try {
131 await fn()
132 } finally {
133 pressing.delete(key)
134 }
135}
136
137const criticModeOf = (value: unknown): CriticMode | undefined => (value === 'off' || value === 'button' || value === 'auto' ? value : undefined)
138
139export function configureSpec(options: PluginOptions) {
140 criticDefault = criticModeOf(options.specCritic) ?? 'off'
141}
142
143async function wordsIn($: EngineInterface) {
144 return WORDS[await langIn($)]
145}
146
147const timeOf = (at: number, lang: string) => {
148 try {
149 return new Date(at).toLocaleString(lang === 'ru' ? 'ru-RU' : 'en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
150 } catch {
151 return new Date(at).toISOString().slice(0, 16).replace('T', ' ')
152 }
153}
154
155// A path under `dir`, joined with the separator `dir` itself uses (`C:\proj` on Windows, `/proj`
156// elsewhere); a part may hold `/` (`web/page`), and a root (`C:\`, `/`) stays one.
157const join = (dir: string, ...parts: string[]) =>
158 [dir.replace(/[\\/]+$/, ''), ...parts.flatMap(p => p.split(/[\\/]/))].join(dir.includes('\\') ? '\\' : '/')
159
160// A Windows path (a drive letter or a `\`) names the same folder in any case: keyed and compared
161// lower-cased there, as given elsewhere.
162const isWindowsPath = (path: string) => /^[A-Za-z]:|\\/.test(path)
163const keyOf = (path: string) => (isWindowsPath(path) ? path.toLowerCase() : path)
164
165// Every Markdown file under `dir`, by its path inside it, line ends as LF. A folder or file gone
166// while it is read is left out, not the whole read.
167async function readTree($: EngineInterface, dir: string, prefix = ''): Promise<Files> {
168 const out: Files = {}
169 for (const entry of await $.fs.list(dir).catch(() => [])) {
170 if (entry.kind === 'dir') Object.assign(out, await readTree($, join(dir, entry.name), `${prefix}${entry.name}/`))
171 else if (entry.kind === 'file' && entry.name.endsWith('.md')) {
172 const text = (await $.fs.read(join(dir, entry.name)).catch(() => undefined)) as string | undefined
173 if (text !== undefined) out[`${prefix}${entry.name}`] = lf(text)
174 }
175 }
176 return out
177}
178
179// The capabilities whose delta specs sit under `dir`, by their path below it.
180async function capabilitiesIn($: EngineInterface, dir: string, prefix = ''): Promise<string[]> {
181 const out: string[] = []
182 for (const entry of await $.fs.list(dir).catch(() => [])) {
183 if (entry.kind === 'dir') out.push(...(await capabilitiesIn($, join(dir, entry.name), `${prefix}${entry.name}/`)))
184 else if (entry.name === 'spec.md' && prefix !== '') out.push(prefix.slice(0, -1))
185 }
186 return out
187}
188
189// The other active changes whose specs touch a capability this one does, and the requirements
190// both name there: the ones that will collide on archiving.
191async function overlapsOf($: EngineInterface, dir: string, current: string, changes: readonly string[], mine: Lens['requirements']) {
192 const capabilities = new Set(mine.map(r => r.capability))
193 const out: Overlap[] = []
194 for (const other of changes) {
195 if (other === current) continue
196 for (const capability of await capabilitiesIn($, join(dir, other, 'specs'))) {
197 if (!capabilities.has(capability)) continue
198 const theirs = parseRequirements(capability, ((await $.fs.read(join(dir, other, 'specs', capability, 'spec.md')).catch(() => '')) as string) ?? '')
199 const names = new Set(theirs.map(r => r.name))
200 const shared = mine.filter(r => r.capability === capability && names.has(r.name)).map(r => r.name)
201 out.push({ change: other, capability, shared })
202 }
203 }
204 return out
205}
206
207// `openspec validate --strict` on the change in view, once per version of it; where the CLI does
208// not run, the result says so and nothing is held up by it.
209async function check($: EngineInterface, lens: Lens) {
210 const at = `${lens.root}:${lens.current}:${lens.fingerprint}`
211 const checked = await read($, checkedAtom)
212 if (checking === at || (checked?.change === lens.current && checked.fingerprint === lens.fingerprint)) return
213 checking = at
214 try {
215 const args = ['validate', lens.current, '--strict', '--json', '--no-interactive']
216 const init = { cwd: await $.session.root(), timeoutMs: 30_000 }
217 // npm installs the CLI on Windows as `openspec.cmd`, which a command run with no shell does not
218 // find by its bare name: tried by that name where the bare one did not start. A run cut short
219 // (a /clear's end of the session under it) says nothing of the CLI: the next scan runs it again.
220 const run = (cli: string) => $.process.run([cli, ...args], init)
221 const r = await run('openspec')
222 .catch((err: unknown) => (/time|abort/i.test(String(err)) ? Promise.reject(err) : run('openspec.cmd')))
223 .catch((err: unknown) => (/abort/i.test(String(err)) ? null : /time/i.test(String(err)) ? ('timeout' as const) : undefined))
224 if (r === null) return
225 // A run past its time says nothing of whether the CLI is there: said so, not as missing.
226 if (r === 'timeout') {
227 await update($, checkedAtom, () => ({ change: lens.current, fingerprint: lens.fingerprint, issues: null, isTimedOut: true as const }))
228 return
229 }
230 const issues = (r && parseIssues(r.stdout)) ?? null
231 await update($, checkedAtom, () => ({ change: lens.current, fingerprint: lens.fingerprint, issues }))
232 } finally {
233 if (checking === at) checking = ''
234 }
235}
236
237// Reads the project's OpenSpec changes from disk: the active ones, latest edited first, the one
238// in view (the one picked, else the latest) and its files. `isEdit`: an edit to the change asked
239// for it, and the test files are taken as last read.
240function scan($: EngineInterface, isEdit = false): Promise<void> {
241 if (!isEdit) isTestsStale = true
242 if (scanning) {
243 isScanStale = true
244 return scanning
245 }
246 scanning = (async () => {
247 do {
248 isScanStale = false
249 const isFresh = isTestsStale
250 isTestsStale = false
251 await scanOnce($, isFresh)
252 } while (isScanStale)
253 })().finally(() => {
254 scanning = undefined
255 })
256 return scanning
257}
258
259async function scanOnce($: EngineInterface, isTestsFresh: boolean) {
260 if (isTestsFresh) testTexts.clear()
261 const root = join(await $.session.root(), 'openspec')
262 const dir = join(root, 'changes')
263 const active: { name: string; at: number }[] = []
264 if (await $.fs.exists(dir).catch(() => false))
265 for (const entry of await $.fs.list(dir).catch(() => [])) {
266 if (entry.kind !== 'dir' || entry.name === 'archive') continue
267 // A change archived or removed while the folder is read is passed over.
268 const inside = await $.fs.list(join(dir, entry.name)).catch(() => undefined)
269 if (inside === undefined) continue
270 active.push({ name: entry.name, at: Math.max(entry.mtimeMs, ...inside.map(f => f.mtimeMs)) })
271 }
272 if (active.length === 0) {
273 files = {}
274 await update($, lensAtom, () => null)
275 return
276 }
277 const changes = active.sort((a, b) => b.at - a.at).map(c => c.name)
278 const picked = (await $.store.get(pickedKey(root)).catch(() => undefined)) as string | undefined
279 const current = picked !== undefined && changes.includes(picked) ? picked : changes[0]!
280 files = await readTree($, join(dir, current))
281 const seen = (await $.store.get(seenKey(root, current)).catch(() => undefined)) as Files | undefined
282 const fingerprint = fingerprintOf(files)
283 const deltas = Object.keys(files).flatMap(path => {
284 const capability = capabilityOf(path)
285 return capability === undefined ? [] : parseRequirements(capability, files[path]!)
286 })
287 // Each capability's main spec, by its requirements' names; null where `openspec/specs` has none.
288 const mains: Record<string, string[] | null> = {}
289 for (const capability of new Set(deltas.map(r => r.capability))) {
290 const main = join(root, 'specs', capability, 'spec.md')
291 mains[capability] = (await $.fs.exists(main).catch(() => false)) ? requirementNames(((await $.fs.read(main).catch(() => '')) as string) ?? '') : null
292 }
293 const requirements = annotateRequirements(deltas, mains, parseCovers(files['test-cases.md'] ?? ''))
294 const lens: Lens = {
295 root,
296 changes,
297 current,
298 tasks: parseTasks(files['tasks.md'] ?? ''),
299 taskList: parseTaskList(files['tasks.md'] ?? ''),
300 tests: parseTests(files['test-cases.md'] ?? ''),
301 testCases: await testCasesOf($, files['test-cases.md'] ?? ''),
302 requirements,
303 newCapabilities: Object.keys(mains).filter(c => mains[c] === null),
304 overlaps: await overlapsOf($, dir, current, changes, requirements),
305 fingerprint,
306 isSeen: seen !== undefined,
307 deltas: seen ? deltasHeldOf(seen, fingerprint) : [],
308 }
309 const lang = await langIn($)
310 const brief = (await $.store.get(briefKey(root, lang, current)).catch(() => undefined)) as Brief | undefined
311 await update($, lensAtom, () => lens)
312 await update($, briefAtom, () => brief ?? null)
313 await prunePicks($, lens)
314 await forgetArchived($, root, changes)
315 void check($, lens).catch(() => {})
316}
317
318// What changed since `seen`, by line: worked out once per pair of versions, as a scan on each edit
319// would otherwise diff every file again.
320function deltasHeldOf(seen: Files, fingerprint: string) {
321 const key = `${fingerprintOf(seen)}:${fingerprint}`
322 if (deltasHeld?.key !== key) deltasHeld = { key, deltas: deltasOf(seen, files) }
323 return deltasHeld.deltas
324}
325
326// Each test case and where it stands. Its test is looked for in the file its Automated line names,
327// under the project folder or a folder in it (`__front/e2e/…`), each file read once.
328async function testCasesOf($: EngineInterface, md: string): Promise<TestCase[]> {
329 const cases = parseTestCases(md)
330 if (cases.length === 0) return []
331 const isMarked = cases.some(c => c.mark !== 'none')
332 const project = await $.session.root()
333 const folders = (await $.fs.list(project).catch(() => []))
334 .filter(f => f.kind === 'dir' && !f.name.startsWith('.') && f.name !== 'node_modules')
335 .map(f => f.name)
336 const texts = testTexts
337 const out: TestCase[] = []
338 for (const c of cases) {
339 for (const file of c.files ?? []) {
340 if (texts.has(file)) continue
341 let text: string | null = null
342 for (const at of [join(project, file), ...folders.map(d => join(project, d, file))])
343 if (await $.fs.exists(at).catch(() => false)) {
344 text = ((await $.fs.read(at).catch(() => '')) as string) ?? ''
345 break
346 }
347 texts.set(file, text)
348 }
349 // Tested where any file its line names holds a test with its id.
350 const isTested = (c.files ?? []).some(file => {
351 const text = texts.get(file) ?? null
352 return text !== null && hasCase(text, c.id)
353 })
354 const state: TestCase['state'] = c.mark === 'manual' ? 'manual' : c.mark === 'none' && isMarked ? 'unmarked' : isTested ? 'tested' : 'untested'
355 out.push({ ...c, state })
356 }
357 return out
358}
359
360// A change archived or deleted leaves its read text and briefs behind: dropped, so the store
361// keeps to the active ones.
362async function forgetArchived($: EngineInterface, root: string, changes: readonly string[]) {
363 const at = keyOf(root)
364 for (const key of await $.store.keys().catch(() => [] as string[])) {
365 const kept = ['seen', 'decided', 'critic'].find(kind => key.startsWith(`${kind}:${at}:`))
366 const change = kept
367 ? key.slice(`${kept}:${at}:`.length)
368 : /^brief:\w+:/.test(key) && key.includes(`:${at}:`)
369 ? key.slice(key.indexOf(`:${at}:`) + at.length + 2)
370 : undefined
371 if (change !== undefined && !changes.includes(change)) await $.store.delete(key)
372 }
373}
374
375// Why a call found no answer, in words that say what to do about it.
376const whyNot = (w: Words, r: { reason: string; status?: number | null; error?: string }) =>
377 r.reason === 'empty-reply'
378 ? w.why.empty
379 : r.reason === 'aborted'
380 ? w.why.aborted
381 : r.error === 'rate_limit' || r.error === 'overloaded' || r.error === 'authentication_failed'
382 ? w.why[r.error]
383 : w.why.api(r.status ?? null)
384
385// An error shown above the tab, with the model call to run again where it was one.
386async function fail($: EngineInterface, text: string, failed: Failed | null = null) {
387 await update($, errorAtom, () => text)
388 await update($, failedAtom, () => failed)
389}
390
391async function clearError($: EngineInterface) {
392 await update($, errorAtom, () => null)
393 await update($, failedAtom, () => null)
394}
395
396// A call starting puts away its own last failure, and a refused prompt's, not another call's.
397async function clearErrorOf($: EngineInterface, job: Busy) {
398 const failed = await read($, failedAtom)
399 if (failed === null || failed.what === job) await clearError($)
400}
401
402// `fn` runs once the calls before it are done, shown as `job` meanwhile; `key` names the call, so
403// one asked for while the same waits is not run twice.
404function inTurn($: EngineInterface, job: Busy, key: string, fn: () => Promise<void>): Promise<void> {
405 const same = waiting.get(key)
406 if (same) return same
407 jobs++
408 const queued = update($, queuedAtom, q => [...q, job])
409 const run = queue.then(async () => {
410 waiting.delete(key)
411 try {
412 await queued
413 await update($, queuedAtom, q => {
414 const i = q.indexOf(job)
415 return i < 0 ? q : [...q.slice(0, i), ...q.slice(i + 1)]
416 })
417 await update($, busyAtom, () => job)
418 await clearErrorOf($, job)
419 await fn()
420 } finally {
421 jobs--
422 await update($, busyAtom, () => null)
423 }
424 })
425 waiting.set(key, run)
426 queue = run.catch(() => {})
427 return run
428}
429
430async function retry($: EngineInterface) {
431 const failed = await read($, failedAtom)
432 await clearError($)
433 if (failed?.what === 'brief') await makeBrief($)
434 else if (failed?.what === 'explain') await explainChanges($)
435 else if (failed?.what === 'options') await makeOptions($)
436 else if (failed?.what === 'answer' && failed.question !== undefined) await ask($, failed.question)
437}
438
439// The change in view with its files read. After a reload `files` starts empty while the session's
440// values still hold the change: it is read again before anything is sent to the model or kept as
441// read, so neither is made of nothing.
442async function lensNow($: EngineInterface) {
443 const lens = await read($, lensAtom)
444 if (lens && fingerprintOf(files) !== lens.fingerprint) await scan($)
445 return read($, lensAtom)
446}
447
448// The brief of the change as it is when its turn comes; asked for while one of that version is being
449// made, it is that one.
450async function makeBrief($: EngineInterface): Promise<void> {
451 const lens = await read($, lensAtom)
452 if (lens && briefing?.fingerprint === lens.fingerprint && !waiting.has('brief')) return briefing.done
453 const run: Promise<void> = inTurn($, 'brief', 'brief', async () => {
454 const now = await lensNow($)
455 if (!now) return
456 briefing = { fingerprint: now.fingerprint, done: run }
457 try {
458 await briefOf($, now)
459 } finally {
460 briefing = undefined
461 }
462 })
463 return run
464}
465
466type Decided = { point: string; answer: string }
467
468async function decidedOf($: EngineInterface, lens: Lens) {
469 return ((await $.store.get(decidedKey(lens.root, lens.current)).catch(() => undefined)) as Decided[] | undefined) ?? []
470}
471
472// Kept so a brief made again does not ask them again: a point said again goes in place of what it
473// said before, and only the latest are kept.
474async function keepDecided($: EngineInterface, lens: Lens, items: readonly Decided[]) {
475 const points = new Set(items.map(x => x.point))
476 const kept = [...(await decidedOf($, lens)).filter(x => !points.has(x.point)), ...items].slice(-DECIDED_KEPT)
477 await $.store.set(decidedKey(lens.root, lens.current), kept)
478}
479
480async function dropDecided($: EngineInterface, lens: Lens, points: readonly string[]) {
481 const kept = (await decidedOf($, lens)).filter(x => !points.includes(x.point))
482 await $.store.set(decidedKey(lens.root, lens.current), kept)
483}
484
485async function briefOf($: EngineInterface, lens: Lens) {
486 // Decisions sent for an older version are in the files the brief is made from now.
487 const refreshing = await read($, refreshingAtom)
488 if (refreshing?.change === lens.current && refreshing.fingerprint !== lens.fingerprint) await update($, refreshingAtom, () => null)
489 const w = await wordsIn($)
490 const lang = await langIn($)
491 const text = specText(lens.current, files)
492 const decided = await decidedOf($, lens)
493 const r = await $.model.complete({
494 model: MODEL,
495 system: w.system,
496 // The change first and marked, so a question asked soon after reads it from the cache.
497 prompt: [{ text, cache: true }, { text: [w.briefPrompt(lens.current), ...(decided.length > 0 ? [w.decidedPrompt(decided)] : [])].join('\n\n') }],
498 maxTokens: 3000,
499 timeoutMs: TIMEOUT_MS,
500 })
501 if (!r.isAnswered) {
502 await fail($, w.failed(whyNot(w, r)), { what: 'brief' })
503 return
504 }
505 const brief: Brief = { fingerprint: lens.fingerprint, ...splitBrief(r.text, w.decideHeading, w.none), at: await $.clock.now() }
506 await $.store.set(briefKey(lens.root, lang, lens.current), brief)
507 // The pane may have moved on to another change, or language, while the model read.
508 if ((await read($, lensAtom))?.current === lens.current && (await langIn($)) === lang) await update($, briefAtom, () => brief)
509}
510
511function explainChanges($: EngineInterface) {
512 return inTurn($, 'explain', 'explain', async () => {
513 const lens = await lensNow($)
514 if (!lens || lens.deltas.length === 0) return
515 const seen = (await $.store.get(seenKey(lens.root, lens.current)).catch(() => undefined)) as Files | undefined
516 if (!seen) return
517 const w = await wordsIn($)
518 const r = await $.model.complete({
519 model: MODEL,
520 system: w.system,
521 prompt: `${w.explainPrompt(lens.current)}\n\n${changesText(seen, files)}`,
522 maxTokens: 1500,
523 timeoutMs: TIMEOUT_MS,
524 })
525 if (r.isAnswered) await update($, explainAtom, () => r.text)
526 else await fail($, w.failed(whyNot(w, r)), { what: 'explain' })
527 })
528}
529
530// The Decide tab's work on `brief`: what was kept for it, else a fresh start.
531async function decidingOf($: EngineInterface, brief: Brief): Promise<Deciding> {
532 const deciding = await read($, decidingAtom)
533 return deciding?.at === brief.at ? deciding : { at: brief.at, open: null, verdicts: {} }
534}
535
536async function changeDeciding($: EngineInterface, fn: (d: Deciding, brief: Brief) => Deciding) {
537 const brief = await read($, briefAtom)
538 if (!brief) return
539 const deciding = fn(await decidingOf($, brief), brief)
540 await update($, decidingAtom, () => deciding)
541}
542
543// The model's options for every decision at once, asked for when the first one is opened; asked
544// again once for a brief kept from before the model said which it recommends.
545function makeOptions($: EngineInterface) {
546 return inTurn($, 'options', 'options', async () => {
547 const lens = await lensNow($)
548 const brief = await read($, briefAtom)
549 if (lens && brief && (!brief.options || !brief.advice) && brief.decisions.length > 0) await optionsOf($, lens, brief)
550 })
551}
552
553async function optionsOf($: EngineInterface, lens: Lens, brief: Brief) {
554 const w = await wordsIn($)
555 const lang = await langIn($)
556 const r = await $.model.complete({
557 model: MODEL,
558 system: w.system,
559 prompt: [{ text: specText(lens.current, files), cache: true }, { text: w.optionsPrompt(brief.decisions) }],
560 maxTokens: 1500,
561 timeoutMs: TIMEOUT_MS,
562 })
563 if (!r.isAnswered) {
564 await fail($, w.failed(whyNot(w, r)), { what: 'options' })
565 return
566 }
567 const withOptions: Brief = { ...brief, options: parseOptions(r.text, brief.decisions.length), advice: parseAdvice(r.text, brief.decisions.length) }
568 await $.store.set(briefKey(lens.root, lang, lens.current), withOptions)
569 if ((await read($, briefAtom))?.at === brief.at) await update($, briefAtom, () => withOptions)
570}
571
572async function toggleDecision($: EngineInterface, index: number) {
573 await changeDeciding($, d => ({ ...d, open: d.open === index ? null : index }))
574 if ((await read($, decidingAtom))?.open === index) await makeOptions($)
575}
576
577// A decision settled: kept until it is sent, and the next one still open is opened.
578async function settle($: EngineInterface, index: number, answer: string) {
579 const a = answer.trim()
580 if (a === '') return
581 await changeDeciding($, (d, brief) => {
582 const verdicts: Record<string, Verdict> = { ...d.verdicts, [index]: { answer: a, isSent: false } }
583 const next = brief.decisions.findIndex((_, i) => i > index && !verdicts[i])
584 return { ...d, open: next < 0 ? null : next, verdicts }
585 })
586}
587
588async function unsettle($: EngineInterface, index: number) {
589 await changeDeciding($, d => {
590 const { [index]: _dropped, ...verdicts } = d.verdicts
591 return { ...d, verdicts }
592 })
593}
594
595// The decisions settled and not yet sent go to the session as the person's prompt: Claude writes
596// them into the change's files, which makes the brief stale, and the next one is made from those.
597// Every point not decided yet goes to the session for Claude to decide: it writes each answer
598// into the change's files, or asks where it cannot decide alone, and the brief is made again from
599// those. The points show as Claude's to decide until then.
600async function decideAll($: EngineInterface) {
601 const lens = await read($, lensAtom)
602 const brief = await read($, briefAtom)
603 if (!lens || !brief) return
604 const w = await wordsIn($)
605 const verdicts = (await decidingOf($, brief)).verdicts
606 const open = brief.decisions.flatMap((point, i) => (verdicts[i] ? [] : [{ i, point }]))
607 if (open.length === 0) return
608 const r = await $.prompt.submit({ text: w.decideAllPrompt(lens.current, join(lens.root, 'changes', lens.current), open.map(x => x.point)), asUser: true })
609 if (r.drop !== undefined) {
610 await fail($, w.notSent(r.drop))
611 return
612 }
613 await update($, refreshingAtom, () => ({ change: lens.current, fingerprint: lens.fingerprint }))
614 await keepDecided($, lens, open.map(x => ({ point: x.point, answer: w.byClaude })))
615 await changeDeciding($, d => ({
616 ...d,
617 open: null,
618 verdicts: { ...d.verdicts, ...Object.fromEntries(open.map(x => [String(x.i), { answer: w.byClaude, isSent: true, isByClaude: true }])) },
619 }))
620 await $.ui.toast(w.decideAllSent(open.length))
621}
622
623// The decisions sent go to Claude again as they were: those settled here to write in, the rest to
624// decide.
625async function resendDecisions($: EngineInterface) {
626 const lens = await read($, lensAtom)
627 const brief = await read($, briefAtom)
628 if (!lens || !brief) return
629 const w = await wordsIn($)
630 const verdicts = (await decidingOf($, brief)).verdicts
631 const sent = brief.decisions.flatMap((point, i) => (verdicts[i]?.isSent ? [{ point, ...verdicts[i] }] : []))
632 if (sent.length === 0) return
633 const dir = join(lens.root, 'changes', lens.current)
634 const settled = sent.filter(x => !x.isByClaude)
635 const left = sent.filter(x => x.isByClaude).map(x => x.point)
636 const text = [...(settled.length > 0 ? [w.applyPrompt(lens.current, dir, settled)] : []), ...(left.length > 0 ? [w.decideAllPrompt(lens.current, dir, left)] : [])].join('\n\n')
637 const r = await $.prompt.submit({ text, asUser: true })
638 if (r.drop !== undefined) {
639 await fail($, w.notSent(r.drop))
640 return
641 }
642 await update($, refreshingAtom, () => ({ change: lens.current, fingerprint: lens.fingerprint }))
643 await $.ui.toast(w.resent(sent.length))
644}
645
646// The decisions sent come back to the pane: a point left to Claude is open again, an answer settled
647// here waits to be written in, and either can be changed.
648async function reopenDecisions($: EngineInterface) {
649 await update($, refreshingAtom, () => null)
650 const lens = await read($, lensAtom)
651 const brief = await read($, briefAtom)
652 if (lens && brief) {
653 const verdicts = (await decidingOf($, brief)).verdicts
654 await dropDecided($, lens, brief.decisions.filter((_, i) => verdicts[i]?.isSent))
655 }
656 await changeDeciding($, d => ({
657 ...d,
658 verdicts: Object.fromEntries(Object.entries(d.verdicts).flatMap(([i, v]) => (v.isByClaude ? [] : [[i, { ...v, isSent: false }]]))),
659 }))
660}
661
662async function applyDecisions($: EngineInterface) {
663 const lens = await read($, lensAtom)
664 const brief = await read($, briefAtom)
665 if (!lens || !brief) return
666 const w = await wordsIn($)
667 const deciding = await decidingOf($, brief)
668 const ready = brief.decisions.flatMap((point, i) => {
669 const v = deciding.verdicts[i]
670 return v && !v.isSent ? [{ i, point, answer: v.answer }] : []
671 })
672 if (ready.length === 0) return
673 const r = await $.prompt.submit({ text: w.applyPrompt(lens.current, join(lens.root, 'changes', lens.current), ready), asUser: true })
674 if (r.drop !== undefined) {
675 await fail($, w.notSent(r.drop))
676 return
677 }
678 await update($, refreshingAtom, () => ({ change: lens.current, fingerprint: lens.fingerprint }))
679 await keepDecided($, lens, ready.map(x => ({ point: x.point, answer: x.answer })))
680 const sent = new Set(ready.map(x => String(x.i)))
681 await changeDeciding($, d => ({
682 ...d,
683 open: null,
684 verdicts: Object.fromEntries(Object.entries(d.verdicts).map(([i, v]) => [i, sent.has(i) ? { ...v, isSent: true } : v])),
685 }))
686 await $.ui.toast(w.applied(ready.length))
687}
688
689// A task or an archiving goes to Claude naming OpenSpec's own skill for it, where the router has one.
690// A task names Matt's `tdd` too, where it is installed; in a project /setup-project prepared, an
691// archiving first moves what the change settled into the glossary and the ADRs.
692async function send($: EngineInterface, kind: 'task' | 'archive', text: string) {
693 const skill = await specSkill($, kind).catch(() => undefined)
694 const w = await wordsIn($)
695 const tdd = kind === 'task' ? await tddSkill($).catch(() => undefined) : undefined
696 const docs = kind === 'archive' && (await isSetUp($).catch(() => false)) ? w.archiveDocs : undefined
697 const lines = [text, ...(docs ? [docs] : []), ...(tdd ? [w.withTdd(tdd)] : []), ...(skill ? [w.withSkill(skill)] : [])]
698 const r = await $.prompt.submit({ text: lines.join('\n\n'), asUser: true })
699 if (skill && r.drop === undefined) await sentWithSkill($, skill, text).catch(() => {})
700 return r
701}
702
703// One task goes to the session as the person's prompt: Claude does it and ticks it.
704async function doTask($: EngineInterface, task: Task) {
705 const lens = await read($, lensAtom)
706 if (!lens || task.isDone) return
707 const w = await wordsIn($)
708 const key = taskKey(task)
709 const r = await send($, 'task', w.taskPrompt(lens.current, join(lens.root, 'changes', lens.current), key, task.text))
710 if (r.drop !== undefined) {
711 await fail($, w.notSent(r.drop))
712 return
713 }
714 await update($, sentTasksAtom, sent => [...new Set([...sent, `${lens.current}:${key}`])])
715 await $.ui.toast(w.taskSentToast(key))
716}
717
718const caseKey = (lens: Lens, id: string) => `${lens.current}:${id}`
719
720// The scenarios of the change's specs no test case covers, each with an id among the cases': `S:`
721// then its capability, requirement and name.
722const uncoveredOf = (lens: Lens) =>
723 lens.requirements.flatMap(r =>
724 (r.uncovered ?? []).map(scenario => ({ id: `S:${r.capability}|${r.name}|${scenario}`, capability: r.capability, requirement: r.name, scenario })),
725 )
726
727// What can be picked on the Tests tab: a case with no test yet, a scenario no case covers.
728const pickableOf = (lens: Lens) => new Set([...(lens.testCases ?? []).filter(c => c.state !== 'tested').map(c => c.id), ...uncoveredOf(lens).map(s => s.id)])
729
730// A pick whose case got its test, or whose scenario got a case, is no longer one.
731async function prunePicks($: EngineInterface, lens: Lens) {
732 const pickable = pickableOf(lens)
733 const at = `${lens.current}:`
734 const isKept = (k: string) => !k.startsWith(at) || pickable.has(k.slice(at.length))
735 if ((await read($, pickedCasesAtom)).some(k => !isKept(k))) await update($, pickedCasesAtom, picked => picked.filter(isKept))
736}
737
738async function togglePick($: EngineInterface, id: string) {
739 const lens = await read($, lensAtom)
740 if (!lens) return
741 const key = caseKey(lens, id)
742 await update($, pickedCasesAtom, picked => (picked.includes(key) ? picked.filter(k => k !== key) : [...picked, key]))
743}
744
745// The test cases go to the session as the person's prompt: Claude writes their tests, runs them
746// and marks what is unmarked; a scenario no case covers gets a case first, closed the same way.
747async function closeCases($: EngineInterface, ids: readonly string[]) {
748 const lens = await read($, lensAtom)
749 if (!lens) return
750 const cases = (lens.testCases ?? []).filter(c => ids.includes(c.id))
751 const scenarios = uncoveredOf(lens).filter(s => ids.includes(s.id))
752 if (cases.length + scenarios.length === 0) return
753 const w = await wordsIn($)
754 const file = join(lens.root, 'changes', lens.current, 'test-cases.md')
755 const text = [
756 ...(cases.length > 0 ? [w.closePrompt(lens.current, file, cases)] : []),
757 ...(scenarios.length > 0 ? [w.coverPrompt(lens.current, file, scenarios, cases.length === 0)] : []),
758 ].join('\n\n')
759 const r = await $.prompt.submit({ text, asUser: true })
760 if (r.drop !== undefined) {
761 await fail($, w.notSent(r.drop))
762 return
763 }
764 const keys = [...cases, ...scenarios].map(c => caseKey(lens, c.id))
765 await update($, sentCasesAtom, sent => [...new Set([...sent, ...keys])])
766 await update($, pickedCasesAtom, picked => picked.filter(k => !keys.includes(k)))
767 await $.ui.toast(w.casesSentToast(keys.length))
768}
769
770// What stands between the change and its archive, once every task is ticked: errors and refused
771// deltas `openspec validate` found, what is left to decide or not yet written in, test cases with
772// no mark. `isOffered` false while tasks are left: there is nothing to archive yet.
773async function readiness($: EngineInterface, lens: Lens) {
774 const w = await wordsIn($)
775 const isOffered = lens.tasks.total > 0 && lens.tasks.done === lens.tasks.total
776 const blockers: string[] = []
777 const checked = await read($, checkedAtom)
778 const isChecked = checked?.change === lens.current && checked.fingerprint === lens.fingerprint
779 const issues = isChecked ? (checked.issues ?? []) : []
780 const errors = issues.filter(i => i.level === 'ERROR').length
781 if (!isChecked) blockers.push(w.blockChecking)
782 if (errors > 0) blockers.push(w.blockErrors(errors))
783 if (issues.some(i => i.level !== 'ERROR' && blocksArchive(i))) blockers.push(w.blockRefused)
784 const brief = await read($, briefAtom)
785 if (brief && brief.fingerprint === lens.fingerprint && brief.isUnsplit) blockers.push(w.blockDecideUnknown)
786 else if (brief && brief.fingerprint === lens.fingerprint) {
787 const verdicts = (await decidingOf($, brief)).verdicts
788 const undecided = brief.decisions.filter((_, i) => !verdicts[i]).length
789 const toApply = Object.values(verdicts).filter(v => !v.isSent).length
790 if (undecided > 0) blockers.push(w.blockDecide(undecided))
791 if (toApply > 0) blockers.push(w.blockApply(toApply))
792 } else blockers.push(w.blockBrief)
793 if (lens.tests.unmarked.length > 0) blockers.push(w.blockUnmarked(lens.tests.unmarked))
794 const untested = untestedIn(lens)
795 if (untested > 0) blockers.push(w.blockUntested(untested))
796 const withoutTest = withoutTestIn(lens)
797 if (withoutTest > 0) blockers.push(w.blockWithoutTest(withoutTest))
798 const mismatches = mismatchesIn(lens)
799 if (mismatches > 0) blockers.push(w.blockMismatch(mismatches))
800 const archive = await read($, archiveAtom)
801 return { isOffered, blockers, isReady: isOffered && blockers.length === 0, archive: archive?.change === lens.current ? archive : null }
802}
803
804const untestedIn = (lens: Lens) => lens.requirements.reduce((n, r) => n + (r.uncovered?.length ?? 0), 0)
805const withoutTestIn = (lens: Lens) => (lens.testCases ?? []).filter(c => c.state === 'untested').length
806const mismatchesIn = (lens: Lens) => lens.requirements.filter(r => r.mismatch).length
807
808async function askArchive($: EngineInterface, isAsked: boolean) {
809 const lens = await read($, lensAtom)
810 if (lens) await update($, archiveAtom, () => (isAsked ? { change: lens.current, isSent: false } : null))
811}
812
813// Archiving goes to Claude as the person's prompt: the project's own way of it, where it has one.
814async function archiveChange($: EngineInterface) {
815 const lens = await read($, lensAtom)
816 if (!lens || !(await readiness($, lens)).isReady) return
817 const w = await wordsIn($)
818 const r = await send($, 'archive', w.archivePrompt(lens.current, join(lens.root, 'changes', lens.current)))
819 if (r.drop !== undefined) {
820 await fail($, w.notSent(r.drop))
821 return
822 }
823 await update($, archiveAtom, () => ({ change: lens.current, isSent: true }))
824 await $.ui.toast(w.archiveSent)
825}
826
827// The archiving sent, once its change's folder is gone: the person is offered a retro of the work.
828async function afterArchive($: EngineInterface) {
829 const archive = await read($, archiveAtom)
830 if (!archive?.isSent) return
831 const root = await $.session.root()
832 if (await $.fs.exists(join(root, 'openspec', 'changes', archive.change)).catch(() => true)) return
833 await update($, archiveAtom, () => null)
834 await offerRetro($, archive.change)
835}
836
837async function criticMode($: EngineInterface) {
838 return (await read($, criticModeAtom)) ?? criticDefault
839}
840
841// The critic's last look at `change`: this session's, else the one kept from an earlier session.
842async function criticOf($: EngineInterface, root: string, change: string) {
843 return (await read($, criticAtom))[change] ?? ((await $.store.get(criticKey(root, change)).catch(() => undefined)) as Critic | undefined)
844}
845
846async function keepCritic($: EngineInterface, root: string, critic: Critic) {
847 await update($, criticAtom, all => ({ ...all, [critic.change]: critic }))
848 await $.store.set(criticKey(root, critic.change), critic)
849}
850
851// The critic agent reads the change in view against the code for holes: when asked, or in auto mode
852// at a main turn's end once the change's text moved on from what it read last. Rounds whose holes
853// went to Claude count up while Claude's turns close them. Auto mode stops at a look that finds no
854// blocker, the change ready, or once CRITIC_ROUNDS went, what the last changed left to the person:
855// either way only the person asking again starts it over.
856async function critique($: EngineInterface, isAsked: boolean) {
857 if (critiquing) {
858 isAskedAgain ||= isAsked
859 return
860 }
861 critiquing = true
862 try {
863 await startCritic($, isAsked)
864 while (isAskedAgain) {
865 isAskedAgain = false
866 await startCritic($, true)
867 }
868 } finally {
869 critiquing = false
870 }
871}
872
873async function startCritic($: EngineInterface, isAsked: boolean) {
874 const lens = await lensNow($)
875 if (!lens) return
876 const mode = await criticMode($)
877 if (mode === 'off' || (!isAsked && mode !== 'auto')) return
878 const was = await criticOf($, lens.root, lens.current)
879 const at = await $.clock.now()
880 if (was?.status === 'running' && at - was.at < CRITIC_STALE_MS) return
881 const fingerprint = criticPrint(files)
882 if (!isAsked && (was?.fingerprint === fingerprint || (was && (isReady(was) || was.isCapped)))) return
883 if (!isAsked && was && was.round >= CRITIC_ROUNDS) {
884 await keepCritic($, lens.root, { ...was, fingerprint, isCapped: true })
885 return
886 }
887 const round = isAsked ? 0 : (was?.round ?? 0)
888 const w = await wordsIn($)
889 const dir = join(lens.root, 'changes', lens.current)
890 const task = w.criticTask(lens.current)
891 const spawned = await $.agent.spawn({ prompt: w.criticPrompt(lens.current, dir, await decidedOf($, lens)), description: task, subagentType: 'Explore', model: MODEL }).catch(() => undefined)
892 // A spawn a hook answered carries no id: the agent is found in the list, as this mod's run of the task.
893 const agentId =
894 spawned?.agentId ?? (spawned && !spawned.deny ? (await $.agent.list().catch(() => [])).findLast(a => a.spawnedBy === 'agent-lens' && a.description === task)?.id : undefined)
895 if (agentId) critics.add(agentId)
896 // What the rounds before sent stays in view while they go on.
897 const sent = isAsked ? [] : (was?.sent ?? [])
898 await keepCritic($, lens.root, { change: lens.current, fingerprint, at, holes: [], round, status: agentId ? 'running' : 'failed', ...(agentId ? { agentId } : {}), ...(sent.length > 0 ? { sent } : {}) })
899}
900
901// What the critic agent answered, once its run ends: its holes, sent to Claude to close in auto mode.
902async function heardCritic($: EngineInterface, agentId: string, answer: string) {
903 const lens = await read($, lensAtom)
904 const critic = Object.values(await read($, criticAtom)).find(c => c.status === 'running' && c.agentId === agentId)
905 if (!lens || !critic) return
906 const holes = parseHoles(answer)
907 // Its id stays: the engine hands its report back after this end, and it is known by the id.
908 await keepCritic($, lens.root, { ...critic, status: holes ? 'done' : 'failed', holes: holes ?? [] })
909 if (holes?.some(hole => !hole.isMinor) && (await criticMode($)) === 'auto') await closeHoles($, critic.change, false)
910}
911
912// A look that found nothing that blocks the work: the change is ready to be built.
913const isReady = (critic: Critic) => critic.status === 'done' && !critic.holes.some(hole => !hole.isMinor)
914
915// The holes of the last look still to go to Claude: the blockers, and the details the code can settle.
916const holesLeft = (critic: Critic | undefined) => {
917 const left = critic?.status === 'done' ? critic.holes.filter(hole => !hole.isSent) : []
918 return { blockers: left.filter(hole => !hole.isMinor), minors: left.filter(hole => hole.isMinor) }
919}
920
921// The holes found go to the session as the person's prompt: Claude closes, asks about or declines
922// each in the change's files. Kept as decided, so neither the brief nor the critic raises them again.
923// In auto mode the blockers alone go; the person's h sends the details as well. While the main loop
924// is in a turn they wait for its end.
925async function closeHoles($: EngineInterface, change: string, isAll: boolean) {
926 if (isMainTurn) {
927 holesWaiting = change
928 return
929 }
930 const lens = await read($, lensAtom)
931 const critic = (await read($, criticAtom))[change]
932 const { blockers, minors } = holesLeft(critic)
933 const holes = isAll ? [...blockers, ...minors] : blockers
934 if (!lens || !critic || holes.length === 0) return
935 const w = await wordsIn($)
936 // Kept sent before the prompt goes: it waits in the queue while a turn runs, and a critic that
937 // turn's end starts reads the round from this, not has its own record written over after.
938 const round = critic.round + 1
939 const going = new Set(holes)
940 const marked = critic.holes.map(hole => (going.has(hole) ? { ...hole, isSent: true as const } : hole))
941 await keepCritic($, lens.root, { ...critic, holes: marked, round, isSent: true, sent: [...(critic.sent ?? []), { round, holes }] })
942 const r = await $.prompt.submit({ text: w.closeHolesPrompt(change, join(lens.root, 'changes', change), holes), asUser: true })
943 if (r.drop !== undefined) {
944 // Taken back, unless a critic started since.
945 const now = (await read($, criticAtom))[change]
946 if (now?.isSent && now.round === round) await keepCritic($, lens.root, critic)
947 await fail($, w.notSent(r.drop))
948 return
949 }
950 await keepDecided($, { ...lens, current: change }, holes.map(hole => ({ point: `${hole.text} (${hole.where})`, answer: hole.fix })))
951 await $.ui.toast(w.holesToast(holes.length))
952}
953
954// After a turn the row follows what agents ticked and rewrote; a spec that moved on from the
955// decisions sent gets its brief made again, so the Decide tab shows what is left. One still where
956// it was says so, and the decisions can be sent again or changed. Only a turn the model answered
957// sends the holes waiting or starts a critic: one the person stopped, or an error ended, leaves the
958// holes waiting for the next.
959async function afterTurn($: EngineInterface, isAnswered: boolean) {
960 await scan($)
961 await afterArchive($).catch(() => {})
962 if (isAnswered) {
963 const waiting = holesWaiting
964 holesWaiting = undefined
965 if (waiting !== undefined) await closeHoles($, waiting, false)
966 else await critique($, false)
967 }
968 const lens = await read($, lensAtom)
969 const refreshing = await read($, refreshingAtom)
970 if (!lens || !refreshing || refreshing.change !== lens.current) return
971 if (refreshing.fingerprint === lens.fingerprint) await update($, refreshingAtom, r => (r === null ? r : { ...r, isUnchanged: true }))
972 else await makeBrief($)
973}
974
975// The change in view as the review reads it beside a diff: its name, the tasks not ticked yet,
976// and what its specs require, each requirement with its scenarios.
977export async function specInView($: EngineInterface) {
978 const lens = await read($, lensAtom)
979 if (!lens) return undefined
980 return {
981 change: lens.current,
982 tasks: lens.taskList.filter(t => !t.isDone).map(t => (t.id ? `${t.id} ${t.text}` : t.text)),
983 requirements: lens.requirements.map(r => ({ name: `${r.capability}: ${r.name}`, op: r.op, scenarios: r.scenarios })),
984 }
985}
986
987// What is on disk now becomes what later changes are counted from.
988async function markRead($: EngineInterface) {
989 const lens = await lensNow($)
990 if (!lens) return
991 await $.store.set(seenKey(lens.root, lens.current), files)
992 await update($, explainAtom, () => null)
993 await update($, lensAtom, () => ({ ...lens, isSeen: true, deltas: [] }))
994}
995
996async function ask($: EngineInterface, question: string) {
997 const q = question.trim()
998 if (q === '') return
999 await inTurn($, 'answer', `answer:${q}`, async () => {
1000 const lens = await lensNow($)
1001 if (!lens) return
1002 const w = await wordsIn($)
1003 await update($, answerAtom, () => null)
1004 const r = await $.model.complete({
1005 model: MODEL,
1006 system: w.system,
1007 prompt: [{ text: specText(lens.current, files), cache: true }, { text: w.askPrompt(q) }],
1008 maxTokens: 1500,
1009 timeoutMs: TIMEOUT_MS,
1010 })
1011 if (r.isAnswered) await update($, answerAtom, () => ({ question: q, text: r.text }))
1012 else await fail($, w.failed(whyNot(w, r)), { what: 'answer', question: q })
1013 })
1014}
1015
1016// Opens the pane on the change in view. A change opened here for the first time is taken as read
1017// from now on; a brief missing or older than the change is made, which is what opening asks for.
1018export async function openPane($: EngineInterface, tab?: Tab) {
1019 await scan($)
1020 const lens = await read($, lensAtom)
1021 if (!lens) return false
1022 if (tab) await update($, tabAtom, () => tab)
1023 if (!lens.isSeen) await markRead($)
1024 await $.ui.open({ id: PANE, title: (await wordsIn($)).title(lens.current), focus: true, closeOnEscape: true })
1025 if ((await read($, briefAtom))?.fingerprint !== lens.fingerprint) void makeBrief($).catch(() => {})
1026 return true
1027}
1028
1029async function pickChange($: EngineInterface, change: string) {
1030 const lens = await read($, lensAtom)
1031 if (!lens || change === lens.current) return
1032 await $.store.set(pickedKey(lens.root), change)
1033 await update($, explainAtom, () => null)
1034 await update($, answerAtom, () => null)
1035 await clearError($)
1036 await openPane($)
1037}
1038
1039// An edit to the change is read beside the turn: the tool call does not wait on the scan.
1040function afterEdit($: EngineInterface, path: string) {
1041 if (new RegExp(String.raw`[\\/]openspec[\\/]changes[\\/]`, isWindowsPath(path) ? 'i' : '').test(path)) void scan($, true).catch(() => {})
1042}
1043
1044// The colour of what is said: red is broken, yellow wants a look, green is done.
1045type Tone = 'error' | 'warning' | 'success'
1046const SEP = ' · '
1047// Cells of the Tasks tab's progress bar.
1048const BAR = 10
1049
1050// What the row and the pane say of the change in view, and its colour as a whole: red while a test
1051// case says neither how it is automated nor why it is manual or the spec has errors; yellow while
1052// something is left to decide or the spec changed since it was read; green otherwise.
1053async function statusOf($: EngineInterface, lens: Lens) {
1054 const brief = await read($, briefAtom)
1055 const isFresh = brief !== null && brief.fingerprint === lens.fingerprint
1056 const verdicts = isFresh ? Object.values((await decidingOf($, brief)).verdicts) : []
1057 const decisions = isFresh ? brief.decisions.length - verdicts.length : 0
1058 const toApply = verdicts.filter(v => !v.isSent).length
1059 const checked = await read($, checkedAtom)
1060 const issues = checked?.change === lens.current && checked.fingerprint === lens.fingerprint ? (checked.issues ?? []) : []
1061 const errors = issues.filter(i => i.level === 'ERROR').length
1062 const warnings = issues.filter(i => i.level === 'WARNING').length
1063 const overlapping = [...new Set(lens.overlaps.map(o => o.change))]
1064 const untested = untestedIn(lens)
1065 const withoutTest = withoutTestIn(lens)
1066 const mismatches = mismatchesIn(lens)
1067 const isDecideUnknown = isFresh && brief.isUnsplit === true
1068 const ready = await readiness($, lens)
1069 const tone: Tone =
1070 lens.tests.unmarked.length > 0 || errors > 0
1071 ? 'error'
1072 : decisions > 0 || toApply > 0 || warnings > 0 || untested > 0 || withoutTest > 0 || mismatches > 0 || isDecideUnknown || overlapping.length > 0 || lens.deltas.length > 0
1073 ? 'warning'
1074 : 'success'
1075 return { decisions, toApply, errors, warnings, overlapping, untested, withoutTest, mismatches, isDecideUnknown, isReady: ready.isReady && !ready.archive?.isSent, tone }
1076}
1077
1078export async function registerCommand($: EngineInterface) {
1079 await $.command.register({ name: 'spec', description: (await wordsIn($)).description, argumentHint: '[<change> | critic off|button|auto | lang en|ru]' })
1080}
1081
1082// What the model said in another language is not this one's: the explanation and the answer go,
1083// the brief of the change is read in the new one.
1084export async function forgetSaid($: EngineInterface) {
1085 await update($, explainAtom, () => null)
1086 await update($, answerAtom, () => null)
1087 await scan($)
1088}
1089
1090export async function startSpec($: EngineInterface, isInteractive: boolean) {
1091 // A reload drops the calls the old code was making, while the session's values still show them.
1092 if (jobs === 0) {
1093 await update($, busyAtom, () => null)
1094 await update($, queuedAtom, () => [])
1095 }
1096 await registerCommand($)
1097 await restoreCriticMode($)
1098 if (isInteractive) await scan($).catch(() => {})
1099}
1100
1101async function restoreCriticMode($: EngineInterface) {
1102 const mode = criticModeOf(await $.store.get(CRITIC_MODE_KEY).catch(() => undefined))
1103 await update($, criticModeAtom, () => mode ?? null)
1104}
1105
1106// A /clear or /resume empties the session's values: the change in view is read again, so the row
1107// is back before the first prompt.
1108export async function rescanSpec($: EngineInterface) {
1109 await restoreCriticMode($)
1110 await scan($).catch(() => {})
1111}
1112
1113// Agents tick tasks and rewrite the spec as they go: the row follows them.
1114export function startSpecTurn() {
1115 isMainTurn = true
1116}
1117
1118export function endSpecTurn($: EngineInterface, reason: TurnCompleteReason) {
1119 isMainTurn = false
1120 void afterTurn($, reason === 'answer').catch(() => {})
1121}
1122
1123// What a critic hands back to the main conversation as its run ends: the Decide tab holds it, and
1124// the holes go to Claude by the mod's own prompt, in auto mode or on h; read by Claude as well, they
1125// would be closed twice and the button would hold nothing back. By the agent's id in the text: the
1126// ids this module started, those the engine lists as this mod's, and each change's last critic.
1127// What the person is told it went to, or undefined for any other text.
1128export async function criticReport($: EngineInterface, text: string) {
1129 const listed = (await $.agent.list().catch(() => [])).filter(a => a.spawnedBy === 'agent-lens').map(a => a.id)
1130 const kept = Object.values(await read($, criticAtom)).flatMap(c => (c.agentId ? [c.agentId] : []))
1131 return [...critics, ...listed, ...kept].some(id => text.includes(id)) ? (await wordsIn($)).criticReportKept : undefined
1132}
1133
1134// A subagent's run ended: the critic's, its answer is read.
1135export function endSpecAgent($: EngineInterface, agentId: string, answer: string) {
1136 void heardCritic($, agentId, answer).catch(() => {})
1137}
1138
1139export function afterSpecEdit($: EngineInterface, path: string) {
1140 afterEdit($, path)
1141}
1142
1143export async function command($: Parameters<MatchedHook<'command.run', { command: 'spec' }>>[0], e: Parameters<MatchedHook<'command.run', { command: 'spec' }>>[1]) {
1144 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
1145 const w = await wordsIn($)
1146 await scan($)
1147 const lens = await read($, lensAtom)
1148 if (!lens) return { text: w.noSpec }
1149 if (verb === 'critic' && !lens.changes.includes(verb)) {
1150 const mode = criticModeOf(arg.toLowerCase())
1151 if (!mode) return { text: w.criticUsage(await criticMode($)) }
1152 await update($, criticModeAtom, () => mode)
1153 await $.store.set(CRITIC_MODE_KEY, mode)
1154 return { text: w.criticSet(mode) }
1155 }
1156 if (verb !== '' && !lens.changes.includes(verb)) return { text: w.unknownChange(verb, lens.changes) }
1157 if (verb !== '') await pickChange($, verb)
1158 await openPane($, 'brief')
1159 return { text: w.opened((await read($, lensAtom))?.current ?? lens.current) }
1160}
1161
1162// The change in view as the row above the prompt says it, in `bodyColumns` less the `taken` the rest
1163// of the row holds: what wants a look, red before yellow, then its tasks and test cases, each part
1164// opening the pane on the tab that tells more of it. A narrow row drops progress before it drops a
1165// problem, and says how many parts it dropped.
1166export async function specLine($: EngineInterface, e: AbovePromptEvent, taken: number) {
1167 const lens = await read($, lensAtom)
1168 if (!lens) return null
1169 const { Text, Button } = $.ui.resolve(e)
1170 const w = await wordsIn($)
1171 const s = await statusOf($, lens)
1172 const { tasks, tests } = lens
1173 const parts: { key: string; text: string; color?: Tone; tab: Tab }[] = []
1174 const add = (when: boolean, key: string, text: () => string, tab: Tab, color?: Tone) => {
1175 if (when) parts.push({ key, text: text(), tab, ...(color ? { color } : {}) })
1176 }
1177 add(tests.unmarked.length > 0, 'unmarked', () => w.unmarked(tests.unmarked), 'tests', 'error')
1178 add(s.errors > 0, 'errors', () => w.specErrors(s.errors), 'spec', 'error')
1179 add(s.decisions > 0, 'decide', () => w.toDecide(s.decisions), 'decide', 'warning')
1180 add(s.isDecideUnknown, 'decide-unknown', () => w.decideUnknown, 'decide', 'warning')
1181 add(s.toApply > 0, 'apply', () => w.toApply(s.toApply), 'decide', 'warning')
1182 const critic = (await read($, criticAtom))[lens.current]
1183 add(critic?.status === 'running', 'critic', () => w.criticBusy, 'decide', 'warning')
1184 const { blockers } = holesLeft(critic)
1185 add(blockers.length > 0, 'holes', () => w.holes(blockers.length), 'decide', 'warning')
1186 add(lens.deltas.length > 0, 'changed', () => w.changed, 'changes', 'warning')
1187 add(s.warnings > 0, 'warnings', () => w.specWarnings(s.warnings), 'spec', 'warning')
1188 add(s.untested > 0, 'untested', () => w.untested(s.untested), 'tests', 'warning')
1189 add(s.withoutTest > 0, 'nocase', () => w.withoutTest(s.withoutTest), 'tests', 'warning')
1190 add(s.mismatches > 0, 'mismatch', () => w.mismatches(s.mismatches), 'spec', 'warning')
1191 add(s.overlapping.length > 0, 'overlaps', () => w.overlapsWith(s.overlapping), 'spec', 'warning')
1192 add((await read($, busyAtom)) === 'brief', 'busy', () => w.makingBrief, 'brief', 'warning')
1193 add(s.isReady, 'ready', () => w.readyToArchive, 'tasks', 'success')
1194 add(tasks.total > 0, 'tasks', () => w.tasks(tasks), 'tasks', tasks.total > 0 && tasks.done === tasks.total ? 'success' : undefined)
1195 add(tests.cases > 0, 'tests', () => w.tests(tests), 'tests')
1196
1197 // Cells are counted as characters: the row's words and marks are one cell each.
1198 const room = e.props.bodyColumns - `◆ ${lens.current}`.length - taken
1199 const widthOf = (n: number) => parts.slice(0, n).reduce((sum, p) => sum + SEP.length + p.text.length, 0)
1200 const moreOf = (n: number) => (n < parts.length ? SEP.length + w.more(parts.length - n).length : 0)hooks/guard/words.ts 72 lines1import type { Lang } from '../../types'
2import type { Stuck } from '../../types/guard'
3
4// Russian plural: 1 шаг, 2 шага, 5 шагов.
5const plural = (n: number, one: string, few: string, many: string) => {
6 const ten = n % 10
7 const hundred = n % 100
8 return ten === 1 && hundred !== 11 ? one : ten >= 2 && ten <= 4 && (hundred < 12 || hundred > 14) ? few : many
9}
10
11// What the guard tells the agent it watches, the person, and /changes guard, in English and Russian.
12const EN = {
13 // To the agent: a call refused as a loop, the note past the soft limit, the stop at the hard one.
14 stuck: (why: Stuck, n: number): string =>
15 `agent-lens refused this call: ${
16 why === 'repeat'
17 ? `it is the same call with the same input, ${n} times in a row, and its result did not change`
18 : why === 'error'
19 ? `it is the same call that failed ${n} times in a row`
20 : 'you are going back and forth between the same two calls, and their results do not change'
21 }. Repeating it will not help. Try another way, or stop and say what is in the way.`,
22 soft: (minutes: number, steps: number, hardMinutes: number, hardSteps: number): string =>
23 `agent-lens: you have been on this for ${minutes} min and ${steps} tool calls. If the end is not near, stop now and report what is done, what is left and what is in the way, instead of going on. At ${hardMinutes} min or ${hardSteps} calls every tool is refused.`,
24 hard: (minutes: number, steps: number): string =>
25 `agent-lens refused this call: the work has run ${minutes} min and ${steps} tool calls, past the limit. Call no more tools. Answer now: what is done, what is left, and what is in the way.`,
26 // To the person.
27 softToast: (who: string, minutes: number, steps: number): string => `${who} has run ${minutes} min · ${steps} steps: told to wrap up.`,
28 hardToast: (who: string, minutes: number, steps: number): string => `${who} stopped at ${minutes} min · ${steps} steps: it reports what it did.`,
29 hardTitle: 'agent-lens stopped an agent',
30 main: 'Claude',
31 // The row above the prompt.
32 running: (who: string, minutes: number, steps: number): string => `⏱ ${who} · ${minutes} min · ${steps} steps`,
33 stopped: (who: string, minutes: number, steps: number): string => `■ ${who} stopped · ${minutes} min · ${steps} steps`,
34 // /changes guard.
35 status: (isOn: boolean, minutes: number, steps: number): string =>
36 isOn
37 ? `The guard is on. Past ${Math.round(minutes / 2)} min or ${Math.round(steps / 2)} tool calls in one turn, an agent is told to wrap up; past ${minutes} min or ${steps}, every tool is refused and it reports what it did. A call made a fourth time in a row with the same result, at any time, is refused.`
38 : 'The guard is off: no agent is held to a limit.',
39 loops: (lines: string[]): string => (lines.length > 0 ? `\nRunning now:\n${lines.map(l => ` ${l}`).join('\n')}` : ''),
40 usage: 'Usage: /changes guard on, /changes guard off, /changes guard <minutes> <steps> (the hard limit; the soft one is half)',
41}
42
43const RU: typeof EN = {
44 stuck: (why, n) =>
45 `agent-lens отклонил этот вызов: ${
46 why === 'repeat'
47 ? `это тот же вызов с тем же входом ${n} раза подряд, и результат не меняется`
48 : why === 'error'
49 ? `этот же вызов упал ${n} раза подряд`
50 : 'ты ходишь туда-обратно между одними и теми же двумя вызовами, и их результаты не меняются'
51 }. Повтор не поможет. Попробуй по-другому или остановись и скажи, что мешает.`,
52 soft: (minutes, steps, hardMinutes, hardSteps) =>
53 `agent-lens: ты на этом ${minutes} мин и ${steps} ${plural(steps, 'вызов', 'вызова', 'вызовов')} инструментов. Если до конца далеко, остановись сейчас и доложи, что сделано, что осталось и что мешает, а не продолжай. На ${hardMinutes} мин или ${hardSteps} вызовах любой инструмент будет отклонён.`,
54 hard: (minutes, steps) =>
55 `agent-lens отклонил этот вызов: работа идёт ${minutes} мин и ${steps} ${plural(steps, 'вызов', 'вызова', 'вызовов')} инструментов, это за пределом. Больше не вызывай инструменты. Ответь сейчас: что сделано, что осталось и что мешает.`,
56 softToast: (who, minutes, steps) => `${who} работает ${minutes} мин · ${steps} ${plural(steps, 'шаг', 'шага', 'шагов')}: попросил закругляться.`,
57 hardToast: (who, minutes, steps) => `${who} остановлен на ${minutes} мин · ${steps} ${plural(steps, 'шаге', 'шагах', 'шагах')}: пишет отчёт о сделанном.`,
58 hardTitle: 'agent-lens остановил агента',
59 main: 'Claude',
60 running: (who, minutes, steps) => `⏱ ${who} · ${minutes} мин · ${steps} ${plural(steps, 'шаг', 'шага', 'шагов')}`,
61 stopped: (who, minutes, steps) => `■ ${who} остановлен · ${minutes} мин · ${steps} ${plural(steps, 'шаг', 'шага', 'шагов')}`,
62 status: (isOn, minutes, steps) =>
63 isOn
64 ? `Сторож включён. Если агент за один ход работает дольше ${Math.round(minutes / 2)} мин или ${Math.round(steps / 2)} вызовов инструментов, его просят закругляться; дольше ${minutes} мин или ${steps} — любой инструмент отклоняется, и агент пишет отчёт о сделанном. Вызов, который в четвёртый раз подряд даёт тот же результат, отклоняется всегда.`
65 : 'Сторож выключен: ни один агент не ограничен.',
66 loops: lines => (lines.length > 0 ? `\nСейчас работают:\n${lines.map(l => ` ${l}`).join('\n')}` : ''),
67 usage: 'Как вызвать: /changes guard on, /changes guard off, /changes guard <минуты> <шаги> (жёсткий предел; мягкий — половина)',
68}
69
70export type Words = typeof EN
71export const WORDS: Record<Lang, Words> = { en: EN, ru: RU }
72hooks/core/hooks.ts 7 lines1import type { MatchedHook } from 'claude-code'
2
3// The row above the prompt and a pane, as a part draws its own.
4export type AbovePromptHook = MatchedHook<'ui.render', { component: 'AbovePrompt' }>
5export type AbovePromptEvent = Parameters<AbovePromptHook>[1]
6export type PaneHook = MatchedHook<'ui.render', { component: 'Pane'; requestId: string }>
7hooks/review/diff.ts 401 lines1import type { FileDiff, Finding, Hunk, Line, ShellRun, Where } from '../../types/review'
2
3// FNV-1a over a text: hunk ids and fingerprints.
4export const hash = (text: string) => {
5 let h = 0x811c9dc5
6 for (const ch of text) {
7 h ^= ch.codePointAt(0)!
8 h = Math.imul(h, 0x01000193) >>> 0
9 }
10 return h.toString(16).padStart(8, '0')
11}
12
13// A file's lines, CRLF read as LF: a file Windows wrote compares, and shows, line for line.
14const splitLines = (text: string) => (text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/))
15
16// Paths as the engine and git spell them: `/` or `\` between names, a drive (`C:\`) or `/` at the
17// root. A path with a drive or a backslash is Windows's: its names compare case-folded.
18const SEP = /[\\/]/
19const isWindows = (path: string) => /^[A-Za-z]:/.test(path) || path.includes('\\')
20const sepOf = (path: string) => (isWindows(path) ? '\\' : '/')
21const isRoot = (path: string) => path === '/' || /^[A-Z]:\\$/.test(path)
22const fold = (path: string) => (isWindows(path) ? path.toLowerCase() : path)
23
24// One spelling of a path, so a file is one key whoever named it: on Windows `\` between names and
25// the drive letter upper-cased (git says `d:/proj` where the engine says `D:\proj`); no separator
26// at the end but a root's.
27export const canonical = (path: string) => {
28 const p = isWindows(path) ? path.replace(/\//g, '\\').replace(/^[a-z]:/, d => d.toUpperCase()) : path
29 if (isRoot(p)) return p
30 const trimmed = p.replace(/[\\/]+$/, '')
31 return /^[A-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed || p
32}
33
34export const samePath = (a: string, b: string) => fold(canonical(a)) === fold(canonical(b))
35
36// `path` below `dir`, its names joined by `/` as git and the model name them; undefined for a path
37// not inside it, `dir` itself included.
38export const below = (path: string, dir: string) => {
39 const p = canonical(path)
40 const d = canonical(dir)
41 const head = isRoot(d) ? d : `${d}${sepOf(d)}`
42 return p.length > head.length && fold(p).startsWith(fold(head)) ? p.slice(head.length).split(SEP).join('/') : undefined
43}
44
45// The folder `path` is in; a root's is itself.
46export const parentOf = (path: string) => {
47 const p = canonical(path)
48 const cut = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
49 if (isRoot(p) || cut < 0) return p
50 const dir = p.slice(0, cut)
51 return dir === '' ? '/' : /^[A-Z]:$/.test(dir) ? `${dir}\\` : dir
52}
53
54// The last name in a path; a root's is the root.
55export const nameOf = (path: string) => canonical(path).split(SEP).filter(Boolean).at(-1) ?? path
56
57// `rel`, its names joined by `/` as git prints them, under `dir`, by the separator `dir` uses.
58export const joinPath = (dir: string, rel: string) => {
59 const d = canonical(dir)
60 return canonical(`${isRoot(d) ? d : `${d}${sepOf(d)}`}${rel.split(SEP).join(sepOf(d))}`)
61}
62
63// The edit script from `a` to `b`, line by line: common ends trimmed first, as an edit usually
64// touches a few lines of a long file; the middle by longest common subsequence, or replaced whole
65// past a few million cells.
66const editScript = (a: string[], b: string[]): Line[] => {
67 let start = 0
68 while (start < a.length && start < b.length && a[start] === b[start]) start++
69 let endA = a.length
70 let endB = b.length
71 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
72 endA--
73 endB--
74 }
75 const head = a.slice(0, start).map(text => ({ kind: ' ' as const, text }))
76 const tail = a.slice(endA).map(text => ({ kind: ' ' as const, text }))
77 const x = a.slice(start, endA)
78 const y = b.slice(start, endB)
79 const middle: Line[] = []
80 if (x.length * y.length > 4_000_000) {
81 middle.push(...x.map(text => ({ kind: '-' as const, text })), ...y.map(text => ({ kind: '+' as const, text })))
82 } else {
83 const n = x.length
84 const m = y.length
85 const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
86 for (let i = n - 1; i >= 0; i--)
87 for (let j = m - 1; j >= 0; j--) lcs[i]![j] = x[i] === y[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
88 let i = 0
89 let j = 0
90 while (i < n && j < m) {
91 if (x[i] === y[j]) {
92 middle.push({ kind: ' ', text: x[i]! })
93 i++
94 j++
95 } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) middle.push({ kind: '-', text: x[i++]! })
96 else middle.push({ kind: '+', text: y[j++]! })
97 }
98 while (i < n) middle.push({ kind: '-', text: x[i++]! })
99 while (j < m) middle.push({ kind: '+', text: y[j++]! })
100 }
101 return [...head, ...middle, ...tail]
102}
103
104// The hunks of an edit script, `context` lines kept around each run of changes; runs closer than
105// twice that share a hunk, as `git diff` does.
106const hunksOf = (path: string, script: Line[], context: number): Hunk[] => {
107 const hunks: Hunk[] = []
108 const changed = script.map((l, i) => (l.kind === ' ' ? -1 : i)).filter(i => i >= 0)
109 if (changed.length === 0) return hunks
110 let from = 0
111 while (from < changed.length) {
112 let to = from
113 while (to + 1 < changed.length && changed[to + 1]! - changed[to]! <= 2 * context + 1) to++
114 const first = Math.max(0, changed[from]! - context)
115 const last = Math.min(script.length - 1, changed[to]! + context)
116 let oldStart = 1
117 let newStart = 1
118 for (let k = 0; k < first; k++) {
119 if (script[k]!.kind !== '+') oldStart++
120 if (script[k]!.kind !== '-') newStart++
121 }
122 const lines = script.slice(first, last + 1)
123 hunks.push({ id: hash(`${path}\0${lines.map(l => l.kind + l.text).join('\n')}`), oldStart, newStart, lines })
124 from = to + 1
125 }
126 return hunks
127}
128
129const count = (hunks: Hunk[], kind: '+' | '-') => hunks.reduce((n, h) => n + h.lines.filter(l => l.kind === kind).length, 0)
130
131// What changed in `path` from `before` to `after`; null for a file that was not there.
132export const fileDiff = (path: string, where: Where, before: string | null, after: string | null, context = 3): FileDiff => {
133 const hunks = hunksOf(path, editScript(splitLines(before ?? ''), splitLines(after ?? '')), context)
134 return { path, ...where, isNew: before === null && after !== null, isDeleted: before !== null && after === null, added: count(hunks, '+'), removed: count(hunks, '-'), hunks }
135}
136
137// A diff is kept in the session's values, and a value has a size it is refused past: a file's hunks
138// are kept up to so many lines and characters, the files of one diff up to so many in all, and from
139// the first line past that on, the rest is counted, not kept.
140const FILE_ROOM = { lines: 2_000, chars: 400_000 }
141const DIFF_ROOM = { lines: 20_000, chars: 2_000_000 }
142
143export const capped = (files: readonly FileDiff[]): FileDiff[] => {
144 const diff = { ...DIFF_ROOM }
145 return files.map(f => {
146 const file = { ...FILE_ROOM }
147 let isFull = false
148 let cut = 0
149 const hunks: Hunk[] = []
150 for (const h of f.hunks) {
151 let kept = 0
152 for (const l of h.lines) {
153 const fits = !isFull && file.lines > 0 && diff.lines > 0 && l.text.length <= Math.min(file.chars, diff.chars)
154 if (!fits) {
155 isFull = true
156 break
157 }
158 kept++
159 file.lines--
160 diff.lines--
161 file.chars -= l.text.length
162 diff.chars -= l.text.length
163 }
164 cut += h.lines.length - kept
165 if (kept === h.lines.length) hunks.push(h)
166 else if (kept > 0) hunks.push({ ...h, lines: h.lines.slice(0, kept), cut: h.lines.length - kept })
167 }
168 return cut > 0 ? { ...f, hunks, cut } : f
169 })
170}
171
172const ESCAPES: Readonly<Record<string, string>> = { a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r' }
173
174// A path as `git diff` prints it after `--- ` and `+++ `: a tab after one with a space in it, and
175// one with a quote, a backslash or a control character in C quotes; the rest as it is, by
176// core.quotePath=false.
177const diffPath = (text: string) => {
178 const p = text.replace(/\t$/, '')
179 if (!/^".*"$/s.test(p)) return p
180 return p.slice(1, -1).replace(/\\([0-7]{3}|.)/gs, (_, e: string) => (/^[0-7]{3}$/.test(e) ? String.fromCharCode(parseInt(e, 8)) : (ESCAPES[e] ?? e)))
181}
182
183// The files of `git diff` output, by their path in the repository; paths are made absolute by
184// `repo`, with its separator, and `whereOf` says where each is shown.
185export const parseUnified = (text: string, repo: string, whereOf: (path: string) => Where): FileDiff[] => {
186 const files: FileDiff[] = []
187 let file: FileDiff | undefined
188 let hunk: Hunk | undefined
189 let oldPath = ''
190 for (const line of text.split(/\r?\n/)) {
191 if (line.startsWith('diff --git ')) {
192 file = undefined
193 hunk = undefined
194 continue
195 }
196 if (line.startsWith('--- ')) {
197 oldPath = diffPath(line.slice(4))
198 continue
199 }
200 if (line.startsWith('+++ ')) {
201 const newPath = diffPath(line.slice(4))
202 const isDeleted = newPath === '/dev/null'
203 const path = joinPath(repo, (isDeleted ? oldPath : newPath).replace(/^[ab]\//, ''))
204 file = { path, ...whereOf(path), isNew: oldPath === '/dev/null', isDeleted, added: 0, removed: 0, hunks: [] }
205 files.push(file)
206 continue
207 }
208 const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
209 if (header && file) {
210 hunk = { id: '', oldStart: Number(header[1]), newStart: Number(header[2]), lines: [] }
211 file.hunks.push(hunk)
212 continue
213 }
214 if (!hunk || !file || line.startsWith('\\')) continue
215 const kind = line[0]
216 if (kind === '+' || kind === '-' || kind === ' ') {
217 hunk.lines.push({ kind, text: line.slice(1) })
218 if (kind === '+') file.added++
219 if (kind === '-') file.removed++
220 }
221 }
222 for (const f of files) for (const h of f.hunks) h.id = hash(`${f.path}\0${h.lines.map(l => l.kind + l.text).join('\n')}`)
223 return files.filter(f => f.hunks.length > 0)
224}
225
226// Where `path` is shown, given the project folder and the repository it is in (null: none). A
227// repository inside the project is named by its path from it, the project's own by nothing, one
228// outside by its folder's name; outside any, the file's folder from the project, or its own name.
229// Each named by `/`, on Windows too: as the model reads them and git takes them.
230export const whereIn = (path: string, root: string, repo: string | null): Where => {
231 const top = repo ?? (below(path, root) !== undefined ? root : parentOf(path))
232 const group = samePath(top, root) ? '' : (below(top, root) ?? nameOf(top))
233 const rel = below(path, top) ?? path
234 return { group, rel, shown: group === '' ? rel : `${group}/${rel}` }
235}
236
237// Files as kept by an earlier version of the mod, which named them by `shown` alone: each given
238// where it is shown from its path. The session's values outlive a reload of the mod's code.
239export const withWhere = (files: readonly FileDiff[], root: string): FileDiff[] =>
240 files.map(f => (typeof f.rel === 'string' && typeof f.group === 'string' ? f : { ...f, ...whereIn(f.path, root, null) }))
241
242// A removed line and the added one that took its place, split around what changed between them:
243// the common start and end, and each one's middle.
244export const changedSpan = (was: string, now: string) => {
245 let start = 0
246 while (start < was.length && start < now.length && was[start] === now[start]) start++
247 let end = 0
248 while (end < was.length - start && end < now.length - start && was[was.length - 1 - end] === now[now.length - 1 - end]) end++
249 return { head: was.slice(0, start), was: was.slice(start, was.length - end), now: now.slice(start, now.length - end), tail: was.slice(was.length - end) }
250}
251
252// Every hunk id, in order: changes with any line of the diff.
253export const fingerprintOf = (files: readonly FileDiff[]) => hash(files.flatMap(f => f.hunks.map(h => h.id)).join(','))
254
255const hunkText = (h: Hunk) => `@@ -${h.oldStart} +${h.newStart} @@\n${h.lines.map(l => `${l.kind}${l.text}`).join('\n')}`
256
257// The diff for the model to read, each hunk under its id, cut at `limit` characters.
258export const diffText = (files: readonly FileDiff[], limit = 80_000) => {
259 let text = ''
260 for (const f of files) {
261 text += `=== ${f.shown}${f.isNew ? ' (new file)' : f.isDeleted ? ' (deleted)' : ''} ===\n`
262 for (const h of f.hunks) text += `[hunk ${h.id}]\n${hunkText(h)}\n`
263 if (f.cut) text += `… ${f.cut} more lines left out\n`
264 text += '\n'
265 if (text.length > limit) return `${text.slice(0, limit)}\n… (cut)`
266 }
267 return text
268}
269
270// Lines `from` to `to` of a file, each after its number.
271const numbered = (lines: readonly string[], from: number, to: number) => {
272 const width = String(lines.length).length
273 return lines
274 .slice(from, to)
275 .map((l, k) => `${String(from + k + 1).padStart(width)} ${l}`)
276 .join('\n')
277}
278
279// Whether a file still holds each hunk's new lines where the hunk says: one edited since the diff
280// was taken would number its lines otherwise.
281const holds = (lines: readonly string[], hunks: readonly Hunk[]) =>
282 hunks.every(h => h.lines.filter(l => l.kind !== '-').every((l, k) => lines[h.newStart - 1 + k] === l.text))
283
284// A changed file as `text` holds it now, each line numbered: whole while within `limit`, lines and
285// characters both, else the lines around each hunk, `around` on either side; undefined where it no
286// longer matches the diff.
287export const fileText = (f: FileDiff, text: string, limit: { lines: number; chars: number }, around = 40) => {
288 const lines = splitLines(text)
289 if (!holds(lines, f.hunks)) return undefined
290 if (lines.length <= limit.lines && text.length <= limit.chars) return numbered(lines, 0, lines.length)
291 const spans: [number, number][] = []
292 for (const h of f.hunks) {
293 const from = Math.max(0, h.newStart - 1 - around)
294 const to = Math.min(lines.length, h.newStart - 1 + h.lines.filter(l => l.kind !== '-').length + around)
295 const last = spans.at(-1)
296 if (last && from <= last[1]) last[1] = Math.max(last[1], to)
297 else spans.push([from, to])
298 }
299 return spans.map(([from, to]) => numbered(lines, from, to)).join('\n…\n')
300}
301
302// The changed files for the model to read the diff in, each under its shown path, by `texts` (from
303// `fileText`, by path; a file with none is left out), cut at `limit` characters.
304export const filesText = (files: readonly FileDiff[], texts: Readonly<Record<string, string>>, limit: number) => {
305 let text = ''
306 for (const f of files) {
307 const body = texts[f.path]
308 if (body === undefined) continue
309 text += `=== ${f.shown} ===\n${body}\n\n`
310 if (text.length > limit) return `${text.slice(0, limit)}\n… (cut)`
311 }
312 return text
313}
314
315// A hunk as `git apply` would read it, for Claude to take back.
316export const hunkPatch = (f: FileDiff, h: Hunk) => `--- a/${f.shown}\n+++ b/${f.shown}\n${hunkText(h)}`
317
318const parsed = (text: string): { value: unknown } | undefined => {
319 try {
320 return { value: JSON.parse(text) }
321 } catch {
322 return undefined
323 }
324}
325
326// The JSON value in a model's reply: the last fenced block that holds one, else the first span
327// from an opening bracket to the last closing one that parses, so what the model reasoned before it
328// is passed over; undefined where there is none.
329const jsonIn = (text: string, open: '[' | '{', close: ']' | '}'): unknown => {
330 for (const m of [...text.matchAll(/```(?:json)?[ \t]*\n([\s\S]*?)```/g)].reverse()) {
331 const body = m[1]!.trim()
332 const r = body.startsWith(open) ? parsed(body) : undefined
333 if (r) return r.value
334 }
335 const end = text.lastIndexOf(close)
336 for (let i = text.indexOf(open); i !== -1 && i < end; i = text.indexOf(open, i + 1)) {
337 const r = parsed(text.slice(i, end + 1))
338 if (r) return r.value
339 }
340 return undefined
341}
342
343const SEVERITIES = ['bug', 'rule', 'note'] as const
344
345// The review's findings from its JSON array, each with an id made from what it says.
346export const parseFindings = (text: string): Finding[] | undefined => {
347 const items = jsonIn(text, '[', ']')
348 if (!Array.isArray(items)) return undefined
349 return items.flatMap(item => {
350 if (typeof item !== 'object' || item === null) return []
351 const { file, line, severity, text: said, rule } = item as Record<string, unknown>
352 if (typeof file !== 'string' || typeof said !== 'string' || said.trim() === '') return []
353 const level = SEVERITIES.find(s => s === severity) ?? 'note'
354 return [
355 {
356 id: hash(`${file}\0${said}`),
357 file,
358 ...(typeof line === 'number' && line > 0 ? { line } : {}),
359 severity: level,
360 text: said.trim(),
361 ...(typeof rule === 'string' && rule.trim() !== '' ? { rule: rule.trim() } : {}),
362 },
363 ]
364 })
365}
366
367// The findings the second look kept, from its JSON array of their numbers (1 the first). Undefined
368// when the answer is no such array: what it meant is not known, so nothing is dropped on it.
369export const keptFindings = (text: string, findings: readonly Finding[]): Finding[] | undefined => {
370 const numbers = jsonIn(text, '[', ']')
371 if (!Array.isArray(numbers) || !numbers.every(n => typeof n === 'number')) return undefined
372 return findings.filter((_, i) => numbers.includes(i + 1))
373}
374
375// The shell commands of a turn as the model reads them: each command, then the end of its output.
376export const shellText = (runs: readonly ShellRun[]) =>
377 runs.map(r => `$ ${r.command}${r.isError ? ' (failed)' : ''}${r.output ? `\n${r.output}` : ''}`).join('\n\n')
378
379// What each hunk is for, from the model's JSON object by hunk id; ids it was not sent are dropped.
380export const parseNotes = (text: string, ids: readonly string[]): Record<string, string> => {
381 const notes = jsonIn(text, '{', '}')
382 if (typeof notes !== 'object' || notes === null) return {}
383 return Object.fromEntries(
384 Object.entries(notes as Record<string, unknown>).filter((e): e is [string, string] => ids.includes(e[0]) && typeof e[1] === 'string' && e[1].trim() !== ''),
385 )
386}
387
388// The paths `git status --porcelain -z` names, each relative to its repository: a rename or a copy
389// names its old path after its new one, and both count.
390export const parseStatus = (text: string): string[] => {
391 const entries = text.split('\0')
392 const paths: string[] = []
393 for (let i = 0; i < entries.length; i++) {
394 const entry = entries[i]!
395 if (entry.length < 4) continue
396 paths.push(entry.slice(3))
397 if (entry[0] === 'R' || entry[0] === 'C') paths.push(entries[++i] ?? '')
398 }
399 return paths.filter(Boolean)
400}
401