SLOPSHOPPER

agent-lens

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…

newpanebandguardcommandprompt
v0.11.0MITupdated 2026-10-23Darvl-52/agent-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-lens
│ ┃ Review ✕ › fix the failing auth test and add an audit log call │ ┃ [ Changes ] [ Findings ] [ Walkthrough ] [ A │ ┃ ⏺ Read(src/auth.ts) │ ┃ No changes here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Diff: last turn ▾ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ agent-lens: Opened the review. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review
[ Changes ] [ Findings ] [ Walkthrough ] [ Ask ] No changes here. Diff: last turn ▾
Pane · Skill router
1: [Overview] 2: Rules 3: Skills 4: History Mode: a: ● auto s: suggest o: off l: language: en ────────────────────────────────────────────────────── Last prompt ╭──────────────────────────────────────────────────────────╮ │ «fix the failing auth test and add an audit lo…» │ │ → no skills installed │ ╰──────────────────────────────────────────────────────────╯ This session: 1 prompts, 0 with a skill (0 by rule, 0 by mo… 0 skills · 0 left out · 0 rules ────────────────────────────────────────────────────── Tab: move · Enter: press · 1-4: tabs · a/s/o: mode · Esc: c…
Pane · agent-lens-spec
No OpenSpec changes here: the project has no openspec/changes folder with an active change.
Pane · agent-lens-setup
This project is not set up yet: /setup-project. The check of the files ✓ The files pass the check.
README

agent-lens

A Claude Code mod for working with the agent, in three parts that know of each other, and a guard over them all:

  • What the agent changed. Each edit is checked against the project's 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.
  • The OpenSpec change. In a project with 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.
  • The skill for each prompt. Each prompt you send is read, and Claude is told which of your skills to load for it: your rules first, a quick model call where none fits. /router shows what it picked and why.
  • The guard. No agent works a turn on forever: a call repeated with nothing changing is refused, an agent long at it is told to wrap up, and past the limit it is stopped and reports what it did. /changes guard says the limits.
  • Setting a project up. /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.

The row above the prompt

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.

How the parts work together

  • A task from /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.
  • The review reads the change in view. Beside the diff it is told the OpenSpec change in progress: its tasks not done yet and what its specs require, each requirement with its scenarios. It reports code that goes against a requirement or a scenario, and a task the request named that the diff leaves half done.
  • The rules are checked once. What the check of each edit caught joins the findings while it is still in the code, and the review after the turn is told not to report it again.
  • One language. lang ru after any of the three commands switches all of them.

Setting a project up

/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:

  1. Matt's setup-matt-pocock-skills, run as if you typed it: the tracker, the labels, the domain layout.
  2. openspec init --tools claude, run by the mod, where the project has no openspec/.
  3. What is already written, where the project has docs of its own (a 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.
  4. The map from the code: 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.
  5. The coding standards, read from the code and each put to you before it is written: a mechanical rule (a banned API, an import's shape, where a file lives) goes to the project's linter, as a tool checks it better than prose; a judgement goes to CODING_STANDARDS.md. Each says what, why (the mistake it prevents), where it shows, and what checks it (eslint: <rule> or review).
  6. 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).
  7. 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.

The guard

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:

  • A loop is refused at once, as a stuck detector finds it: the same tool call with the same input a fourth time in a row with the same result each time (a poll whose result moves on is no loop), the same call a third time after it failed twice, or two calls taken in turn a sixth time with the same results. The agent is told why, and to try another way or say what is in the way.
  • Past half the limit (30 min or 75 tool calls by default) the next tool result carries a note: if the end is not near, stop and report what is done, what is left and what is in the way. You get a toast, and the row above the prompt shows ⏱ Claude · 34 min · 80 steps.
  • Past the limit (60 min or 150 tool calls) every tool is refused, so the agent answers with what it did; you get a notification, and the row shows ■ 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.

What the agent changed

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.

Its part of the row

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.

The pane

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

  1. Changes. The files, then the picked one's hunks, each headed by a line on what it does and why, in terms of your request. On a hunk: 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.
  2. Findings. What the review found: likely bugs, project rules broken (quoted), debugging left in, changes nobody asked for, behaviour changed with no test. 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.
  3. Walkthrough. The change in reading order: in short, where to start, how the pieces fit, what it leaves out. g makes it again.
  4. Ask. A question about the diff, or about one piece after ask on it.

Esc closes the pane.

Rules while Claude edits

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.

The rules it checks against

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 model and what it costs

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.

The OpenSpec change

Its part of the row

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.

The pane

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

  1. Brief. The gist, what is out of scope, the key decisions with their reasons, the risks. Made by the model when you open a change whose brief is missing or older than the change, and kept, so opening it again costs nothing. g makes it again; a brief older than the change says so at its top.
  2. Changes. Each file edited since you last read the change, lines added and removed. e asks the model what changed in substance, s marks it read. The first time you open a change counts as reading it.
  3. Decide. The brief's list of what needs a person: open questions, TODOs, choices made without a reason or on an assumption, each with the file it is in. 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.

  1. Tasks. The tasks of tasks.md under their headings, ticked or not. n sends the next one to the session a
Source 23 files
hooks/register.tsx 246 lines
1import 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}
246
hooks/core/lang.ts 40 lines
1import { 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
40
hooks/guard/guard.ts 218 lines
1import { 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}
218
hooks/review/review.tsx 1693 lines
1import { 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 lines
1import 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}
301
hooks/router/state.ts 177 lines
1import { 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}
177
hooks/setup/setup.tsx 584 lines
1import { 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}
584
hooks/switch.ts 38 lines
1import 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}
38
hooks/spec/lens.tsx 1826 lines
1import { 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 lines
1import 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 }
72
hooks/core/hooks.ts 7 lines
1import 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 }>
7
hooks/review/diff.ts 401 lines
1import 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