SLOPSHOPPER

plan-crutches

Visual review of Claude's plan before it runs: a browser page with the change tree, OK marks per file, signatures and +/- diffs of the changed methods…

newpaneguardcommandtoastprompt
v0.3.0MITupdated 2026-10-20s-rybak/plan-crutches
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-crutches
│ ┃ Plan Review ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Plan Crutches UA │ plan-crutches │ │ ┃ No plan under review. Start plan mode, or ● plan-crutches: plan-│ Plan Crutches: the review page did not │ │ ┃ run /plan-review demo to try the pane. ⏺ Read(src/auth.ts) │ start: Error: node 18 or newer was not │ │ ┃ Review page: The review page is not running. ⎿ Read 6 lines │ found; set the nodePath option of │ │ ┃ /plan-review to start it. ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-review │ ⎿ plan-crutches: Plan Review pane opened; the page did not start ( │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan Review
Plan Crutches UA No plan under review. Start plan mode, or run /plan-review demo to try the pane. Review page: The review page is not running. Run /plan-revie it.
README

Plan Crutches

Plan Crutches is a mod for Claude Code. It shows Claude's plan in a review page in your browser before Claude writes any code. You see the task, a short summary, the change tree and the signatures of new and changed methods. Each changed file shows whole, with the planned changes in place and the unchanged lines folded. A changed or deleted method shows as a git-style diff: its current code as removed lines, the planned code as added lines. You comment on folders, files, symbols or any text you select with the mouse. When a file needs no comments, you tick it as OK. You ask the page to explain any place of the plan. A 3D diagram shows the files and the calls the plan changes. Then you press Submit. With comments, Claude revises the plan and the page shows the next version. Without comments, Claude proceeds to implementation.

The landing page with screenshots is at https://s-rybak.github.io/plan-crutches/.

How it works

  1. You start plan mode and give Claude a task.
  2. Claude writes the plan file as usual and ends it with a plan-crutches JSON block. The block marks every symbol as new, modified or deleted. The mod adds this instruction to the plan mode reminder.
  3. When Claude calls ExitPlanMode, the mod reads the plan file, starts the review page server, opens the page and tells Claude to wait.
  4. You review in the page. Every comment is saved in the session state and in a Markdown record. The record, the live review and every version of the plan are files under ~/.claude/plan-crutches/<project>/, outside the project. If Claude Code restarts and you resume the session, the review comes back on its own. If you start a new session in the same folder, /plan-review restore brings it back.
  5. Submit sends your comments to Claude as the next message. Claude updates the plan file and calls ExitPlanMode again. The page shows version 2 and marks your comments as resolved. Highlight changes shows what version 2 changed against version 1.
  6. Submit with no pending comments approves the plan. Claude Code then shows its own plan dialog once. Press Yes there to start the implementation.

A plan can also come from a file. /plan-review open <path> reads a Markdown plan file and starts the same review. The page, the pane, the record and the saved review all work as for a plan from plan mode. The path is absolute or relative to the project folder, and ~ stands for your home folder. Use it for a plan from an earlier session under ~/.claude/plans/, or for a plan you wrote by hand. If the file is already under review, the command opens it as the next version, and your comments stay. If another plan is under review, the command resets that review first. If you give no path, the mod finds the plan itself. It takes the plan file of this session: the file plan mode names, or the file the review already holds. If there is none, it takes the newest .md file under ~/.claude/plans/. The reply names the file it chose and why.

The review page

The page is plain HTML, CSS and JavaScript under web/, served by a small Node server under server/ on 127.0.0.1. The mod starts the server with $.process.spawn. After every change, the mod pushes the review state to the server. The mod reads every action of the page from the server's standard output. The page gets the state over Server-Sent Events, so it follows the session live.

  • Header: plan title, version, status, comment count, the count of files marked OK, connection dot, theme toggle, language toggle (EN and UA) and the Submit button. When the plan has more than one version, the version becomes a list. Pick an earlier version to look at it. The path of the Markdown record has a line of its own under the badges. The header and the tabs float: they stay at the top while you scroll.
  • Task: the raw task text, from the Jira issue Claude fetched or from your prompt.
  • Summary: the plan summary as Markdown with images, cut at 9000 characters with a Show all button. Press Edit to change the text. Save to the plan writes it into the plan file, and the next revision prompt tells Claude to keep your wording. When the plan has an earlier version, Highlight changes shows the summary as a text diff against that version. The list next to the button picks the version to compare with.
  • Changes by folder: every folder and file the plan touches, marked new, mod or del, with the number of comments on each. Click a folder to fold it, click the speech bubble to comment on it, click a file to open it. Every row has an OK box. When the file needs no comments, tick it. The box of a folder ticks every file under it. When a new version changes a file, the mod clears its OK mark. With Highlight changes on, each row carries a chip: added, changed or removed since the compared version. A removed file stays in the tree as a crossed-out row.
  • File view: the description of the change and each symbol with its highlighted signature. Each symbol says what the plan does to it: new, modified or deleted. Below them, the whole file with the planned changes in place. The page folds the unchanged lines into one row that shows their count. Click the row to show them. Show all lines unfolds the whole file. A modified symbol appears as git shows it: its current code as removed lines, the planned signature as added lines. A deleted symbol appears as removed lines only. Before you ask for an implementation, the file shows only the planned signatures. Expand implementation on a symbol card, or Show implementation next to its signature in the file, asks the model for the body. The generated code takes the place of the stub in the file, under the removed lines, and you can comment on its lines. The page scrolls to it.
  • Select any text with the mouse, in a code block, the summary, the task or an answer: a popup opens next to the selection with three tabs. Drag its title bar to move the window. Comment: type the comment and press Enter. A code comment keeps the line range and the selected text, and the lines stay marked. Ask: type a question about the selected text and press Enter. Claude answers in the same window, with the selection and its place as context, and you continue the conversation there. The thread is kept in the Ask tab, and "Add the conversation to a comment" turns it into a comment on the same lines. Explain: Claude says what this place is, where it sits in the project and why the plan changes it. The answer comes in the language you write in. When you open the tab, the page sends the request, and not before. The same place is explained once per version.
  • Diagram: a 3D board with one block per planned file, colored by its status: new, modified or deleted. Each block lists the symbols the plan changes. Lines between symbols are the calls and uses the plan creates. Calls inside one file are drawn inside its block. Press Generate change diagram to build it: a clean session with read-only tools gets the plan and reads the project. Click a symbol to see every place in the project that calls it, in existing code too. The callers arrive as new rows and blocks, up to 20. More callers than that become one block marked others. Click the symbol again to hide them. The plan's own blocks are always drawn whole, with no cap on files or symbols. Test files and test folders stay out of the diagram. Each block shows the file name as its title and the folder path below it. Drag on the board rotates it, Shift and drag pans it, the wheel zooms, and 2D flattens it. Drag a block to move it. Auto layout puts the moved blocks back. Save as SVG writes the flat diagram next to the Markdown record.
  • Ask: a question box that answers from the plan and the session without changing either. Every question is a thread: Reply continues the conversation under it, with the earlier turns as context. A thread shows where it came from and the selected text, and can become a comment. Answered threads go into the Markdown record.
  • Versions: every version of the plan is saved, as the plan file and as the plan read from it. The version list in the header opens an earlier version. The page then shows that version read-only, with a banner. Bring this version back writes it into the plan file and makes it the next version. The history keeps the order: version 4 can be version 2 brought back.
  • Comments: every comment by state (pending, sent, resolved), the version list with Open, Highlight changes and Bring this version back, and the review history. A pending comment has a pencil button to change its text and a cross to delete it. After Submit, the comment stays as Claude saw it.

The pane in Claude Code stays as a second view with the page link, so a terminal session still has a review surface.

Colors and styles

The page reads its colors from CSS variables in web/theme.css, with light and dark values. Change them there, or put overrides in web/custom.css. That file is yours and is not part of the mod. If the file exists, the server serves it. If the file is missing, the page ignores it. The theme toggle in the header switches between auto, light and dark.

Commands

CommandWhat it does
/plan-reviewIf the page server is not running, starts it. Opens the page and the pane.
/plan-review demoLoads a demo plan so you can try the page without plan mode.
/plan-review open [path]Opens a plan file for review without plan mode. With no path, it opens the plan file of this session, or else the newest file under ~/.claude/plans/. The same file under review gets the next version and keeps the comments. The command resets any other live review first.
/plan-review urlPrints the page address with its token.
/plan-review restoreBrings back the stored review of this folder: the plan, the comments, the questions and the diagram.
/plan-review closeCloses the pane. The page keeps running.
/plan-review stopStops the page server.
/plan-review resetClears the review state.

Options

Set them in /config or in settings.json under pluginConfigs.plan-crutches.options.

OptionDefaultMeaning
languageenPage and pane language, en or uk.
modelsonnetModel for the light calls: plan structure extraction, the pick of project files for an implementation, the change diagram and its callers, and answers without a session fork.
bodyModelopusModel that writes method bodies. Empty means the model option.
plansDir~/.claude/plan-crutchesFolder for the Markdown records and SVG files. A path under ~ or an absolute path is shared, with one subfolder per project. A relative path is a folder inside the project.
summaryLimit9000Characters of the summary shown before Show all.
port4817Port of the page server on 127.0.0.1. A taken port falls back to a free one.
openInautoWhere the page opens: auto tries the Claude desktop browser pane, then the system browser. system uses the system browser. none only shows the link.
nodePathemptyPath of the node executable. Empty means node from PATH, then the usual install locations. Node 18 or newer.

How an implementation is generated

Expand implementation and Show implementation ask bodyModel for one symbol's body. The prompt carries, in this order:

  1. The whole plan: the summary and every planned file with every symbol's full signature and description. This block is the same for every symbol of a plan version and is marked for the prompt cache.
  2. The implementations already generated for the other symbols, the current file's own first, so names, signatures and behavior stay consistent across the plan.
  3. Real project files. These are the other planned files as they are on disk and the files the current file imports. The model option also picks up to 8 files from the project's file list (git ls-files): the types, helpers and callers the symbol needs.
  4. The current content of the file and the symbol to implement.

How the diagram is generated

Generate change diagram starts a subagent of the mod's own type. The subagent runs in a clean session: no history, no CLAUDE.md, and only the read-only tools Read, Grep and Glob. Its prompt holds the whole plan and the current text of the planned files. It answers with a JSON graph: every planned file with its symbols, and the calls between them. The page draws the graph and never drops a planned block. Test files stay out of the graph and of the callers.

A click on a symbol starts a second request. The mod first runs git grep for the symbol name and names the function that holds each hit. The clean session gets those candidates, drops false positives, adds the callers the search missed, and answers up to 20 callers. The rest becomes one block marked others. If no subagent can start, a model call without tools answers from the plan and the file texts. If that call fails too, the page shows the text search result alone and says so.

Security of the page

The server binds to 127.0.0.1 only. It answers only requests whose Host header names the local machine. A web site that points its name at 127.0.0.1 gets a 403. Every API call carries a token that the mod makes once per session. The page gets the token from the link, keeps it in local storage and then removes it from the address bar. Project files are served under /project/ with the same token, for images in the summary and for the file view. The server never serves a file outside the project folder. The state the mod pushes holds the plan and the signature diffs, not the text of the project files.

Install

This repository is a plugin marketplace. In a terminal session of Claude Code, type:

/plugin install plan-crutches --marketplace s-rybak/plan-crutches

Claude Code asks to add the marketplace. Answer y, then pick a scope. The user scope loads the mod in every project.

From a shell:

claude plugin install plan-crutches --marketplace s-rybak/plan-crutches

In the desktop app, /plugin does not run. Click + next to the prompt box, then Plugins, then Add marketplace with s-rybak/plan-crutches, then Add plugin.

The mod needs Claude Code 2.1.275 or newer. The review page needs Node 18 or newer on the same machine. To get a new version, run claude plugin update plan-crutches, then /reload-plugins in a session.

Run it from this folder

The mod loads from a folder that Claude Code watches. In a terminal session:

claude --plugin-dir /path/to/plan-crutches

In the desktop app, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, then start a new session.

Try the page alone

The page can run without the mod, with the demo plan and canned answers:

node scripts/demo-server.mjs

Then open http://127.0.0.1:4818/?t=demo.

Check it

claude plugin validate .
claude plugin test .
node --test tests/server.node.mjs tests/page.node.mjs

The first two need Claude Code 2.1.275 or newer. The last one checks the page server end to end and the page modules that render Markdown, code, diffs, the tree and the diagram layout.

Landing page

The landing page is docs/index.html, with its screenshots under docs/assets/. The live site is https://s-rybak.github.io/plan-crutches/ and GitHub Pages serves it from the docs/ folder of the main branch. To look at it on your machine, start the small static server and open http://127.0.0.1:4819/ in the browser. The command is:

node scripts/landing-server.mjs

The screenshots show the demo plan in light and dark themes. node scripts/landing-shots.mjs takes them again with the installed Google Chrome. It starts the demo server, drives the page and writes the WebP files under docs/assets/. It needs cwebp on the PATH.

Limits

  • The built-in plan dialog remains after approval: one Yes click.
  • Claude must write the plan-crutches block for the structured view. Without it, the mod asks the model to extract the structure, and falls back to the Markdown alone.
  • The page server runs only where the session runs, so a session in the cloud has the pane alone.
  • A session keeps the mod it loaded at its start. After an update of the mod, start a new session. If the page is newer than the mod of its session, the page shows a warning.
  • A stored review needs its plan file. If the plan file under ~/.claude/plans/ is gone, the review cannot come back. The live review, the generated bodies and the plan versions are files under ~/.claude/plan-crutches/<project>/. One file holds at most 4 MB. When the record grows past that, the explanations and the questions are left out first, and the comments always stay. When you approve the plan, reset the review or leave plan mode, the mod marks the stored review of the folder as closed. The version files stay.
  • A plan opened from a file has no plan mode session behind it. After Submit with comments, the revision request asks Claude to update the plan file and call ExitPlanMode. If the new version does not appear, run /plan-review open again.
  • The diagram shows calls as the clean session read them. It is a reading of the code, not a compiler's analysis.
Source 18 files
hooks/register.tsx 1923 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
3
4import type {
5  CallersQuery,
6  CommentTarget,
7  Diagram,
8  DiagramRef,
9  Explanation,
10  FileCache,
11  GeneratedBody,
12  Lang,
13  LoadedVersion,
14  OkMark,
15  ParsedFrom,
16  PlanFile,
17  PlanModel,
18  PlanSymbol,
19  PlanVersion,
20  QaItem,
21  Review,
22  ReviewComment,
23  SavedVersion,
24  StoredReview,
25  TaskCapture,
26  UiState,
27  VersionSource,
28  WebNotice,
29  WebSnapshot,
30  WebState,
31} from '../types'
32import { DEMO_PLAN } from './demo-plan'
33import {
34  CALLERS_LIMIT,
35  DIAGRAM_AGENT_DESCRIPTION,
36  DIAGRAM_AGENT_NAME,
37  DIAGRAM_AGENT_PROMPT,
38  DIAGRAM_SYSTEM,
39  callersPrompt,
40  diagramPrompt,
41  enclosingSymbol,
42  groupCandidates,
43  isDeclarationOf,
44  isImportLine,
45  isTestPath,
46  loadingCallers,
47  parseCallersReply,
48  parseDiagramReply,
49  parseGrepOutput,
50} from './diagram'
51import type { CallerCandidate } from './diagram'
52import { codeFromReply, currentSymbolText } from './diff'
53import { applySummary, extractJson, normalizePlan, parsePlan, planFromMarkdown, titleFromMarkdown } from './plan-parser'
54import { renderPane, withoutKeys } from './pane'
55import type { PaneActions, PaneModel } from './pane'
56import { bodiesFile, projectDir, recordsDir, reviewFile, versionFile } from './paths'
57import type { SelectableProps } from './select'
58import {
59  BODY_SYSTEM,
60  EXTRACTION_SYSTEM,
61  PICK_FILES_SYSTEM,
62  PLAN_BLOCK_INSTRUCTIONS,
63  PLAN_BLOCK_REMINDER,
64  approvePrompt,
65  askMorePrompt,
66  askPrompt,
67  bodyPrompt,
68  denyMessage,
69  explainPrompt,
70  userWords,
71  extractionPrompt,
72  importCandidates,
73  parseFileList,
74  pickFilesPrompt,
75  relativeImports,
76  revisionPrompt,
77} from './prompts'
78import type { RelatedFile } from './prompts'
79import { t } from './i18n'
80import { newestPlanFile, openArgument, planSourceNote, resolvePlanPath } from './open-plan'
81import type { FoundPlan } from './open-plan'
82import { recordFileName, renderRecord } from './record'
83import {
84  STORE_INDEX_KEY,
85  STORE_KEEP,
86  buildStored,
87  indexWith,
88  indexWithout,
89  isRestorable,
90  parseBodiesText,
91  parseIndex,
92  parseStoredText,
93  serializeBodies,
94  serializeStored,
95  storeKey,
96  thawStored,
97} from './persist'
98import { PANE_ID, bodyKey, newId } from './state'
99import { buildSavedVersion, keepOkMarks, parseSavedVersion, pathsUnderFolder, serializeVersion, versionEntry } from './versions'
100import { buildSnapshot, openCommands, pageUrl, parseAction, parseServerLine, parseTarget, splitLines } from './web'
101import type { WebAction } from './web'
102
103type OpenIn = 'auto' | 'system' | 'none'
104type Options = {
105  language: Lang
106  model: string
107  bodyModel: string
108  plansDir: string
109  summaryLimit: number
110  port: number
111  openIn: OpenIn
112  nodePath: string
113}
114
115const planAtom = atom<'plan-crutches', 'plan'>(
116  { plugin: 'plan-crutches', key: 'plan' } as const,
117  null as PlanModel | null,
118)
119
120const reviewAtom = atom<'plan-crutches', 'review'>(
121  { plugin: 'plan-crutches', key: 'review' } as const,
122  {
123    status: 'idle',
124    version: 0,
125    parsedFrom: 'none',
126    warnings: [],
127    history: [],
128    previousPaths: [],
129  } as Review,
130)
131
132const commentsAtom = atom<'plan-crutches', 'comments'>(
133  { plugin: 'plan-crutches', key: 'comments' } as const,
134  [] as ReviewComment[],
135)
136
137const uiAtom = atom<'plan-crutches', 'ui'>(
138  { plugin: 'plan-crutches', key: 'ui' } as const,
139  {
140    lang: 'en',
141    tab: 'changes',
142    view: 'tree',
143    collapsed: [],
144    showFullSummary: false,
145    showTask: true,
146    expanded: [],
147  } as UiState,
148)
149
150const taskAtom = atom<'plan-crutches', 'task'>(
151  { plugin: 'plan-crutches', key: 'task' } as const,
152  {} as TaskCapture,
153)
154
155const lastPromptAtom = atom<'plan-crutches', 'lastPrompt'>(
156  { plugin: 'plan-crutches', key: 'lastPrompt' } as const,
157  '',
158)
159
160const bodiesAtom = atom<'plan-crutches', 'bodies'>(
161  { plugin: 'plan-crutches', key: 'bodies' } as const,
162  {} as Record<string, GeneratedBody>,
163)
164
165const qaAtom = atom<'plan-crutches', 'qa'>(
166  { plugin: 'plan-crutches', key: 'qa' } as const,
167  [] as QaItem[],
168)
169
170const diagramAtom = atom<'plan-crutches', 'diagram'>(
171  { plugin: 'plan-crutches', key: 'diagram' } as const,
172  { status: 'idle', callers: {} } as Diagram,
173)
174
175const filesAtom = atom<'plan-crutches', 'files'>(
176  { plugin: 'plan-crutches', key: 'files' } as const,
177  {} as FileCache,
178)
179
180const webAtom = atom<'plan-crutches', 'web'>(
181  { plugin: 'plan-crutches', key: 'web' } as const,
182  {} as WebState,
183)
184
185const okAtom = atom<'plan-crutches', 'ok'>(
186  { plugin: 'plan-crutches', key: 'ok' } as const,
187  {} as Record<string, OkMark>,
188)
189
190const explainAtom = atom<'plan-crutches', 'explanations'>(
191  { plugin: 'plan-crutches', key: 'explanations' } as const,
192  {} as Record<string, Explanation>,
193)
194
195const versionsAtom = atom<'plan-crutches', 'versions'>(
196  { plugin: 'plan-crutches', key: 'versions' } as const,
197  [] as PlanVersion[],
198)
199
200const compareAtom = atom<'plan-crutches', 'compare'>(
201  { plugin: 'plan-crutches', key: 'compare' } as const,
202  null as LoadedVersion | null,
203)
204
205const viewingAtom = atom<'plan-crutches', 'viewing'>(
206  { plugin: 'plan-crutches', key: 'viewing' } as const,
207  null as LoadedVersion | null,
208)
209
210const PANE_TITLE = 'Plan Review'
211const SERVER_START_MS = 8000
212const SERVER_IDLE_MS = 600_000
213const DIAGRAM_AGENT_TYPE = `plan-crutches:${DIAGRAM_AGENT_NAME}`
214const AGENT_WAIT_MS = 900_000
215const EMPTY_DIAGRAM: Diagram = { status: 'idle', callers: {} }
216
217function readOptions(raw: PluginOptions): Options {
218  const language = raw.language === 'uk' ? 'uk' : 'en'
219  const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : 'sonnet'
220  const bodyModel = typeof raw.bodyModel === 'string' && raw.bodyModel.trim() ? raw.bodyModel.trim() : 'opus'
221  // Empty, `~/...` or absolute: the shared folder with one subfolder per project. Anything else: a folder inside the project.
222  const plansDir = typeof raw.plansDir === 'string' ? raw.plansDir.trim().replace(/\/+$/, '') : ''
223  const summaryLimit = typeof raw.summaryLimit === 'number' && raw.summaryLimit > 200 ? Math.floor(raw.summaryLimit) : 9000
224  const port = typeof raw.port === 'number' && raw.port >= 0 && raw.port <= 65535 ? Math.floor(raw.port) : 4817
225  const openIn: OpenIn = raw.openIn === 'system' || raw.openIn === 'none' ? raw.openIn : 'auto'
226  const nodePath = typeof raw.nodePath === 'string' ? raw.nodePath.trim() : ''
227  return { language, model, bodyModel, plansDir, summaryLimit, port, openIn, nodePath }
228}
229
230const EMPTY_REVIEW: Review = { status: 'idle', version: 0, parsedFrom: 'none', warnings: [], history: [], previousPaths: [] }
231
232function freshUi(previous: UiState): UiState {
233  return { lang: previous.lang, tab: 'changes', view: 'tree', collapsed: [], showFullSummary: false, showTask: true, expanded: [] }
234}
235
236let options: Options = { language: 'en', model: 'sonnet', bodyModel: 'opus', plansDir: '', summaryLimit: 9000, port: 4817, openIn: 'auto', nodePath: '' }
237
238// ---- the review page server -------------------------------------------------
239
240type ServerHandle = { url: string; port: number; token: string; stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> }
241
242let server: ServerHandle | undefined
243let serverStarting: Promise<ServerHandle | undefined> | undefined
244let heartbeat: Timer | undefined
245let notice: WebNotice | undefined
246let noticeCounter = 0
247let pushing = false
248let pushAgain = false
249
250function setNotice(text: string): void {
251  noticeCounter += 1
252  notice = { id: noticeCounter, text }
253}
254
255function makeToken(): string {
256  return `${newId()}${newId()}${newId()}${newId()}`
257}
258
259async function homeDir($: EngineInterface): Promise<string> {
260  try {
261    return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
262  } catch {
263    return ''
264  }
265}
266
267async function findNode($: EngineInterface): Promise<string> {
268  if (options.nodePath) return options.nodePath
269  const home = await homeDir($)
270  const candidates = ['node', '/opt/homebrew/bin/node', '/usr/local/bin/node', `${home}/.volta/bin/node`, '/usr/bin/node']
271  const nvmDir = `${home}/.nvm/versions/node`
272  try {
273    if (home && (await $.fs.exists(nvmDir))) {
274      const versions = (await $.fs.list(nvmDir)).filter(entry => entry.kind === 'dir').map(entry => entry.name).sort().reverse()
275      for (const version of versions) candidates.push(`${nvmDir}/${version}/bin/node`)
276    }
277  } catch {
278    // no nvm
279  }
280  for (const candidate of candidates) {
281    try {
282      const ran = await $.process.run([candidate, '--version'], { timeoutMs: 8000 })
283      if (ran.exitCode === 0 && /^v(1[89]|[2-9]\d)\./.test(ran.stdout.trim())) return candidate
284    } catch {
285      // try the next one
286    }
287  }
288  throw new Error('node 18 or newer was not found; set the nodePath option of plan-crutches')
289}
290
291async function noteServerError($: EngineInterface, error: string): Promise<void> {
292  $.ui.log(`plan-crutches: review page server: ${error}`, { to: 'debug' })
293  await update($, webAtom, web => ({ ...(web.token ? { token: web.token } : {}), error }))
294}
295
296async function serverGone($: EngineInterface): Promise<void> {
297  server = undefined
298  if (heartbeat) {
299    heartbeat.cancel()
300    heartbeat = undefined
301  }
302  await update($, webAtom, web => (web.token ? { token: web.token } : {}))
303}
304
305async function startServer($: EngineInterface): Promise<ServerHandle | undefined> {
306  const stored = await read($, webAtom)
307  const token = stored.token ?? makeToken()
308  if (!stored.token) await update($, webAtom, web => ({ ...web, token }))
309  let node: string
310  try {
311    node = await findNode($)
312  } catch (error) {
313    await noteServerError($, String(error))
314    return undefined
315  }
316  const root = $.plugin.root
317  let cwd = ''
318  try {
319    cwd = await $.session.cwd()
320  } catch {
321    cwd = ''
322  }
323  let stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
324  try {
325    stream = $.process.spawn({
326      argv: [node, `${root}/server/index.mjs`],
327      cwd: root,
328      env: {
329        PLAN_CRUTCHES_PORT: String(options.port),
330        PLAN_CRUTCHES_TOKEN: token,
331        PLAN_CRUTCHES_PROJECT: cwd || root,
332        PLAN_CRUTCHES_WEB: `${root}/web`,
333        PLAN_CRUTCHES_IDLE_MS: String(SERVER_IDLE_MS),
334      },
335    })
336  } catch (error) {
337    await noteServerError($, String(error))
338    return undefined
339  }
340  let settle: (handle: ServerHandle | undefined) => void = () => {}
341  const ready = new Promise<ServerHandle | undefined>(resolve => {
342    settle = resolve
343  })
344  let handle: ServerHandle | undefined
345  let fatal = ''
346  void (async () => {
347    let rest = ''
348    try {
349      for await (const chunk of stream) {
350        if (chunk.stream === 'stderr') {
351          $.ui.log(`plan-crutches server: ${chunk.text.trim()}`, { to: 'debug' })
352          continue
353        }
354        const split = splitLines(rest, chunk.text)
355        rest = split.rest
356        for (const line of split.lines) {
357          const message = parseServerLine(line)
358          if (!message) continue
359          if (message.type === 'listening') {
360            handle = { url: message.url, port: message.port, token: message.token, stream }
361            settle(handle)
362          } else if (message.type === 'action') {
363            const action = parseAction(message.action)
364            if (action) {
365              void handleAction($, action).catch(error => $.ui.log(`plan-crutches: page action ${action.type} failed: ${String(error)}`, { to: 'debug' }))
366            } else {
367              $.ui.log('plan-crutches: ignored a malformed page action', { to: 'debug' })
368            }
369          } else if (message.type === 'fatal') {
370            fatal = message.text
371            $.ui.log(`plan-crutches server: ${message.text}`, { to: 'debug' })
372          } else {
373            $.ui.log(`plan-crutches server: ${message.text}`, { to: 'debug' })
374          }
375        }
376      }
377    } catch (error) {
378      $.ui.log(`plan-crutches server stream ended: ${String(error)}`, { to: 'debug' })
379    } finally {
380      settle(undefined)
381      if (handle && server === handle) await serverGone($)
382    }
383  })()
384  const started = await Promise.race([ready, $.clock.sleep(SERVER_START_MS).then(() => undefined)])
385  if (!started) {
386    await noteServerError($, fatal || `the page server did not report a port within ${SERVER_START_MS / 1000} s`)
387    try {
388      await stream.return({ code: null, signal: null })
389    } catch {
390      // already gone
391    }
392    return undefined
393  }
394  server = started
395  await update($, webAtom, web => ({ ...(web.token ? { token: web.token } : {}), url: started.url, port: started.port }))
396  if (heartbeat) heartbeat.cancel()
397  heartbeat = $.clock.every(60_000, () => {
398    void pingServer($)
399  })
400  void pushState($)
401  return started
402}
403
404async function ensureServer($: EngineInterface): Promise<ServerHandle | undefined> {
405  if (server) return server
406  if (!serverStarting) {
407    serverStarting = startServer($).finally(() => {
408      serverStarting = undefined
409    })
410  }
411  return serverStarting
412}
413
414async function stopServer($: EngineInterface): Promise<void> {
415  const current = server
416  await serverGone($)
417  if (!current) return
418  try {
419    await current.stream.return({ code: null, signal: null })
420  } catch {
421    // already gone
422  }
423}
424
425async function pingServer($: EngineInterface): Promise<boolean> {
426  const current = server
427  if (!current) return false
428  try {
429    const response = await $.http.fetch(`${current.url}/api/ping`, { headers: { 'x-plan-crutches-token': current.token } })
430    if (response.ok) return true
431  } catch {
432    // fall through
433  }
434  if (server === current) await serverGone($)
435  return false
436}
437
438async function buildWebSnapshot($: EngineInterface): Promise<WebSnapshot> {
439  const [plan, review, comments, ui, bodies, qa, diagram, files, ok, explanations, versions, compare, viewing] = await Promise.all([
440    read($, planAtom),
441    read($, reviewAtom),
442    read($, commentsAtom),
443    read($, uiAtom),
444    read($, bodiesAtom),
445    read($, qaAtom),
446    read($, diagramAtom),
447    read($, filesAtom),
448    read($, okAtom),
449    read($, explainAtom),
450    read($, versionsAtom),
451    read($, compareAtom),
452    read($, viewingAtom),
453  ])
454  let cwd = ''
455  try {
456    cwd = await $.session.cwd()
457  } catch {
458    cwd = ''
459  }
460  return buildSnapshot({
461    plan,
462    review,
463    comments,
464    bodies,
465    qa,
466    diagram,
467    files,
468    ok,
469    explanations,
470    versions,
471    compare,
472    viewing,
473    lang: ui.lang,
474    summaryLimit: options.summaryLimit,
475    cwd,
476    ...(notice ? { notice } : {}),
477    nowMs: await $.clock.now(),
478  })
479}
480
481/** Sends the whole state to the page server; coalesces calls that overlap. */
482async function pushState($: EngineInterface): Promise<void> {
483  const current = server
484  if (!current) return
485  if (pushing) {
486    pushAgain = true
487    return
488  }
489  pushing = true
490  try {
491    do {
492      pushAgain = false
493      const snapshot = await buildWebSnapshot($)
494      const response = await $.http.fetch(`${current.url}/api/state`, {
495        method: 'POST',
496        headers: { 'content-type': 'application/json', 'x-plan-crutches-token': current.token },
497        body: JSON.stringify(snapshot),
498      })
499      if (!response.ok) $.ui.log(`plan-crutches: the page server answered ${response.status} to a state push`, { to: 'debug' })
500    } while (pushAgain)
501  } catch (error) {
502    $.ui.log(`plan-crutches: state push failed: ${String(error)}`, { to: 'debug' })
503    if (server === current) await serverGone($)
504  } finally {
505    pushing = false
506  }
507}
508
509function changed($: EngineInterface): void {
510  void pushState($)
511  syncStore($)
512}
513
514// ---- the review between processes ------------------------------------------
515// The session state dies with the process. A live review is also written to a
516// JSON file under the mod's folder in the Claude configuration directory, one
517// subfolder per project (hooks/paths.ts): the same session resumed takes it up
518// on its own, another session in the folder by /plan-review restore. The
519// plugin store holds only the index of folders. The store work runs one job
520// at a time, in the order it was asked.
521
522let storeChain: Promise<void> = Promise.resolve()
523let syncQueued = false
524
525async function sessionCwd($: EngineInterface): Promise<string> {
526  try {
527    return await $.session.cwd()
528  } catch {
529    return ''
530  }
531}
532
533async function currentSessionId($: EngineInterface): Promise<string> {
534  try {
535    return await $.session.id()
536  } catch {
537    return ''
538  }
539}
540
541/** The folder of this project's review files, or empty when the home or the project folder is unknown. */
542async function reviewDir($: EngineInterface): Promise<string> {
543  const [home, cwd] = await Promise.all([homeDir($), sessionCwd($)])
544  return home && cwd ? projectDir(home, cwd) : ''
545}
546
547/** Writes the review to its file after the running job; one write serves every call made meanwhile. */
548function syncStore($: EngineInterface): void {
549  if (syncQueued) return
550  syncQueued = true
551  storeChain = storeChain.then(() => syncJob($))
552}
553
554async function syncJob($: EngineInterface): Promise<void> {
555  syncQueued = false
556  try {
557    await writeStore($)
558  } catch (error) {
559    $.ui.log(`plan-crutches: could not write the review file: ${String(error)}`, { to: 'debug' })
560  }
561}
562
563async function writeStore($: EngineInterface): Promise<void> {
564  const [plan, review, comments, qa, diagram, task, ui, bodies, web, ok, versions, explanations] = await Promise.all([
565    read($, planAtom),
566    read($, reviewAtom),
567    read($, commentsAtom),
568    read($, qaAtom),
569    read($, diagramAtom),
570    read($, taskAtom),
571    read($, uiAtom),
572    read($, bodiesAtom),
573    read($, webAtom),
574    read($, okAtom),
575    read($, versionsAtom),
576    read($, explainAtom),
577  ])
578  if (!plan || review.isDemo || !isRestorable(review)) return
579  const cwd = await sessionCwd($)
580  const dir = await reviewDir($)
581  if (!cwd || !dir) return
582  const savedAt = await isoNow($)
583  const sessionId = await currentSessionId($)
584  const stored = buildStored({ cwd, sessionId, savedAt, plan, review, comments, qa, diagram, task, ui, ok, versions, explanations, ...(web.token ? { token: web.token } : {}) })
585  await $.fs.write(reviewFile(dir), serializeStored(stored))
586  await $.fs.write(bodiesFile(dir), serializeBodies(bodies))
587  const index = indexWith(parseIndex(await $.store.get(STORE_INDEX_KEY)), { cwd, savedAt, title: plan.title, version: review.version, dir }, STORE_KEEP)
588  await $.store.set(STORE_INDEX_KEY, index.entries)
589  // The record once lived in the store itself; that copy only takes room now.
590  const legacy = storeKey(cwd)
591  if ((await $.store.keys()).includes(legacy)) await $.store.delete(legacy)
592}
593
594/** Drops this folder's stored review after the running job: the review ended or was reset. */
595function clearStore($: EngineInterface): Promise<void> {
596  storeChain = storeChain.then(() => deleteStore($))
597  return storeChain
598}
599
600async function deleteStore($: EngineInterface): Promise<void> {
601  try {
602    const cwd = await sessionCwd($)
603    const dir = await reviewDir($)
604    if (!cwd) return
605    // The file system API has no delete: an empty record is no record.
606    if (dir && (await $.fs.exists(reviewFile(dir)))) await $.fs.write(reviewFile(dir), '{}')
607    const legacy = storeKey(cwd)
608    if ((await $.store.keys()).includes(legacy)) await $.store.delete(legacy)
609    const index = parseIndex(await $.store.get(STORE_INDEX_KEY))
610    if (index.some(entry => entry.cwd === cwd)) await $.store.set(STORE_INDEX_KEY, indexWithout(index, cwd))
611  } catch (error) {
612    $.ui.log(`plan-crutches: could not drop the stored review: ${String(error)}`, { to: 'debug' })
613  }
614}
615
616type StoredFound = { stored: StoredReview; bodies: Record<string, GeneratedBody> }
617
618/** This folder's stored review, when its file holds one this code can read. */
619async function readStored($: EngineInterface): Promise<StoredFound | null> {
620  try {
621    const dir = await reviewDir($)
622    if (!dir) return null
623    const file = reviewFile(dir)
624    if (!(await $.fs.exists(file))) return null
625    const stored = parseStoredText(await $.fs.read(file))
626    if (!stored || !isRestorable(stored.review)) return null
627    const bodiesPath = bodiesFile(dir)
628    const bodies = (await $.fs.exists(bodiesPath)) ? parseBodiesText(await $.fs.read(bodiesPath)) : {}
629    return { stored, bodies }
630  } catch (error) {
631    $.ui.log(`plan-crutches: could not read the stored review: ${String(error)}`, { to: 'debug' })
632    return null
633  }
634}
635
636/** Takes a stored review up in this process; `gone` when its plan file is no longer there. */
637async function restoreReview($: EngineInterface, found: StoredFound): Promise<'restored' | 'gone'> {
638  const { stored } = found
639  const planFilePath = stored.review.planFilePath
640  if (!planFilePath || !(await $.fs.exists(planFilePath))) return 'gone'
641  const now = await isoNow($)
642  const thawed = thawStored(stored, found.bodies)
643  versionCache.clear()
644  await update($, planAtom, () => thawed.plan)
645  await update($, reviewAtom, () => ({ ...thawed.review, history: [...thawed.review.history, `restored after a restart ${now}`] }))
646  await update($, commentsAtom, () => thawed.comments)
647  await update($, qaAtom, () => thawed.qa)
648  await update($, diagramAtom, () => thawed.diagram)
649  await update($, taskAtom, () => thawed.task)
650  await update($, uiAtom, () => thawed.ui)
651  await update($, bodiesAtom, () => thawed.bodies)
652  await update($, filesAtom, () => ({}))
653  await update($, okAtom, () => thawed.ok)
654  await update($, versionsAtom, () => thawed.versions)
655  await update($, explainAtom, () => thawed.explanations)
656  await update($, viewingAtom, () => null)
657  const previous = thawed.review.version > 1 ? await loadVersion($, thawed.review.version - 1) : undefined
658  await update($, compareAtom, () => (previous ? { version: previous.version, plan: previous.plan } : null))
659  // The stored token lets the page the person left open reconnect; a server
660  // already running keeps the token it was started with.
661  const token = stored.token
662  if (token && !server) await update($, webAtom, web => ({ ...web, token }))
663  notice = undefined
664  await writeRecord($)
665  changed($)
666  return 'restored'
667}
668
669async function openWeb($: EngineInterface, open: boolean): Promise<string | undefined> {
670  const handle = await ensureServer($)
671  if (!handle) {
672    const web = await read($, webAtom)
673    $.ui.toast(`Plan Crutches: the review page did not start${web.error ? `: ${web.error}` : ''}`)
674    return undefined
675  }
676  const url = pageUrl(handle.url, handle.token)
677  if (!open || options.openIn === 'none') return url
678  if (options.openIn === 'auto') {
679    try {
680      const result = await $.mcp.call('Claude_Browser', 'navigate', { url })
681      if (!result.isError) return url
682      $.ui.log(`plan-crutches: the browser pane refused the page: ${JSON.stringify(result.content).slice(0, 300)}`, { to: 'debug' })
683    } catch (error) {
684      $.ui.log(`plan-crutches: no browser pane for the page: ${String(error)}`, { to: 'debug' })
685    }
686  }
687  for (const argv of openCommands(url)) {
688    try {
689      const ran = await $.process.run(argv, { timeoutMs: 15000 })
690      if (ran.exitCode === 0) return url
691    } catch {
692      // try the next opener
693    }
694  }
695  $.ui.toast(`Plan Crutches: open ${url}`)
696  return url
697}
698
699// ---- plan versions ----------------------------------------------------------
700// Every version of the plan is kept: its file as received and the plan read
701// from it, one JSON file per version under the project's review folder. The
702// page lists them, shows an earlier one, compares the current plan with one,
703// and brings one back as the next version.
704
705const versionCache = new Map<number, SavedVersion>()
706
707/** Writes a version to its file (not for the demo) and returns the row the state keeps. */
708async function saveVersion($: EngineInterface, saved: SavedVersion, isDemo: boolean): Promise<PlanVersion> {
709  versionCache.set(saved.version, saved)
710  let path = ''
711  if (!isDemo) {
712    const dir = await reviewDir($)
713    if (dir) {
714      path = versionFile(dir, saved.version)
715      try {
716        await $.fs.write(path, serializeVersion(saved))
717      } catch (error) {
718        $.ui.log(`plan-crutches: could not write ${path}: ${String(error)}`, { to: 'debug' })
719        path = ''
720      }
721    }
722  }
723  return versionEntry(saved, path)
724}
725
726/** A saved version, from this process's memory or from its file. */
727async function loadVersion($: EngineInterface, version: number): Promise<SavedVersion | undefined> {
728  const cached = versionCache.get(version)
729  if (cached) return cached
730  const entry = (await read($, versionsAtom)).find(one => one.version === version)
731  if (!entry || !entry.path) return undefined
732  try {
733    if (!(await $.fs.exists(entry.path))) return undefined
734    const saved = parseSavedVersion(await $.fs.read(entry.path))
735    if (saved) versionCache.set(version, saved)
736    return saved ?? undefined
737  } catch (error) {
738    $.ui.log(`plan-crutches: could not read ${entry.path}: ${String(error)}`, { to: 'debug' })
739    return undefined
740  }
741}
742
743async function viewVersion($: EngineInterface, version: number | null): Promise<void> {
744  const review = await read($, reviewAtom)
745  if (version === null || version === review.version) {
746    await update($, viewingAtom, () => null)
747    changed($)
748    return
749  }
750  const saved = await loadVersion($, version)
751  if (!saved) {
752    setNotice(`Version ${version} is not available.`)
753    changed($)
754    return
755  }
756  await update($, viewingAtom, () => ({ version: saved.version, plan: saved.plan }))
757  changed($)
758}
759
760async function compareWith($: EngineInterface, version: number | null): Promise<void> {
761  if (version === null) {
762    await update($, compareAtom, () => null)
763    changed($)
764    return
765  }
766  const saved = await loadVersion($, version)
767  if (!saved) {
768    setNotice(`Version ${version} is not available.`)
769    changed($)
770    return
771  }
772  await update($, compareAtom, () => ({ version: saved.version, plan: saved.plan }))
773  changed($)
774}
775
776/** Brings an earlier version back as the next version: its plan file text is written again and received anew. */
777async function restoreVersion($: EngineInterface, version: number): Promise<void> {
778  const [plan, review] = await Promise.all([read($, planAtom), read($, reviewAtom)])
779  if (!plan || review.status !== 'reviewing') {
780    setNotice(review.status === 'revising' ? 'Claude is revising the plan; a version can come back once the new version has arrived.' : 'No plan is under review.')
781    changed($)
782    return
783  }
784  if (version === review.version) return
785  const saved = await loadVersion($, version)
786  if (!saved) {
787    setNotice(`Version ${version} is not available.`)
788    changed($)
789    return
790  }
791  if (!review.isDemo && review.planFilePath) {
792    try {
793      await $.fs.write(review.planFilePath, saved.markdown)
794    } catch (error) {
795      setNotice(`Could not write the plan file: ${String(error)}`)
796      changed($)
797      return
798    }
799  }
800  const next = await receivePlan($, {
801    markdown: saved.markdown,
802    ...(review.planFilePath ? { planFilePath: review.planFilePath } : {}),
803    isDemo: Boolean(review.isDemo),
804    prebuilt: { plan: saved.plan, parsedFrom: saved.parsedFrom, warnings: saved.warnings },
805    source: 'restored',
806    from: version,
807  })
808  setNotice(`Version ${next} is version ${version} brought back.`)
809  changed($)
810}
811
812// ---- the review -------------------------------------------------------------
813
814async function resetAll($: EngineInterface): Promise<void> {
815  await update($, planAtom, () => null)
816  await update($, reviewAtom, () => EMPTY_REVIEW)
817  await update($, commentsAtom, () => [])
818  await update($, diagramAtom, () => EMPTY_DIAGRAM)
819  await update($, qaAtom, () => [])
820  await update($, bodiesAtom, () => ({}))
821  await update($, filesAtom, () => ({}))
822  await update($, taskAtom, () => ({}))
823  await update($, okAtom, () => ({}))
824  await update($, explainAtom, () => ({}))
825  await update($, versionsAtom, () => [])
826  await update($, compareAtom, () => null)
827  await update($, viewingAtom, () => null)
828  await update($, uiAtom, freshUi)
829  versionCache.clear()
830  await clearStore($)
831  notice = undefined
832  changed($)
833}
834
835async function isoNow($: EngineInterface): Promise<string> {
836  return new Date(await $.clock.now()).toISOString()
837}
838
839async function findLatestPlanFile($: EngineInterface): Promise<string | undefined> {
840  const home = await homeDir($)
841  if (!home) return undefined
842  const dir = `${home}/.claude/plans`
843  if (!(await $.fs.exists(dir))) return undefined
844  return newestPlanFile(dir, await $.fs.list(dir))
845}
846
847/**
848 * The plan to open when the person names none: the file this session already
849 * knows (from plan mode, an earlier open or a restore), while it is still on
850 * disk, else the newest plan file under `~/.claude/plans`.
851 */
852async function findPlanFile($: EngineInterface): Promise<FoundPlan | undefined> {
853  const review = await read($, reviewAtom)
854  if (review.planFilePath && (await $.fs.exists(review.planFilePath).catch(() => false))) return { path: review.planFilePath, source: 'review' }
855  const newest = await findLatestPlanFile($).catch(() => undefined)
856  return newest ? { path: newest, source: 'newest' } : undefined
857}
858
859function applyTask(plan: PlanModel, task: TaskCapture): void {
860  if (task.jira?.text) {
861    plan.task = {
862      source: 'jira',
863      text: task.jira.text,
864      ...(task.jira.key ?? plan.task.key ? { key: task.jira.key ?? plan.task.key } : {}),
865      ...(plan.task.title ? { title: plan.task.title } : {}),
866    }
867    return
868  }
869  if (plan.task.text) return
870  if (task.userPrompt) plan.task = { source: 'user', text: task.userPrompt }
871}
872
873async function writeRecord($: EngineInterface): Promise<void> {
874  const [plan, review, comments, diagram, qa, ok, versions] = await Promise.all([
875    read($, planAtom),
876    read($, reviewAtom),
877    read($, commentsAtom),
878    read($, diagramAtom),
879    read($, qaAtom),
880    read($, okAtom),
881    read($, versionsAtom),
882  ])
883  if (!plan || review.isDemo || !review.recordPath) return
884  try {
885    const text = renderRecord({ plan, review, comments, diagram, qa, ok, versions, nowMs: await $.clock.now() })
886    await $.fs.write(review.recordPath, text)
887  } catch (error) {
888    $.ui.log(`plan-crutches: could not write ${review.recordPath}: ${String(error)}`, { to: 'debug' })
889  }
890}
891
892type BuiltPlan = { plan: PlanModel; parsedFrom: ParsedFrom; warnings: string[] }
893
894async function buildPlan($: EngineInterface, markdown: string): Promise<BuiltPlan> {
895  const parsed = parsePlan(markdown)
896  if (parsed.ok) return { plan: parsed.plan, parsedFrom: 'block', warnings: parsed.warnings }
897  const warnings = [`${parsed.reason}; structure extracted by ${options.model}`, ...parsed.warnings]
898  try {
899    const reply = await $.model.complete({
900      model: options.model,
901      system: EXTRACTION_SYSTEM,
902      prompt: extractionPrompt(markdown),
903      maxTokens: 8000,
904      timeoutMs: 180_000,
905    })
906    if (reply.isAnswered) {
907      const plan = normalizePlan(extractJson(reply.text), warnings, titleFromMarkdown(markdown))
908      if (plan) {
909        if (!plan.summary) plan.summary = planFromMarkdown(markdown).summary
910        return { plan, parsedFrom: 'model', warnings }
911      }
912      warnings.push('the model reply was not a plan object')
913    } else {
914      warnings.push(`model extraction failed: ${reply.reason}`)
915    }
916  } catch (error) {
917    warnings.push(`model extraction failed: ${String(error)}`)
918  }
919  return { plan: planFromMarkdown(markdown), parsedFrom: 'markdown', warnings }
920}
921
922async function openPane($: EngineInterface, focus: boolean): Promise<void> {
923  try {
924    const opened = await $.ui.open(focus ? { id: PANE_ID, title: PANE_TITLE, focus: true } : { id: PANE_ID, title: PANE_TITLE })
925    if (!opened.isPlaced) $.ui.log(`plan-crutches: the pane waits undrawn: ${opened.reason}`, { to: 'debug' })
926  } catch (error) {
927    $.ui.log(`plan-crutches: could not open the pane: ${String(error)}`, { to: 'debug' })
928  }
929}
930
931/** A plan coming in: from plan mode or a file (received), or an earlier version brought back (restored). */
932type Incoming = {
933  markdown: string
934  planFilePath?: string
935  isDemo: boolean
936  /** The plan already read from the markdown, when a saved version holds it. */
937  prebuilt?: BuiltPlan
938  source?: VersionSource
939  from?: number
940}
941
942async function receivePlan($: EngineInterface, incoming: Incoming): Promise<number> {
943  const { markdown, planFilePath, isDemo } = incoming
944  const review = await read($, reviewAtom)
945  const previous = await read($, planAtom)
946  const isRevision = !isDemo && (review.status === 'revising' || review.status === 'reviewing') && previous !== null
947  const built = incoming.prebuilt ?? (await buildPlan($, markdown))
948  const plan = built.plan
949  if (!isDemo && !incoming.prebuilt) applyTask(plan, await read($, taskAtom))
950  const version = isRevision ? review.version + 1 : 1
951  const now = await isoNow($)
952  const source: VersionSource = incoming.source ?? 'received'
953
954  if (isRevision) {
955    await update($, commentsAtom, list =>
956      list.map(one => (one.sentInVersion !== undefined && one.resolvedInVersion === undefined ? { ...one, resolvedInVersion: version } : one)),
957    )
958    // An OK stays only on a file the new version plans the same way.
959    await update($, okAtom, marks => keepOkMarks(marks, previous, plan))
960    await update($, compareAtom, () => (previous ? { version: review.version, plan: previous } : null))
961  } else {
962    await update($, commentsAtom, () => [])
963    await update($, diagramAtom, () => EMPTY_DIAGRAM)
964    await update($, qaAtom, () => [])
965    await update($, okAtom, () => ({}))
966    await update($, versionsAtom, () => [])
967    await update($, compareAtom, () => null)
968    versionCache.clear()
969  }
970  await update($, explainAtom, () => ({}))
971  await update($, viewingAtom, () => null)
972  await update($, planAtom, () => plan)
973  await update($, bodiesAtom, () => ({}))
974  await update($, filesAtom, () => ({}))
975  await update($, uiAtom, ui => ({ ...freshUi(ui), collapsed: isRevision ? ui.collapsed : [] }))
976
977  let recordPath: string | undefined
978  if (!isDemo) {
979    if (isRevision && review.recordPath) {
980      recordPath = review.recordPath
981    } else {
982      const [cwd, home] = await Promise.all([sessionCwd($), homeDir($)])
983      recordPath = `${recordsDir(options.plansDir, cwd, home)}/${recordFileName(await $.clock.now(), plan)}`
984    }
985  }
986  const entry = await saveVersion(
987    $,
988    buildSavedVersion({ version, receivedAt: now, markdown, plan, parsedFrom: built.parsedFrom, warnings: built.warnings, source, ...(incoming.from !== undefined ? { from: incoming.from } : {}) }),
989    isDemo,
990  )
991  await update($, versionsAtom, list => [...list.filter(one => one.version !== version), entry])
992  const line = source === 'restored' ? `v${version} restored from v${incoming.from ?? '?'} ${now}` : `v${version} received ${now}`
993  await update($, reviewAtom, current => ({
994    status: 'reviewing' as const,
995    version,
996    parsedFrom: built.parsedFrom,
997    warnings: built.warnings,
998    previousPaths: previous?.files.map(file => file.path) ?? [],
999    history: [...(isRevision ? current.history : []), line],
1000    ...(planFilePath ? { planFilePath } : {}),
1001    ...(recordPath ? { recordPath } : {}),
1002    ...(isRevision && current.diagramSvgPath ? { diagramSvgPath: current.diagramSvgPath } : {}),
1003    ...(isDemo ? { isDemo: true } : {}),
1004  }))
1005  await writeRecord($)
1006  changed($)
1007  return version
1008}
1009
1010/**
1011 * Reviews a plan file without plan mode, the way an ExitPlanMode call is
1012 * reviewed: the file at `given`, or, with no path, the one `findPlanFile`
1013 * picks. The file already under review gets its next version, so the
1014 * comments stay; any other live review is reset first.
1015 */
1016async function openPlanFile($: EngineInterface, given: string): Promise<string> {
1017  let planFilePath: string
1018  let note = ''
1019  if (given) {
1020    planFilePath = resolvePlanPath(given, await sessionCwd($), await homeDir($))
1021    const exists = await $.fs.exists(planFilePath).catch(() => false)
1022    if (!exists) return `Plan Crutches: no file at ${planFilePath}.`
1023  } else {
1024    const found = await findPlanFile($)
1025    if (!found) return 'Plan Crutches: no plan found: this session has no plan file and ~/.claude/plans holds none. Name one: /plan-review open <path>.'
1026    planFilePath = found.path
1027    note = `, ${planSourceNote(found.source)}`
1028  }
1029  let markdown: string
1030  try {
1031    markdown = await $.fs.read(planFilePath)
1032  } catch (error) {
1033    return `Plan Crutches: could not read ${planFilePath}: ${String(error)}`
1034  }
1035  if (!markdown.trim()) return `Plan Crutches: ${planFilePath} is empty.`
1036  const review = await read($, reviewAtom)
1037  const sameFile = !review.isDemo && isRestorable(review) && review.planFilePath === planFilePath
1038  if (!sameFile) await resetAll($)
1039  const version = await receivePlan($, { markdown, planFilePath, isDemo: false })
1040  const url = await openWeb($, true)
1041  await openPane($, true)
1042  return `Plan Crutches: plan v${version} opened from ${planFilePath}${note}.${url ? ` Page: ${url}` : ''}`
1043}
1044
1045async function submit($: EngineInterface): Promise<void> {
1046  const [plan, review, comments, ok] = await Promise.all([read($, planAtom), read($, reviewAtom), read($, commentsAtom), read($, okAtom)])
1047  if (!plan || review.status !== 'reviewing') return
1048  const pending = comments.filter(one => one.sentInVersion === undefined && one.resolvedInVersion === undefined)
1049  const now = await isoNow($)
1050  const notes = { okPaths: Object.keys(ok).sort(), summaryEdited: review.summaryEditedInVersion === review.version }
1051  if (review.isDemo) {
1052    const text = pending.length ? `Demo: ${pending.length} comments would go to Claude as a revision request.` : 'Demo: the plan would be approved.'
1053    $.ui.toast(text)
1054    setNotice(text)
1055    changed($)
1056    return
1057  }
1058  if (pending.length === 0) {
1059    await update($, reviewAtom, current => ({ ...current, status: 'approved' as const, history: [...current.history, `v${review.version} approved ${now}`] }))
1060    try {
1061      await $.prompt.submit({ text: approvePrompt(review.version, notes), asUser: true })
1062    } catch (error) {
1063      await update($, reviewAtom, current => ({ ...current, status: 'reviewing' as const }))
1064      $.ui.toast(`Plan Crutches: could not submit: ${String(error)}`)
1065      setNotice(`Could not submit: ${String(error)}`)
1066      changed($)
1067      return
1068    }
1069    await writeRecord($)
1070    changed($)
1071    return
1072  }
1073  const sentIds = new Set(pending.map(one => one.id))
1074  await update($, commentsAtom, list => list.map(one => (sentIds.has(one.id) ? { ...one, sentInVersion: review.version } : one)))
1075  await update($, reviewAtom, current => ({
1076    ...current,
1077    status: 'revising' as const,
1078    history: [...current.history, `v${review.version} sent back with ${pending.length} comments ${now}`],
1079  }))
1080  try {
1081    await $.prompt.submit({ text: revisionPrompt(plan, pending, review.version, notes), asUser: true })
1082  } catch (error) {
1083    await update($, commentsAtom, list => list.map(one => (sentIds.has(one.id) ? { id: one.id, target: one.target, text: one.text, createdInVersion: one.createdInVersion } : one)))
1084    await update($, reviewAtom, current => ({ ...current, status: 'reviewing' as const }))
1085    $.ui.toast(`Plan Crutches: could not submit: ${String(error)}`)
1086    setNotice(`Could not submit: ${String(error)}`)
1087    changed($)
1088    return
1089  }
1090  await writeRecord($)
1091  changed($)
1092}
1093
1094async function loadFile($: EngineInterface, path: string): Promise<void> {
1095  const files = await read($, filesAtom)
1096  if (files[path] !== undefined) return
1097  let content: string | null = null
1098  try {
1099    const base = await projectBase($)
1100    const full = `${base}/${path}`
1101    if (await $.fs.exists(full)) content = await $.fs.read(full)
1102  } catch {
1103    content = null
1104  }
1105  await update($, filesAtom, current => ({ ...current, [path]: content }))
1106  changed($)
1107}
1108
1109/** The project folder the plan's paths are relative to (the demo has its own). */
1110async function projectBase($: EngineInterface): Promise<string> {
1111  const review = await read($, reviewAtom)
1112  return review.isDemo ? `${$.plugin.root}/fixtures/demo-src` : await $.session.cwd()
1113}
1114
1115/** Reads one project file for a prompt; null when it is missing or unreadable. */
1116async function readProjectFile($: EngineInterface, base: string, path: string): Promise<string | null> {
1117  if (path.includes('..')) return null
1118  try {
1119    const full = `${base}/${path}`
1120    if (!(await $.fs.exists(full))) return null
1121    return await $.fs.read(full)
1122  } catch {
1123    return null
1124  }
1125}
1126
1127const MAX_LISTED_FILES = 4000
1128const SKIP_LISTED = /(^|\/)(node_modules|dist|build|vendor|\.git|coverage|target|__pycache__)(\/|$)|\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|lock|map|min\.js)$/i
1129
1130/** The project's tracked files from git, else nothing; the model picks from this list. */
1131async function listProjectFiles($: EngineInterface, base: string): Promise<string[]> {
1132  try {
1133    const ran = await $.process.run(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { cwd: base, timeoutMs: 20_000 })
1134    if (ran.exitCode !== 0) return []
1135    return ran.stdout
1136      .split('\n')
1137      .map(line => line.trim())
1138      .filter(line => line && !SKIP_LISTED.test(line))
1139      .slice(0, MAX_LISTED_FILES)
1140  } catch {
1141    return []
1142  }
1143}
1144
1145/**
1146 * The real code the implementation needs: the other planned files as they are
1147 * on disk, what the current file imports, and the files a cheap model picks
1148 * from the project's file list.
1149 */
1150async function gatherRelatedFiles($: EngineInterface, plan: PlanModel, file: PlanFile, symbol: PlanSymbol, fileText: string | null): Promise<RelatedFile[]> {
1151  const base = await projectBase($)
1152  const related: RelatedFile[] = []
1153  const seen = new Set<string>([file.path])
1154  const add = async (path: string, why: string): Promise<void> => {
1155    if (seen.has(path) || related.length >= 24) return
1156    seen.add(path)
1157    const text = await readProjectFile($, base, path)
1158    if (text !== null && text.trim()) related.push({ path, text, why })
1159  }
1160  for (const other of plan.files) {
1161    if (other.path !== file.path && other.status !== 'new') await add(other.path, `planned ${other.status}`)
1162  }
1163  const sources = [fileText ?? '', symbol.signature, ...file.symbols.map(one => one.signature)]
1164  for (const spec of relativeImports(sources.join('\n'))) {
1165    for (const candidate of importCandidates(file.path, spec)) {
1166      if (seen.has(candidate)) break
1167      if (await $.fs.exists(`${base}/${candidate}`).catch(() => false)) {
1168        await add(candidate, `imported by ${file.path}`)
1169        break
1170      }
1171    }
1172  }
1173  const listed = await listProjectFiles($, base)
1174  if (listed.length === 0) return related
1175  try {
1176    const picked = await $.model.complete({
1177      model: options.model,
1178      system: PICK_FILES_SYSTEM,
1179      prompt: pickFilesPrompt(plan, file, symbol, fileText, listed, [...seen]),
1180      maxTokens: 400,
1181      effort: 'low',
1182      timeoutMs: 60_000,
1183    })
1184    if (picked.isAnswered) {
1185      for (const path of parseFileList(picked.text, new Set(listed))) await add(path, 'chosen as context')
1186    } else {
1187      $.ui.log(`plan-crutches: file pick for ${symbol.name} failed: ${picked.reason}`, { to: 'debug' })
1188    }
1189  } catch (error) {
1190    $.ui.log(`plan-crutches: file pick for ${symbol.name} failed: ${String(error)}`, { to: 'debug' })
1191  }
1192  return related
1193}
1194
1195async function generateBody($: EngineInterface, file: PlanFile, symbol: PlanSymbol): Promise<void> {
1196  const key = bodyKey(file.path, symbol.name)
1197  const plan = await read($, planAtom)
1198  if (!plan) return
1199  await update($, bodiesAtom, current => ({ ...current, [key]: { status: 'loading' as const } }))
1200  changed($)
hooks/demo-plan.ts 110 lines
1// Generated from fixtures/demo-plan.md. Edit the fixture and regenerate.
2export const DEMO_PLAN = `# Session refresh for the auth module
3
4## Context
5
6Users are logged out when the access token expires because nothing refreshes the session. The task (AUTH-42) asks for a silent refresh and for the removal of the legacy token store.
7
8## Approach
9
101. Add a \`session.ts\` module with a refresh function and a small in-memory cache.
112. Teach \`user.ts\` to call the refresh when a token is close to expiry.
123. Expose a \`/auth/refresh\` route.
134. Delete the legacy token store.
145. Document the flow.
15
16## Risks
17
18- The refresh endpoint must not be callable without the refresh cookie.
19- The cache is per process; a multi-instance deploy needs Redis later.
20
21\`\`\`plan-crutches
22{
23  "title": "Session refresh for the auth module",
24  "summary": "Add a silent session refresh so users stay signed in.\\n\\n- New \`src/auth/session.ts\` holds \`refreshSession\` and a small cache.\\n- \`src/auth/user.ts\` calls the refresh when a token is within 60 seconds of expiry.\\n- \`src/api/routes.ts\` gains \`POST /auth/refresh\`, guarded by the refresh cookie.\\n- The legacy \`src/legacy/tokens.ts\` store goes away.\\n- \`docs/auth.md\` explains the flow.\\n\\nRisks: the cache is per process; a multi-instance deploy needs Redis later.",
25  "task": {
26    "source": "jira",
27    "key": "AUTH-42",
28    "title": "Users get logged out after 15 minutes",
29    "text": "As a signed-in user I want to stay signed in while I use the app.\\n\\nAcceptance criteria:\\n- The access token is refreshed silently before it expires.\\n- A failed refresh sends the user to the login page with a message.\\n- The old token store in src/legacy is removed."
30  },
31  "files": [
32    {
33      "path": "src/auth/session.ts",
34      "status": "new",
35      "description": "Session refresh logic and an in-memory cache of refresh promises, so parallel callers share one request.",
36      "symbols": [
37        {
38          "name": "Session",
39          "kind": "type",
40          "signature": "export type Session = { userId: string; accessToken: string; expiresAt: number }",
41          "description": "The session as the client keeps it."
42        },
43        {
44          "name": "refreshSession",
45          "kind": "function",
46          "signature": "export async function refreshSession(userId: string): Promise<Session> {",
47          "description": "Calls the refresh endpoint once per user at a time and returns the new session.",
48          "anchor": "after Session"
49        },
50        {
51          "name": "isExpiringSoon",
52          "kind": "function",
53          "signature": "export function isExpiringSoon(session: Session, now: number, marginMs = 60_000): boolean {",
54          "description": "True when the access token expires within the margin.",
55          "anchor": "end of file"
56        }
57      ]
58    },
59    {
60      "path": "src/auth/user.ts",
61      "status": "modified",
62      "description": "Refresh the session before returning the current user when the token is close to expiry; drop the display-name helper.",
63      "symbols": [
64        {
65          "name": "getCurrentUser",
66          "kind": "function",
67          "change": "modified",
68          "signature": "export async function getCurrentUser(): Promise<User | null> {",
69          "description": "Returns the current user, refreshing the session first when isExpiringSoon says so."
70        },
71        {
72          "name": "displayNameOf",
73          "kind": "function",
74          "change": "deleted",
75          "signature": "export function displayNameOf(user: User): string {",
76          "description": "Moves to the presentation layer; nothing in auth uses it."
77        }
78      ]
79    },
80    {
81      "path": "src/api/routes.ts",
82      "status": "modified",
83      "description": "Add the refresh route.",
84      "symbols": [
85        {
86          "name": "refreshRoute",
87          "kind": "const",
88          "signature": "export const refreshRoute = route.post('/auth/refresh', requireRefreshCookie, handleRefresh)",
89          "description": "POST /auth/refresh, allowed only with the refresh cookie.",
90          "anchor": "after loginRoute"
91        }
92      ]
93    },
94    {
95      "path": "src/legacy/tokens.ts",
96      "status": "deleted",
97      "description": "The legacy token store is replaced by session.ts.",
98      "symbols": []
99    },
100    {
101      "path": "docs/auth.md",
102      "status": "new",
103      "description": "Explains the refresh flow and the cookie requirement.",
104      "symbols": []
105    }
106  ]
107}
108\`\`\`
109`
110
hooks/diagram.ts 399 lines
1// The change diagram, the pure part: the prompts a clean session answers, the
2// readers of its answers, the text search for callers and the record text.
3// register.tsx runs the session and keeps the result in `$.state.diagram`.
4import type { CallersQuery, DiagramCaller, DiagramEdge, DiagramFile, DiagramGraph, DiagramRef, DiagramSymbol, PlanModel, SymbolKind } from '../types'
5import { extractJson } from './plan-parser'
6import { planSurface } from './prompts'
7
8/** How many callers one query shows; the rest is one "others" block. */
9export const CALLERS_LIMIT = 20
10/** How many text-search hits go to the session as candidates. */
11export const CANDIDATE_LIMIT = 150
12/** How much of the plan the graph question carries: the whole plan, within reason. */
13const PLAN_TEXT_LIMIT = 120000
14const FILE_TEXT_LIMIT = 16000
15const FILES_TEXT_LIMIT = 150000
16const KINDS: readonly SymbolKind[] = ['function', 'method', 'class', 'type', 'const', 'other']
17
18export const DIAGRAM_AGENT_NAME = 'diagram'
19export const DIAGRAM_AGENT_DESCRIPTION = 'Plan Crutches: maps the calls between the symbols of an implementation plan. Read-only; the mod spawns it, never the model.'
20
21/** The system prompt of the clean session: it reads code and answers JSON. */
22export const DIAGRAM_AGENT_PROMPT =
23  'You are a code analyst in a clean session with read-only tools (Read, Grep, Glob). ' +
24  'You get an implementation plan and one question about the calls between symbols. ' +
25  'Read the project files when the answer depends on them; a text-search candidate list, when given, is a start, not the truth. ' +
26  'Answer with one JSON object only: no prose before or after it, no code fence.'
27
28/** The system prompt of the fallback completion, which has no tools. */
29export const DIAGRAM_SYSTEM =
30  'You are a code analyst. You get an implementation plan, the current text of its files and one question about the calls between symbols. ' +
31  'Answer from the text given. Answer with one JSON object only: no prose, no code fence.'
32
33const GRAPH_SHAPE = `{
34  "files": [
35    { "path": "relative/path.ts", "symbols": [ { "name": "symbolName", "kind": "function" | "method" | "class" | "type" | "const" | "other" } ] }
36  ],
37  "edges": [
38    { "from": { "path": "relative/path.ts", "symbol": "caller" }, "to": { "path": "relative/other.ts", "symbol": "callee" }, "kind": "call" | "use" }
39  ]
40}`
41
42const CALLERS_SHAPE = `{
43  "callers": [ { "path": "relative/path.ts", "symbol": "enclosingSymbol", "kind": "function" | "method" | "class" | "type" | "const" | "other", "line": 12 } ],
44  "total": 3
45}`
46
47/**
48 * Test files and folders (tests/, __tests__/, spec/, e2e/, fixtures/, *.test.*,
49 * *.spec.*, *_test.go, test_*.py, *Test.java …) stay out of the diagram: not as
50 * blocks of the plan, not as callers.
51 */
52const TEST_PATH =
53  /(^|\/)(tests?|__tests__|specs?|e2e|cypress|__mocks__|__snapshots__|testdata|fixtures)(\/|$)|(^|\/)(test_[^/]*\.py|conftest\.py)$|[._-](test|spec|tests|specs)\.[a-z0-9]+$|_test\.(go|rs|py|rb|ex|exs|c|cc|cpp)$|(Test|Tests|Spec|IT)\.(java|kt|kts|cs|php|scala|swift|groovy)$|\.feature$/i
54
55export function isTestPath(path: string): boolean {
56  return TEST_PATH.test(normalizePath(path))
57}
58
59function cut(text: string, limit: number): string {
60  return text.length > limit ? `${text.slice(0, limit)}\n… (cut, ${text.length - limit} more characters)` : text
61}
62
63function filesText(texts: Readonly<Record<string, string | null>>): string {
64  const blocks: string[] = []
65  let used = 0
66  for (const [path, text] of Object.entries(texts)) {
67    if (text === null || !text.trim()) continue
68    const block = `${path}:\n\`\`\`\n${cut(text, FILE_TEXT_LIMIT)}\n\`\`\``
69    if (used + block.length > FILES_TEXT_LIMIT) {
70      blocks.push(`… ${Object.keys(texts).length - blocks.length} more files not shown`)
71      break
72    }
73    blocks.push(block)
74    used += block.length
75  }
76  return blocks.length ? `Current content of the planned files that exist in the project:\n\n${blocks.join('\n\n')}` : ''
77}
78
79/**
80 * The question that builds the change graph: every planned file and symbol,
81 * and the calls between them. With `tools`, the session may read the project;
82 * without, the planned files' text in the prompt is all it has.
83 */
84export function diagramPrompt(plan: PlanModel, texts: Readonly<Record<string, string | null>>, options: { tools: boolean }): string {
85  const parts = [
86    'Build the call graph of the implementation plan below for an architecture diagram.',
87    planSurface(plan, PLAN_TEXT_LIMIT),
88  ]
89  const existing = filesText(texts)
90  if (existing) parts.push(existing)
91  parts.push(
92    `Answer with JSON of exactly this shape:\n${GRAPH_SHAPE}\n\n` +
93      'Rules:\n' +
94      '- List every planned file with every planned symbol (same paths and names as the plan). For a deleted file, list the symbols it exports today, since other code may still use them.\n' +
95      '- Skip test files and test folders (tests/, __tests__/, spec/, *.test.*, *.spec.*, fixtures): the diagram shows the production code only.\n' +
96      '- An edge from A to B means A calls B (kind "call") or A uses B as a type or a constant (kind "use"). Include edges between symbols of the same file.\n' +
97      '- Only edges between symbols listed in this answer. Use the plan descriptions and signatures' +
98      (options.tools ? ', and read the project files to confirm calls that exist in the current code.' : ' and the file texts given above.') +
99      '\n- No prose, no code fence: the JSON object only.',
100  )
101  return parts.join('\n\n')
102}
103
104/** One hit of the text search for a symbol, with the symbol that encloses the line. */
105export type CallerCandidate = { path: string; line: number; text: string; enclosing: string; kind: SymbolKind }
106
107/** The question that lists the callers of one symbol across the project; `root` names the folder paths are relative to. */
108export function callersPrompt(plan: PlanModel, ref: DiagramRef, candidates: readonly CallerCandidate[], limit = CALLERS_LIMIT, root?: string): string {
109  const planned = plan.files.find(file => file.path === ref.path)?.symbols.find(symbol => symbol.name === ref.symbol)
110  const parts = [
111    `Find every place in the project that calls or uses the symbol \`${ref.symbol}\` of ${ref.path}.`,
112    planSurface(plan, 12000),
113    `Symbol: ${ref.symbol} in ${ref.path}${planned ? `\nSignature:\n${planned.signature}\nWhat it does: ${planned.description}` : ''}`,
114  ]
115  if (candidates.length) {
116    const lines = candidates.map(one => `${one.path}:${one.line}: ${one.text.trim().slice(0, 160)}    [in ${one.enclosing}]`)
117    parts.push(`Text-search candidates (path:line: text [in enclosing symbol]):\n${lines.join('\n')}`)
118  } else {
119    parts.push('A text search for the name found nothing; the symbol may be new, or called under another name.')
120  }
121  parts.push(
122    `Answer with JSON of exactly this shape:\n${CALLERS_SHAPE}\n\n` +
123      'Rules:\n' +
124      `- One entry per enclosing symbol (the function, method, class or constant whose body holds the use); "(module)" for a use at module level. At most ${limit} entries, the most important first.\n` +
125      '- Drop false positives: the declaration itself, comments, strings, and other symbols that only share the name. An import line alone is no caller: name the function that uses the symbol. A re-export counts, as "(module)".\n' +
126      '- Add callers the text search missed (re-exports, calls through an interface, other spellings).\n' +
127      '- Skip uses in test files and test folders (tests/, __tests__/, spec/, *.test.*, *.spec.*, fixtures): they are not callers for this diagram.\n' +
128      '- "total" is the number of distinct callers you found, including the ones beyond the limit.\n' +
129      (root ? `- Write every path relative to the project folder ${root}, as the plan writes them.\n` : '') +
130      '- No prose, no code fence: the JSON object only.',
131  )
132  return parts.join('\n\n')
133}
134
135const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
136const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
137const asKind = (value: unknown): SymbolKind => ((KINDS as readonly string[]).includes(asString(value)) ? (asString(value) as SymbolKind) : 'other')
138
139export function normalizePath(path: string): string {
140  return path.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '')
141}
142
143/** A path with the first matching prefix removed (an absolute project folder, or the folder's path inside its git repository). */
144export function stripPrefixes(path: string, prefixes: readonly string[]): string {
145  const raw = path.trim().replace(/\\/g, '/').replace(/^\.\//, '')
146  for (const prefix of prefixes) {
147    if (prefix && raw.startsWith(prefix)) return normalizePath(raw.slice(prefix.length))
148  }
149  return normalizePath(raw)
150}
151
152function readRef(raw: unknown): DiagramRef | null {
153  if (!isRecord(raw)) return null
154  const path = normalizePath(asString(raw.path))
155  const symbol = asString(raw.symbol)
156  return path && symbol ? { path, symbol } : null
157}
158
159/**
160 * Turns the session's answer into the graph the page draws. The plan decides
161 * the blocks and their rows: every planned file with every planned symbol,
162 * whatever the answer forgot, without a cap; a deleted file takes the exports
163 * the answer found. Test files stay out. Edges stay only between rows that
164 * exist.
165 */
166export function normalizeGraph(raw: unknown, plan: PlanModel): DiagramGraph {
167  const answered = new Map<string, Record<string, unknown>>()
168  if (isRecord(raw) && Array.isArray(raw.files)) {
169    for (const item of raw.files) {
170      if (!isRecord(item)) continue
171      const path = normalizePath(asString(item.path))
172      if (path) answered.set(path, item)
173    }
174  }
175  const files: DiagramFile[] = []
176  for (const planned of plan.files) {
177    if (isTestPath(planned.path)) continue
178    const symbols: DiagramSymbol[] = planned.symbols.map(symbol => ({ name: symbol.name, kind: symbol.kind }))
179    if (symbols.length === 0) {
180      const found = answered.get(planned.path)
181      if (found && Array.isArray(found.symbols)) {
182        const seen = new Set<string>()
183        for (const item of found.symbols) {
184          if (!isRecord(item)) continue
185          const name = asString(item.name)
186          if (!name || seen.has(name)) continue
187          seen.add(name)
188          symbols.push({ name, kind: asKind(item.kind) })
189        }
190      }
191    }
192    files.push({ path: planned.path, status: planned.status, symbols })
193  }
194  const known = new Set<string>()
195  for (const file of files) for (const symbol of file.symbols) known.add(`${file.path}#${symbol.name}`)
196  const edges: DiagramEdge[] = []
197  const seenEdges = new Set<string>()
198  if (isRecord(raw) && Array.isArray(raw.edges)) {
199    for (const item of raw.edges) {
200      if (!isRecord(item)) continue
201      const from = readRef(item.from)
202      const to = readRef(item.to)
203      if (!from || !to) continue
204      if (!known.has(`${from.path}#${from.symbol}`) || !known.has(`${to.path}#${to.symbol}`)) continue
205      const key = `${from.path}#${from.symbol}>${to.path}#${to.symbol}`
206      if (seenEdges.has(key)) continue
207      seenEdges.add(key)
208      edges.push({ from, to, kind: asString(item.kind) === 'use' ? 'use' : 'call' })
209    }
210  }
211  return { files, edges }
212}
213
214/** The graph in a session's reply, or null when the reply holds no JSON object. */
215export function parseDiagramReply(text: string, plan: PlanModel): DiagramGraph | null {
216  try {
217    return normalizeGraph(extractJson(text), plan)
218  } catch {
219    return null
220  }
221}
222
223/** The callers in a session's reply, capped, their paths made relative to the project, test files left out; null when the reply holds no JSON object. */
224export function parseCallersReply(text: string, limit = CALLERS_LIMIT, prefixes: readonly string[] = []): { callers: DiagramCaller[]; more: number } | null {
225  let raw: unknown
226  try {
227    raw = extractJson(text)
228  } catch {
229    return null
230  }
231  if (!isRecord(raw) || !Array.isArray(raw.callers)) return null
232  const callers: DiagramCaller[] = []
233  const seen = new Set<string>()
234  let tests = 0
235  for (const item of raw.callers) {
236    if (!isRecord(item)) continue
237    const path = stripPrefixes(asString(item.path), prefixes)
238    const symbol = asString(item.symbol) || '(module)'
239    if (!path) continue
240    if (isTestPath(path)) {
241      tests += 1
242      continue
243    }
244    const key = `${path}#${symbol}`
245    if (seen.has(key)) continue
246    seen.add(key)
247    const caller: DiagramCaller = { path, symbol, kind: asKind(item.kind) }
248    if (typeof item.line === 'number' && Number.isFinite(item.line) && item.line > 0) caller.line = Math.floor(item.line)
249    callers.push(caller)
250  }
251  const counted = typeof raw.total === 'number' && Number.isFinite(raw.total) ? Math.floor(raw.total) - tests : 0
252  const total = Math.max(counted, callers.length)
253  const shown = callers.slice(0, limit)
254  return { callers: shown, more: Math.max(0, total - shown.length) }
255}
256
257// ---- the text search for callers --------------------------------------------
258
259const SKIP_PATH = /(^|\/)(node_modules|dist|build|vendor|\.git|coverage|target|__pycache__)(\/|$)|\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|lock|map|min\.js)$/i
260
261/** The lines of `git grep -n` (or `grep -rn`) output as hits, skipping generated folders. */
262export function parseGrepOutput(output: string, limit = CANDIDATE_LIMIT): { path: string; line: number; text: string }[] {
263  const hits: { path: string; line: number; text: string }[] = []
264  for (const raw of output.split('\n')) {
265    const match = raw.match(/^(.+?):(\d+):(.*)$/)
266    if (!match) continue
267    const path = normalizePath(match[1] ?? '')
268    if (!path || SKIP_PATH.test(path)) continue
269    hits.push({ path, line: Number(match[2]), text: match[3] ?? '' })
270    if (hits.length >= limit) break
271  }
272  return hits
273}
274
275const CONTROL = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'else', 'function', 'with', 'elif', 'except', 'match', 'when', 'try', 'do', 'new', 'await', 'yield', 'foreach', 'unless', 'until', 'case', 'typeof', 'throw'])
276
277type Declaration = { name: string; kind: SymbolKind }
278
279/** The symbol a line declares, by the usual forms of the usual languages, else null. */
280export function declarationOf(line: string): Declaration | null {
281  const text = line.trim()
282  if (!text || text.startsWith('//') || text.startsWith('#') || text.startsWith('*') || text.startsWith('/*')) return null
283  let match = text.match(/^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/)
284  if (match?.[1]) return { name: match[1], kind: 'function' }
285  match = text.match(/^(?:export\s+)?(?:abstract\s+)?(?:class|interface|enum|struct|trait|impl(?:<[^>]*>)?\s+(?:[\w:<>]+\s+for\s+)?|module|protocol|record)\s+([A-Za-z_$][\w$]*)/)
286  if (match?.[1]) return { name: match[1], kind: text.includes('class') || text.includes('impl') || text.includes('module') ? 'class' : 'type' }
287  match = text.match(/^(?:export\s+)?(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*=/)
288  if (match?.[1]) return { name: match[1], kind: 'type' }
289  match = text.match(/^(?:export\s+)?(?:const|let|var|val)\s+(?:\{[^}]*\}\s*=\s*)?([A-Za-z_$][\w$]*)/)
290  if (match?.[1]) return { name: match[1], kind: /=>|\bfunction\b/.test(text) ? 'function' : 'const' }
291  match = text.match(/^(?:async\s+)?def\s+([A-Za-z_]\w*)/)
292  if (match?.[1]) return { name: match[1], kind: 'function' }
293  match = text.match(/^def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)/)
294  if (match?.[1]) return { name: match[1], kind: 'method' }
295  match = text.match(/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/)
296  if (match?.[1]) return { name: match[1], kind: text.startsWith('func (') ? 'method' : 'function' }
297  match = text.match(/^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)/)
298  if (match?.[1]) return { name: match[1], kind: 'function' }
299  match = text.match(/^(?:(?:public|private|protected|internal|static|final|override|open|abstract|suspend|inline|operator)\s+)*fun\s+(?:<[^>]*>\s*)?(?:[\w.<>?]+\.)?([A-Za-z_]\w*)/)
300  if (match?.[1]) return { name: match[1], kind: 'method' }
301  match = text.match(/^(?:(?:public|private|protected|static|final|abstract)\s+)*function\s+&?([A-Za-z_]\w*)/)
302  if (match?.[1]) return { name: match[1], kind: 'method' }
303  // a method of a class or an object: `name(args) {`, `static async name<T>(a): R {`, `name(args):` in Python-like bodies
304  match = text.match(
305    /^(?:(?:public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|get|set|export|default|synchronized)\s+)*\*?\s*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::[^={;]+)?\s*(?:=>\s*)?\{?\s*$/,
306  )
307  if (match?.[1] && !CONTROL.has(match[1]) && (text.endsWith('{') || text.endsWith('=>'))) return { name: match[1], kind: 'method' }
308  // a typed method of Java, C# or C++: `public Foo bar(int x) {`
309  match = text.match(/^(?:(?:public|private|protected|internal|static|final|override|virtual|abstract|async)\s+)+[\w<>[\],.?]+\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*(?:throws\s+[\w, ]+)?\s*\{?\s*$/)
310  if (match?.[1] && !CONTROL.has(match[1])) return { name: match[1], kind: 'method' }
311  return null
312}
313
314function indentOf(line: string): number {
315  return line.length - line.trimStart().length
316}
317
318/**
319 * The symbol whose body holds line `index`: the nearest declaration above it
320 * with less indentation, named `Class.method` when a class encloses that one;
321 * `(module)` when nothing does. The line itself counts when it declares.
322 */
323export function enclosingSymbol(lines: readonly string[], index: number): Declaration {
324  const own = declarationOf(lines[index] ?? '')
325  if (own && indentOf(lines[index] ?? '') === 0) return own
326  let limit = indentOf(lines[index] ?? '')
327  let inner: Declaration | null = null
328  let innerIndent = limit
329  for (let i = index - 1; i >= 0 && limit > 0; i -= 1) {
330    const line = lines[i] ?? ''
331    if (!line.trim()) continue
332    const indent = indentOf(line)
333    if (indent >= limit) continue
334    limit = indent
335    const declared = declarationOf(line)
336    if (!declared) continue
337    if (!inner) {
338      inner = declared
339      innerIndent = indent
340      if (indent === 0) break
341      continue
342    }
343    if (declared.kind === 'class' && indent < innerIndent) return { name: `${declared.name}.${inner.name}`, kind: inner.kind === 'function' ? 'method' : inner.kind }
344    if (indent === 0) break
345  }
346  return inner ?? own ?? { name: '(module)', kind: 'other' }
347}
348
349/** Whether a line only imports names: the use that matters is elsewhere in the file. */
350export function isImportLine(text: string): boolean {
351  const line = text.trim()
352  return /^import\b(?!\s*\()/.test(line) || /^from\s+\S+\s+import\b/.test(line) || /^(?:const|let|var)\s+\{[^}]*\}\s*=\s*require\(/.test(line) || /^use\s+[\w:]+/.test(line)
353}
354
355/** Whether a hit line is the declaration of the symbol itself, which is no caller. */
356export function isDeclarationOf(text: string, symbol: string): boolean {
357  const declared = declarationOf(text)
358  return declared !== null && declared.name === symbol
359}
360
361/**
362 * Hits grouped into callers, one per enclosing symbol, capped, test files left
363 * out; what the page shows when no session answered.
364 */
365export function groupCandidates(candidates: readonly CallerCandidate[], limit = CALLERS_LIMIT): { callers: DiagramCaller[]; more: number } {
366  const callers: DiagramCaller[] = []
367  const seen = new Set<string>()
368  for (const one of candidates) {
369    if (isTestPath(one.path)) continue
370    const key = `${one.path}#${one.enclosing}`
371    if (seen.has(key)) continue
372    seen.add(key)
373    callers.push({ path: one.path, symbol: one.enclosing, kind: one.kind, line: one.line })
374  }
375  const shown = callers.slice(0, limit)
376  return { callers: shown, more: callers.length - shown.length }
377}
378
379/** The empty callers query, as the state holds it while a session runs. */
380export function loadingCallers(): CallersQuery {
381  return { status: 'loading', callers: [], more: 0 }
382}
383
384/** The graph as text, for the Markdown record. */
385export function diagramText(graph: DiagramGraph | undefined): string {
386  if (!graph || graph.files.length === 0) return ''
387  const lines: string[] = ['Files:']
388  for (const file of graph.files) {
389    lines.push(`- [${file.status}] ${file.path}${file.symbols.length ? `: ${file.symbols.map(symbol => symbol.name).join(', ')}` : ''}`)
390  }
391  if (graph.edges.length) {
392    lines.push('Calls:')
393    for (const edge of graph.edges) {
394      lines.push(`- ${edge.from.path}#${edge.from.symbol} -> ${edge.to.path}#${edge.to.symbol}${edge.kind === 'use' ? ' (uses)' : ''}`)
395    }
396  }
397  return lines.join('\n')
398}
399
hooks/diff.ts 365 lines
1// The diffs the page draws for a planned file: a new symbol adds its stub at
2// its anchor, a modified symbol replaces the code it has today with the stub,
3// a deleted symbol removes that code. Pure, and free of imports other than
4// types, so scripts/demo-state.mjs can load it under plain node.
5import type { PlanFile, PlanSymbol, SymbolChange } from '../types'
6
7const CONTEXT = 2
8const MAX_DELETED_LINES = 200
9
10/** The line-comment opener for a file, by its extension. */
11export function commentPrefix(path: string): string {
12  const ext = (path.split('.').pop() ?? '').toLowerCase()
13  if (['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'pl', 'r', 'dockerfile', 'makefile', 'env'].includes(ext)) return '#'
14  if (['sql', 'lua', 'hs'].includes(ext)) return '--'
15  if (['html', 'htm', 'xml', 'vue', 'svelte', 'md'].includes(ext)) return '<!--'
16  if (['lisp', 'clj', 'el'].includes(ext)) return ';;'
17  return '//'
18}
19
20function commentLine(path: string, text: string): string {
21  const prefix = commentPrefix(path)
22  if (prefix === '<!--') return `<!-- ${text} -->`
23  return `${prefix} ${text}`
24}
25
26/** What the plan does to a symbol; a symbol without the field is new. */
27export const changeOf = (symbol: Pick<PlanSymbol, 'change'>): SymbolChange => symbol.change ?? 'new'
28
29/** The lines a symbol adds to its file: a comment, the signature, a todo. */
30export function stubLines(file: PlanFile, symbol: PlanSymbol): string[] {
31  const lines: string[] = []
32  if (symbol.description) {
33    for (const part of symbol.description.split('\n')) lines.push(commentLine(file.path, part.trim()))
34  }
35  for (const part of symbol.signature.split('\n')) lines.push(part)
36  const last = symbol.signature.trimEnd()
37  if (last.endsWith('{')) {
38    lines.push('  ' + commentLine(file.path, 'implementation: expand in Plan Crutches'))
39    lines.push('}')
40  } else if (last.endsWith(':')) {
41    lines.push('    ' + commentLine(file.path, 'implementation: expand in Plan Crutches'))
42  } else {
43    lines.push(commentLine(file.path, 'implementation: expand in Plan Crutches'))
44  }
45  return lines
46}
47
48/** A new file's preview: every symbol's stub, blank lines between. */
49export function newFileStub(file: PlanFile): string {
50  const blocks: string[] = []
51  if (file.description) blocks.push(commentLine(file.path, file.description))
52  for (const symbol of file.symbols) blocks.push(stubLines(file, symbol).join('\n'))
53  return blocks.join('\n\n')
54}
55
56function indentOf(line: string): number {
57  return line.length - line.trimStart().length
58}
59
60function identifierIn(anchor: string, keyword: 'after' | 'before'): string | null {
61  const match = anchor.match(new RegExp(`\\b${keyword}\\b\\s+([A-Za-z_$][\\w$.]*)`, 'i'))
62  return match?.[1] ?? null
63}
64
65/** The end (exclusive) of the indented block that starts at `start`, by indentation alone. */
66function blockEnd(lines: readonly string[], start: number): number {
67  const indent = indentOf(lines[start] ?? '')
68  for (let i = start + 1; i < lines.length; i++) {
69    const line = lines[i] ?? ''
70    if (line.trim() === '') continue
71    if (indentOf(line) > indent) continue
72    const trimmed = line.trim()
73    if (trimmed.startsWith('}') || trimmed.startsWith(')') || trimmed.startsWith(']') || /^end\b/.test(trimmed)) return i + 1
74    return i
75  }
76  return lines.length
77}
78
79function lastImportEnd(lines: readonly string[]): number {
80  let end = 0
81  for (let i = 0; i < lines.length; i++) {
82    const trimmed = (lines[i] ?? '').trim()
83    if (/^(import\b|from\b.*\bimport\b|require\(|using\b|#include\b|package\b)/.test(trimmed)) end = i + 1
84    else if (trimmed !== '' && end > 0 && !trimmed.startsWith('//') && !trimmed.startsWith('#')) break
85  }
86  return end
87}
88
89/** Where a symbol's lines go in the old file, from its anchor text. */
90export function insertionIndex(lines: readonly string[], symbol: PlanSymbol): number {
91  const anchor = (symbol.anchor ?? '').trim()
92  if (!anchor) return lines.length
93  const lower = anchor.toLowerCase()
94  if (/\b(top|start|beginning)\b/.test(lower)) return lastImportEnd(lines)
95  if (/\b(end|bottom)\b/.test(lower)) return lines.length
96  const after = identifierIn(anchor, 'after')
97  const before = identifierIn(anchor, 'before')
98  const target = after ?? before
99  if (!target) return lines.length
100  const index = lines.findIndex(line => line.includes(target))
101  if (index < 0) return lines.length
102  return after ? blockEnd(lines, index) : index
103}
104
105// ---- finding the code a symbol has today -----------------------------------
106
107const MODIFIERS =
108  '(?:(?:export|default|public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|get|set|synchronized|pub(?:\\([^)]*\\))?|unsafe|suspend|inline|operator|open|declare|extern|constexpr)\\s+)*'
109const KEYWORDS = '(?:function\\*?|class|interface|enum|struct|trait|type|const|let|var|val|def|fn|func(?:\\s*\\([^)]*\\))?|fun|module|record|protocol|impl|macro|object|data\\s+class|sealed\\s+class)'
110
111function escapeRegExp(text: string): string {
112  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
113}
114
115/**
116 * How surely a line declares the symbol `name`: 3 for a keyword form
117 * (`function name`, `def name`, `class name`, `const name`), 2 for a method
118 * or property form that opens a body or an assignment, 1 for a bare
119 * `name(` at the start of the line, 0 when the line does not declare it.
120 */
121export function declarationScore(line: string, name: string): number {
122  const text = line.trim()
123  if (!text || /^(\/\/|#(?!\[)|\*|\/\*|\*\/)/.test(text)) return 0
124  const escaped = escapeRegExp(name)
125  if (new RegExp(`(^|\\s)${KEYWORDS}\\s+(?:<[^>]*>\\s*)?${escaped}\\b`).test(text)) return 3
126  const member = new RegExp(`^${MODIFIERS}\\*?\\s*${escaped}\\s*(?:<[^>]*>)?\\s*(\\(|=|:)`).exec(text)
127  if (member) {
128    if (/[{:]\s*$|=>\s*\{?\s*$|=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.test(text) || member[1] === '=') return 2
129    if (member[1] === ':' && !/^[A-Za-z_$][\w$]*\s*:\s*(string|number|boolean|any|unknown|void|[A-Z])/.test(text)) return 2
130    return member[1] === '(' && /\)\s*(?::\s*[^;]+)?$/.test(text) && !text.endsWith(';') ? 1 : 0
131  }
132  if (new RegExp(`^${MODIFIERS}[\\w<>\\[\\],.?*&:]+\\s+\\*?${escaped}\\s*\\(`).test(text) && !/^(return|new|await|yield|throw|case|else|typeof|delete)\b/.test(text)) return 2
133  return 0
134}
135
136/**
137 * The text of a line with its string literals emptied and its line comment
138 * cut (by the file's comment opener), so braces inside them do not count.
139 */
140function stripLiterals(line: string, prefix: string): string {
141  let out = ''
142  let quote = ''
143  for (let i = 0; i < line.length; i++) {
144    const ch = line[i] ?? ''
145    if (quote) {
146      if (ch === '\\') {
147        i += 1
148        continue
149      }
150      if (ch === quote) quote = ''
151      continue
152    }
153    if (ch === '"' || ch === "'" || ch === '`') {
154      quote = ch
155      continue
156    }
157    if (prefix !== '<!--' && line.startsWith(prefix, i)) break
158    out += ch
159  }
160  return out
161}
162
163function nextNonBlank(lines: readonly string[], from: number): number {
164  for (let i = from; i < lines.length; i++) if ((lines[i] ?? '').trim() !== '') return i
165  return -1
166}
167
168/**
169 * The end (exclusive) of the symbol declared at `start`: the close of its
170 * brace block, the end of its indented block (Python, Ruby), or the end of
171 * its statement when it has no body.
172 */
173function symbolEnd(lines: readonly string[], start: number, prefix: string): number {
174  let depth = 0
175  let braces = 0
176  let sawBrace = false
177  const baseIndent = indentOf(lines[start] ?? '')
178  for (let i = start; i < lines.length; i++) {
179    const text = stripLiterals(lines[i] ?? '', prefix)
180    for (const ch of text) {
181      if (ch === '(' || ch === '[') depth += 1
182      else if (ch === ')' || ch === ']') depth -= 1
183      else if (ch === '{') {
184        braces += 1
185        sawBrace = true
186      } else if (ch === '}') braces -= 1
187    }
188    if (depth > 0 || braces > 0) continue
189    if (sawBrace) return i + 1
190    const trimmed = text.trim()
191    if (trimmed.endsWith(':')) return blockEnd(lines, start)
192    if (/([,=+\-*/|&?.([{]|=>|\b(?:and|or|not|then|do))$/.test(trimmed)) continue
193    const next = nextNonBlank(lines, i + 1)
194    if (next !== -1 && indentOf(lines[next] ?? '') > baseIndent && !/^[)}\]]/.test((lines[next] ?? '').trim())) return blockEnd(lines, start)
195    return i + 1
196  }
197  return lines.length
198}
199
200/** The first line of the comment, doc block or decorators attached right above a declaration. */
201function attachedAbove(lines: readonly string[], start: number): number {
202  let index = start
203  while (index > 0) {
204    const previous = (lines[index - 1] ?? '').trim()
205    if (previous === '') break
206    if (/^(\/\/|#|\*|\/\*|@|\[|'''|""")/.test(previous) || /\*\/$/.test(previous)) index -= 1
207    else break
208  }
209  return index
210}
211
212export type LineRange = { start: number; end: number }
213
214/**
215 * The lines a symbol occupies in the file today, with its attached comment
216 * or decorators: the surest declaration of its name, the outermost when
217 * several tie. Null when no line declares it.
218 */
219export function symbolRange(lines: readonly string[], name: string, path = ''): LineRange | null {
220  const needle = name.split('.').pop() || name
221  let best: { index: number; score: number; indent: number } | null = null
222  for (let i = 0; i < lines.length; i++) {
223    const score = declarationScore(lines[i] ?? '', needle)
224    if (!score) continue
225    const indent = indentOf(lines[i] ?? '')
226    if (!best || score > best.score || (score === best.score && indent < best.indent)) best = { index: i, score, indent }
227  }
228  if (!best) return null
229  let end = symbolEnd(lines, best.index, commentPrefix(path))
230  while (end > best.index + 1 && (lines[end - 1] ?? '').trim() === '') end -= 1
231  return { start: attachedAbove(lines, best.index), end }
232}
233
234/** The code a symbol has today, or null when the file does not declare it. */
235export function currentSymbolText(fileText: string, name: string, path = ''): string | null {
236  const lines = splitFile(fileText)
237  const range = symbolRange(lines, name, path)
238  return range ? lines.slice(range.start, range.end).join('\n') : null
239}
240
241// ---- the unified diff ------------------------------------------------------
242
243/** One change of the old file: the lines `[start, end)` go, `lines` come in their place. */
244export type Edit = { start: number; end: number; lines: string[] }
245
246function splitFile(fileText: string): string[] {
247  const lines = fileText.replace(/\r\n/g, '\n').split('\n')
248  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
249  return lines
250}
251
252/** The edits sorted by place, overlapping removals merged into one. */
253export function normalizeEdits(edits: readonly Edit[]): Edit[] {
254  const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end)
255  const out: Edit[] = []
256  for (const edit of sorted) {
257    const last = out[out.length - 1]
258    if (last && edit.start < last.end) {
259      last.end = Math.max(last.end, edit.end)
260      last.lines = [...last.lines, ...edit.lines]
261    } else {
262      out.push({ start: edit.start, end: edit.end, lines: [...edit.lines] })
263    }
264  }
265  return out
266}
267
268/**
269 * What every planned symbol does to the old file: a new symbol inserts its
270 * stub at its anchor, a modified one replaces its current code with the
271 * stub, a deleted one removes its current code. A symbol the file does not
272 * hold is inserted (modified) or noted (deleted).
273 */
274export function symbolEdits(old: readonly string[], file: PlanFile): Edit[] {
275  const edits: Edit[] = []
276  for (const symbol of file.symbols) {
277    const change = changeOf(symbol)
278    const range = change === 'new' ? null : symbolRange(old, symbol.name, file.path)
279    if (change === 'deleted') {
280      if (range) edits.push({ start: range.start, end: range.end, lines: [] })
281      else edits.push({ start: old.length, end: old.length, lines: [commentLine(file.path, `delete ${symbol.name}: not found in the current file`)] })
282      continue
283    }
284    if (range) {
285      edits.push({ start: range.start, end: range.end, lines: stubLines(file, symbol) })
286      continue
287    }
288    const at = insertionIndex(old, symbol)
289    edits.push({ start: at, end: at, lines: stubLines(file, symbol) })
290  }
291  if (edits.length === 0) edits.push({ start: old.length, end: old.length, lines: [commentLine(file.path, file.description || 'modified')] })
292  return normalizeEdits(edits)
293}
294
295/** A unified diff of the edits against the old lines, `context` lines around each. */
296export function unifiedDiff(old: readonly string[], edits: readonly Edit[], context = CONTEXT): string {
297  if (edits.length === 0) return ''
298  type Hunk = { start: number; end: number; edits: Edit[] }
299  const hunks: Hunk[] = []
300  for (const edit of edits) {
301    const start = Math.max(0, edit.start - context)
302    const end = Math.min(old.length, edit.end + context)
303    const last = hunks[hunks.length - 1]
304    if (last && start <= last.end) {
305      last.end = Math.max(last.end, end)
306      last.edits.push(edit)
307    } else {
308      hunks.push({ start, end, edits: [edit] })
309    }
310  }
311  const out: string[] = []
312  let offset = 0
313  for (const hunk of hunks) {
314    const body: string[] = []
315    let cursor = hunk.start
316    let removed = 0
317    let added = 0
318    for (const edit of hunk.edits) {
319      for (; cursor < edit.start; cursor++) body.push(` ${old[cursor] ?? ''}`)
320      for (; cursor < edit.end; cursor++) {
321        body.push(`-${old[cursor] ?? ''}`)
322        removed += 1
323      }
324      for (const line of edit.lines) {
325        body.push(`+${line}`)
326        added += 1
327      }
328    }
329    for (; cursor < hunk.end; cursor++) body.push(` ${old[cursor] ?? ''}`)
330    const oldCount = hunk.end - hunk.start
331    const newCount = oldCount - removed + added
332    const oldStart = oldCount === 0 ? hunk.start : hunk.start + 1
333    const newStart = (newCount === 0 ? hunk.start : hunk.start + 1) + offset
334    out.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`)
335    out.push(...body)
336    offset += added - removed
337  }
338  return out.join('\n')
339}
340
341/**
342 * The unified diff of a modified file: new symbols added at their anchors,
343 * modified symbols replaced by their stubs, deleted symbols removed. What
344 * the page draws for a modified file, with the current code as `-` lines.
345 */
346export function stubDiff(fileText: string, file: PlanFile): string {
347  const old = splitFile(fileText)
348  return unifiedDiff(old, symbolEdits(old, file))
349}
350
351/** A unified diff that removes the whole file, cut to a readable length. */
352export function deletedDiff(fileText: string): string {
353  const old = splitFile(fileText)
354  const shown = old.slice(0, MAX_DELETED_LINES)
355  const out = [`@@ -1,${shown.length} +0,0 @@`, ...shown.map(line => `-${line}`)]
356  if (old.length > shown.length) out.push(`-... ${old.length - shown.length} more lines`)
357  return out.join('\n')
358}
359
360/** The code inside the first fenced block of a model reply, else the reply. */
361export function codeFromReply(reply: string): string {
362  const fenced = reply.match(/```[^\n]*\n([\s\S]*?)\n```/)
363  return (fenced?.[1] ?? reply).trim()
364}
365
hooks/plan-parser.ts 285 lines
1import type { FileStatus, PlanFile, PlanModel, PlanSymbol, PlanTask, SymbolChange, SymbolKind } from '../types'
2
3export type ParseResult =
4  | { ok: true; plan: PlanModel; warnings: string[] }
5  | { ok: false; reason: string; warnings: string[] }
6
7const BLOCK_OPEN = /^[ \t]*```+[ \t]*(?:json[ \t]+)?plan-crutches[^\n]*$/
8
9type BlockRange = { open: number; close: number }
10
11/** The lines of the last closed plan-crutches block: its fence lines, or null. */
12function findBlockRange(lines: readonly string[]): BlockRange | null {
13  let found: BlockRange | null = null
14  for (let i = 0; i < lines.length; i++) {
15    const line = lines[i] ?? ''
16    if (!BLOCK_OPEN.test(line)) continue
17    const fence = (line.match(/```+/) ?? ['```'])[0]
18    for (let j = i + 1; j < lines.length; j++) {
19      const inner = lines[j] ?? ''
20      if (inner.trim().startsWith(fence) && inner.trim().replace(/`/g, '').trim() === '') {
21        found = { open: i, close: j }
22        i = j
23        break
24      }
25    }
26  }
27  return found
28}
29
30/**
31 * Finds the last fenced block whose info string names plan-crutches and
32 * returns its inner text, or null when the markdown holds none.
33 */
34export function findBlock(markdown: string): string | null {
35  const lines = markdown.split('\n')
36  const range = findBlockRange(lines)
37  return range ? lines.slice(range.open + 1, range.close).join('\n') : null
38}
39
40/** Removes every plan-crutches block from the markdown. */
41export function stripBlocks(markdown: string): string {
42  const lines = markdown.split('\n')
43  const kept: string[] = []
44  for (let i = 0; i < lines.length; i++) {
45    const line = lines[i] ?? ''
46    if (!BLOCK_OPEN.test(line)) {
47      kept.push(line)
48      continue
49    }
50    const fence = (line.match(/```+/) ?? ['```'])[0]
51    let j = i + 1
52    for (; j < lines.length; j++) {
53      const inner = lines[j] ?? ''
54      if (inner.trim().startsWith(fence) && inner.trim().replace(/`/g, '').trim() === '') break
55    }
56    i = j
57  }
58  return kept.join('\n').trim()
59}
60
61export function titleFromMarkdown(markdown: string): string {
62  const heading = markdown.split('\n').find(line => /^#\s+\S/.test(line))
63  if (heading) return heading.replace(/^#\s+/, '').trim()
64  const first = markdown.split('\n').find(line => line.trim() !== '')
65  return (first ?? 'Plan').trim().slice(0, 80)
66}
67
68function parseJsonLenient(text: string): unknown {
69  try {
70    return JSON.parse(text)
71  } catch {
72    const cleaned = text.replace(/,\s*([}\]])/g, '$1').replace(/^/, '')
73    return JSON.parse(cleaned)
74  }
75}
76
77const STATUSES: readonly FileStatus[] = ['new', 'modified', 'deleted']
78const KINDS: readonly SymbolKind[] = ['function', 'method', 'class', 'type', 'const', 'other']
79
80function asString(value: unknown, fallback = ''): string {
81  if (typeof value === 'string') return value
82  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
83  return fallback
84}
85
86function normalizeStatus(value: unknown, warnings: string[], path: string): FileStatus {
87  const text = asString(value).toLowerCase().trim()
88  if ((STATUSES as readonly string[]).includes(text)) return text as FileStatus
89  if (text === 'added' || text === 'create' || text === 'created') return 'new'
90  if (text === 'changed' || text === 'edit' || text === 'edited' || text === 'update' || text === 'updated') return 'modified'
91  if (text === 'removed' || text === 'delete') return 'deleted'
92  warnings.push(`${path}: unknown status "${text}", treated as modified`)
93  return 'modified'
94}
95
96function normalizeKind(value: unknown): SymbolKind {
97  const text = asString(value).toLowerCase().trim()
98  if ((KINDS as readonly string[]).includes(text)) return text as SymbolKind
99  if (text === 'fn' || text === 'func') return 'function'
100  if (text === 'interface' || text === 'enum' || text === 'struct') return 'type'
101  if (text === 'variable' || text === 'var' || text === 'let') return 'const'
102  return 'other'
103}
104
105/**
106 * What the plan does to a symbol, from its `change` field (or the older
107 * spellings `status`, `op`, `action`), else from the anchor's wording. A
108 * symbol of a new file is always new.
109 */
110export function normalizeChange(record: Record<string, unknown>, fileStatus: FileStatus): SymbolChange {
111  if (fileStatus === 'new') return 'new'
112  const raw = [record.change, record.status, record.op, record.operation, record.action].map(value => asString(value).toLowerCase().trim()).find(Boolean) ?? ''
113  if (['modified', 'modify', 'changed', 'change', 'update', 'updated', 'edit', 'edited', 'replace', 'replaced', 'rewrite', 'rewritten', 'rename', 'renamed', 'move', 'moved'].includes(raw)) return 'modified'
114  if (['deleted', 'delete', 'removed', 'remove', 'drop', 'dropped'].includes(raw)) return 'deleted'
115  if (['new', 'added', 'add', 'create', 'created'].includes(raw)) return 'new'
116  const anchor = asString(record.anchor).toLowerCase()
117  if (/\b(replace|replaces|replacing|modif\w*|rewrite|rewrites|change|changes|update|updates)\b/.test(anchor)) return 'modified'
118  if (/\b(delete|deletes|remove|removes|drop|drops)\b/.test(anchor)) return 'deleted'
119  return 'new'
120}
121
122/** The identifier a signature declares: the one before its `(`, else after a keyword. */
123export function nameFromSignature(signature: string): string {
124  const first = signature.split('\n')[0] ?? ''
125  const call = first.match(/([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\(/)
126  if (call?.[1] && !['function', 'def', 'fn', 'func', 'async', 'export', 'public', 'private', 'static', 'if', 'return'].includes(call[1])) return call[1]
127  const declared = first.match(/\b(?:type|interface|class|const|let|var|def|fn|func|struct|enum|trait|module|val)\s+([A-Za-z_$][\w$]*)/)
128  if (declared?.[1]) return declared[1]
129  const tokens = first.replace(/[^A-Za-z0-9_$]+/g, ' ').trim().split(' ').filter(Boolean)
130  return tokens[tokens.length - 1] ?? ''
131}
132
133function normalizeSymbol(raw: unknown, warnings: string[], path: string, fileStatus: FileStatus): PlanSymbol | null {
134  if (typeof raw !== 'object' || raw === null) return null
135  const record = raw as Record<string, unknown>
136  const signature = asString(record.signature).trim()
137  const name = asString(record.name).trim() || nameFromSignature(signature)
138  if (!name && !signature) {
139    warnings.push(`${path}: a symbol without name and signature was dropped`)
140    return null
141  }
142  const symbol: PlanSymbol = {
143    name: name || signature,
144    kind: normalizeKind(record.kind),
145    signature: signature || name,
146    description: asString(record.description).trim(),
147  }
148  const anchor = asString(record.anchor).trim()
149  if (anchor) symbol.anchor = anchor
150  const change = normalizeChange(record, fileStatus)
151  if (change !== 'new') symbol.change = change
152  return symbol
153}
154
155export function normalizePath(path: string): string {
156  return path.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '')
157}
158
159function normalizeFile(raw: unknown, warnings: string[]): PlanFile | null {
160  if (typeof raw !== 'object' || raw === null) return null
161  const record = raw as Record<string, unknown>
162  const path = normalizePath(asString(record.path))
163  if (!path) {
164    warnings.push('a file without a path was dropped')
165    return null
166  }
167  const status = normalizeStatus(record.status, warnings, path)
168  const symbolsRaw = Array.isArray(record.symbols) ? record.symbols : []
169  const symbols: PlanSymbol[] = []
170  for (const item of symbolsRaw) {
171    const symbol = normalizeSymbol(item, warnings, path, status)
172    if (symbol) symbols.push(symbol)
173  }
174  return {
175    path,
176    status,
177    description: asString(record.description).trim(),
178    symbols,
179  }
180}
181
182function normalizeTask(raw: unknown): PlanTask | null {
183  if (typeof raw !== 'object' || raw === null) return null
184  const record = raw as Record<string, unknown>
185  const text = asString(record.text).trim()
186  if (!text) return null
187  const sourceText = asString(record.source).toLowerCase()
188  const task: PlanTask = { source: sourceText === 'jira' ? 'jira' : 'user', text }
189  const key = asString(record.key).trim()
190  const title = asString(record.title).trim()
191  if (key) task.key = key
192  if (title) task.title = title
193  return task
194}
195
196/** Turns the raw JSON of a plan-crutches block into a PlanModel, or null. */
197export function normalizePlan(raw: unknown, warnings: string[], markdownTitle: string): PlanModel | null {
198  if (typeof raw !== 'object' || raw === null) return null
199  const record = raw as Record<string, unknown>
200  const filesRaw = Array.isArray(record.files) ? record.files : []
201  const files: PlanFile[] = []
202  const seen = new Set<string>()
203  for (const item of filesRaw) {
204    const file = normalizeFile(item, warnings)
205    if (!file) continue
206    if (seen.has(file.path)) {
207      warnings.push(`${file.path}: listed twice, the second entry was dropped`)
208      continue
209    }
210    seen.add(file.path)
211    files.push(file)
212  }
213  if (!Array.isArray(record.files)) warnings.push('the block has no files list')
214  const task = normalizeTask(record.task) ?? { source: 'none', text: '' }
215  return {
216    title: asString(record.title).trim() || markdownTitle,
217    summary: asString(record.summary).trim(),
218    task,
219    files,
220  }
221}
222
223/** Parses a plan file: its plan-crutches block when it has one. */
224export function parsePlan(markdown: string): ParseResult {
225  const warnings: string[] = []
226  const block = findBlock(markdown)
227  if (block === null) return { ok: false, reason: 'no plan-crutches block', warnings }
228  let raw: unknown
229  try {
230    raw = parseJsonLenient(block)
231  } catch (error) {
232    return { ok: false, reason: `the plan-crutches block is not valid JSON: ${String(error)}`, warnings }
233  }
234  const plan = normalizePlan(raw, warnings, titleFromMarkdown(markdown))
235  if (!plan) return { ok: false, reason: 'the plan-crutches block is not an object', warnings }
236  if (!plan.summary) plan.summary = stripBlocks(markdown)
237  return { ok: true, plan, warnings }
238}
239
240/** A plan with no structure: the markdown stands as the summary. */
241export function planFromMarkdown(markdown: string): PlanModel {
242  return {
243    title: titleFromMarkdown(markdown),
244    summary: stripBlocks(markdown) || markdown.trim(),
245    task: { source: 'none', text: '' },
246    files: [],
247  }
248}
249
250/**
251 * The plan file with the summary the user edited put in: into the block's
252 * `summary` field when the block carries one, else in place of the
253 * markdown above the block, else as the whole file.
254 */
255export function applySummary(markdown: string, summary: string): string {
256  const text = summary.replace(/\r\n?/g, '\n').trimEnd()
257  const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
258  const range = findBlockRange(lines)
259  if (!range) return `${text}\n`
260  let raw: unknown = null
261  try {
262    raw = parseJsonLenient(lines.slice(range.open + 1, range.close).join('\n'))
263  } catch {
264    raw = null
265  }
266  if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
267    const record = raw as Record<string, unknown>
268    if (typeof record.summary === 'string' && record.summary.trim()) {
269      const json = JSON.stringify({ ...record, summary: text }, null, 2).split('\n')
270      return [...lines.slice(0, range.open + 1), ...json, ...lines.slice(range.close)].join('\n')
271    }
272  }
273  return [text, '', ...lines.slice(range.open)].join('\n')
274}
275
276/** Pulls the first JSON object out of a model reply that may wrap it in a fence. */
277export function extractJson(reply: string): unknown {
278  const fenced = reply.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
279  const candidate = fenced?.[1] ?? reply
280  const start = candidate.indexOf('{')
281  const end = candidate.lastIndexOf('}')
282  if (start < 0 || end <= start) throw new Error('no JSON object in the reply')
283  return parseJsonLenient(candidate.slice(start, end + 1))
284}
285
hooks/pane.tsx 631 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import type {
4  CommentTarget,
5  FileCache,
6  GeneratedBody,
7  Lang,
8  PlanFile,
9  PlanModel,
10  PlanSymbol,
11  QaItem,
12  Review,
13  ReviewComment,
14  UiState,
15} from '../types'
16import { deletedDiff, newFileStub, stubDiff } from './diff'
17import { otherLang, statusLabel, t } from './i18n'
18import type { StringKey } from './i18n'
19import { threadText } from './prompts'
20import type { SelectableProps } from './select'
21import { bodyKey } from './state'
22import { allDirKeys, buildTree, pathsUnder, visibleRows } from './tree'
23import type { TreeNode, TreeStatus } from './tree'
24
25/** The element constructors the pane draws with, as the surface resolved them. */
26export type PaneEl = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Code' | 'Markdown' | 'Link'>
27
28/** Everything the pane reads: the session state as read for this drawing. */
29export type PaneModel = {
30  plan: PlanModel | null
31  review: Review
32  comments: ReviewComment[]
33  ui: UiState
34  bodies: Record<string, GeneratedBody>
35  qa: QaItem[]
36  files: FileCache
37  summaryLimit: number
38  /** The review page's address with its token, once the server runs. */
39  webUrl?: string
40}
41
42/** Everything the pane does: closures the hooks module made over its `$`. */
43export type PaneActions = {
44  setUi: (change: (ui: UiState) => UiState) => Promise<unknown>
45  addComment: (target: CommentTarget, text: string) => Promise<void>
46  deleteComment: (id: string) => Promise<void>
47  submit: () => Promise<void>
48  loadFile: (path: string) => Promise<void>
49  generateBody: (file: PlanFile, symbol: PlanSymbol) => Promise<void>
50  ask: (question: string, focus?: string) => Promise<void>
51  commentOnSelection: (path: string) => Promise<void>
52  /** Starts the review page server if needed and opens the page. */
53  openWeb: () => Promise<unknown>
54  /** Draws a code block whose lines the person selects with the mouse; absent where the surface has no Client. */
55  selectable?: (args: { key: string; props: SelectableProps }) => RenderElement
56}
57
58type Ctx = { el: PaneEl; m: PaneModel; a: PaneActions; lang: Lang; plan: PlanModel }
59
60const TASK_LIMIT = 6000
61
62function colorOf(status: TreeStatus): string {
63  switch (status) {
64    case 'new':
65      return 'diffAdded'
66    case 'deleted':
67      return 'diffRemoved'
68    case 'modified':
69      return 'warning'
70    default:
71      return 'subtle'
72  }
73}
74
75const MAX_BLOCK_LINES = 1500
76const MAX_BLOCK_CHARS = 90000
77
78/** A code block: selectable with the mouse where the surface allows, plain Code elsewhere. */
79function codeBlock(
80  ctx: Ctx,
81  key: string,
82  source: string,
83  path: string,
84  format: 'source' | 'diff',
85  target: { path: string; symbol?: string },
86  startLine = 1,
87) {
88  const { Code } = ctx.el
89  if (!ctx.a.selectable) return <Code source={source || ' '} path={path} format={format} />
90  let lines = source.split('\n')
91  if (lines.length > MAX_BLOCK_LINES) lines = [...lines.slice(0, MAX_BLOCK_LINES), '…']
92  let total = 0
93  const kept: string[] = []
94  for (const line of lines) {
95    total += line.length + 1
96    if (total > MAX_BLOCK_CHARS) {
97      kept.push('…')
98      break
99    }
100    kept.push(line)
101  }
102  const lang = ctx.lang
103  return ctx.a.selectable({
104    key,
105    props: {
106      lines: kept,
107      path,
108      format,
109      startLine,
110      target,
111      hint: t(lang, 'selectHint'),
112      placeholder: t(lang, 'commentPlaceholder'),
113      cancelLabel: t(lang, 'cancel'),
114      selectedLabel: t(lang, 'selectedLines'),
115    },
116  })
117}
118
119export function withoutKeys(ui: UiState, keys: readonly (keyof UiState)[]): UiState {
120  const copy: Record<string, unknown> = { ...ui }
121  for (const key of keys) delete copy[key]
122  return copy as UiState
123}
124
125export function pendingComments(comments: readonly ReviewComment[]): ReviewComment[] {
126  return comments.filter(one => one.sentInVersion === undefined && one.resolvedInVersion === undefined)
127}
128
129export function renderPane(el: PaneEl, m: PaneModel, a: PaneActions) {
130  const { Box, Text, Button } = el
131  const lang = m.ui.lang
132  const toggleLang = () => a.setUi(state => ({ ...state, lang: otherLang(state.lang) }))
133
134  if (!m.plan) {
135    return (
136      <Box flexDirection="column">
137        <Box flexDirection="row">
138          <Text bold>{t(lang, 'appName')}</Text>
139          <Text> </Text>
140          <Button key="lang" plain onPress={toggleLang}>
141            {t(lang, 'lang')}
142          </Button>
143        </Box>
144        <Text dimColor>{t(lang, 'noPlan')}</Text>
145        {renderWebRow(el, m, a, lang)}
146      </Box>
147    )
148  }
149
150  const ctx: Ctx = { el, m, a, lang, plan: m.plan }
151  return (
152    <Box flexDirection="column">
153      {renderHeader(ctx, toggleLang)}
154      {renderTabs(ctx)}
155      {m.ui.tab === 'changes' && renderChanges(ctx)}
156      {m.ui.tab === 'diagram' && <Text dimColor>{t(lang, 'diagramOnPage')}</Text>}
157      {m.ui.tab === 'ask' && renderAsk(ctx)}
158    </Box>
159  )
160}
161
162function renderWebRow(el: PaneEl, m: PaneModel, a: PaneActions, lang: Lang) {
163  const { Box, Text, Button, Link } = el
164  return (
165    <Box flexDirection="row">
166      <Text dimColor>{t(lang, 'reviewPage')} </Text>
167      {m.webUrl ? <Link href={m.webUrl} label={m.webUrl} /> : <Text dimColor>{t(lang, 'pageDown')}</Text>}
168      <Text> </Text>
169      <Button key="open-web" plain onPress={() => a.openWeb()}>
170        {t(lang, 'openPage')}
171      </Button>
172    </Box>
173  )
174}
175
176function renderHeader(ctx: Ctx, toggleLang: () => Promise<unknown>) {
177  const { el, lang, plan, m, a } = ctx
178  const { Box, Text, Button } = el
179  const { review, comments } = m
180  const pending = pendingComments(comments).length
181  const statusKey = `status_${review.status}` as StringKey
182  let action
183  if (review.status === 'reviewing') {
184    action = (
185      <Button key="submit" variant="primary" onPress={() => a.submit()}>
186        {pending > 0 ? t(lang, 'submitWith', { n: pending }) : t(lang, 'approve')}
187      </Button>
188    )
189  } else if (review.status === 'revising') {
190    action = <Text dimColor>{t(lang, 'revisingNote')}</Text>
191  } else if (review.status === 'approved') {
192    action = <Text color="success">{t(lang, 'approvedNote')}</Text>
193  } else {
194    action = null
195  }
196  return (
197    <Box flexDirection="column">
198      <Box flexDirection="row" justifyContent="space-between">
199        <Box flexDirection="row">
200          <Text bold>{t(lang, 'appName')}</Text>
201          <Text> · {plan.title} </Text>
202        </Box>
203        <Button key="lang" plain onPress={toggleLang}>
204          {t(lang, 'lang')}
205        </Button>
206      </Box>
207      <Text dimColor>
208        {t(lang, 'version')}
209        {review.version} · {t(lang, statusKey)} · {comments.length} {t(lang, 'comments')}
210        {review.isDemo ? ' · demo' : ''}
211      </Text>
212      <Box flexDirection="row">{action}</Box>
213      {renderWebRow(el, m, a, lang)}
214      {review.recordPath && (
215        <Text dimColor>
216          {t(lang, 'recorded')} {review.recordPath}
217        </Text>
218      )}
219    </Box>
220  )
221}
222
223function renderTabs(ctx: Ctx) {
224  const { el, lang, m, a } = ctx
225  const { Box, Text, Button } = el
226  const tabs = ['changes', 'diagram', 'ask'] as const
227  return (
228    <Box flexDirection="row">
229      {tabs.map(tab => (
230        <Button key={`tab:${tab}`} plain onPress={() => a.setUi(state => ({ ...state, tab }))}>
231          {m.ui.tab === tab ? (
232            <Text bold inverse> {t(lang, `tab_${tab}`)} </Text>
233          ) : (
234            <Text dimColor> {t(lang, `tab_${tab}`)} </Text>
235          )}
236        </Button>
237      ))}
238    </Box>
239  )
240}
241
242function renderChanges(ctx: Ctx) {
243  const { el, m, plan } = ctx
244  const { Box } = el
245  if (m.ui.view === 'file' && m.ui.selected) {
246    const file = plan.files.find(one => `file:${one.path}` === m.ui.selected)
247    if (file) return renderFileView(ctx, file)
248  }
249  return (
250    <Box flexDirection="column">
251      {renderTask(ctx)}
252      {renderSummary(ctx)}
253      {renderTree(ctx)}
254      {renderWarnings(ctx)}
255    </Box>
256  )
257}
258
259function renderTask(ctx: Ctx) {
260  const { el, lang, plan, m, a } = ctx
261  const { Box, Text, Button, Markdown } = el
262  const task = plan.task
263  const label =
264    task.source === 'jira'
265      ? `${t(lang, 'taskFromJira')}${task.key ? ` · ${task.key}` : ''}`
266      : task.source === 'user'
267        ? t(lang, 'taskFromUser')
268        : t(lang, 'task')
269  const toggle = () => a.setUi(state => ({ ...state, showTask: !state.showTask }))
270  const text = task.text.length > TASK_LIMIT ? `${task.text.slice(0, TASK_LIMIT)}\n\n…` : task.text
271  return (
272    <Box flexDirection="column">
273      <Button key="task-toggle" plain onPress={toggle}>
274        {m.ui.showTask ? '▾ ' : '▸ '}
275        <Text bold>{t(lang, 'task')}</Text>
276        {task.title ? ` · ${task.title}` : ''}
277      </Button>
278      {m.ui.showTask && (
279        <Box flexDirection="column" paddingLeft={2}>
280          <Text dimColor>{label}</Text>
281          {task.text ? <Markdown text={text} /> : <Text dimColor>{t(lang, 'taskNone')}</Text>}
282        </Box>
283      )}
284    </Box>
285  )
286}
287
288function renderCommentInput(ctx: Ctx, inputKey: string, target: CommentTarget) {
289  const { el, lang, a } = ctx
290  const { Input } = el
291  return <Input key={inputKey} placeholder={t(lang, 'commentPlaceholder')} autoFocus onSubmit={value => a.addComment(target, value.trim())} />
292}
293
294function renderCommentList(ctx: Ctx, list: readonly ReviewComment[]) {
295  const { el, lang, a } = ctx
296  const { Box, Text, Button } = el
297  if (list.length === 0) return null
298  return (
299    <Box flexDirection="column" paddingLeft={2}>
300      {list.map(comment => (
301        <Box flexDirection="row">
302          <Text color="suggestion">┆ </Text>
303          <Text>{comment.text}</Text>
304          {comment.target.lines ? (
305            <Text dimColor> ({t(lang, 'lines')} {comment.target.lines[0]}-{comment.target.lines[1]})</Text>
306          ) : comment.target.selection ? (
307            <Text dimColor> ({t(lang, 'selection')})</Text>
308          ) : null}
309          {comment.resolvedInVersion !== undefined ? (
310            <Text dimColor> · {t(lang, 'resolved', { n: comment.resolvedInVersion })}</Text>
311          ) : comment.sentInVersion !== undefined ? (
312            <Text dimColor> · {t(lang, 'sent')}</Text>
313          ) : (
314            <Button key={`del:${comment.id}`} plain dimColor onPress={() => a.deleteComment(comment.id)}>
315              {' ✕'}
316            </Button>
317          )}
318        </Box>
319      ))}
320    </Box>
321  )
322}
323
324function renderSummary(ctx: Ctx) {
325  const { el, lang, plan, m, a } = ctx
326  const { Box, Text, Button, Markdown } = el
327  const limit = m.summaryLimit
328  const isLong = plan.summary.length > limit
329  const shown = isLong && !m.ui.showFullSummary ? `${plan.summary.slice(0, limit)}\n\n…` : plan.summary
330  const planComments = m.comments.filter(one => !one.target.path)
331  return (
332    <Box flexDirection="column" marginTop={1}>
333      <Box flexDirection="row">
334        <Text bold>{t(lang, 'summary')}</Text>
335        <Text> </Text>
336        <Button key="plan-comment" plain onPress={() => a.setUi(state => ({ ...state, commenting: 'plan' }))}>
337          {t(lang, 'planComment')}
338        </Button>
339      </Box>
340      <Markdown text={shown || ' '} />
341      {isLong && (
342        <Button key="summary-toggle" plain onPress={() => a.setUi(state => ({ ...state, showFullSummary: !state.showFullSummary }))}>
343          {m.ui.showFullSummary ? t(lang, 'showLess') : t(lang, 'showAll')}
344        </Button>
345      )}
346      {m.ui.commenting === 'plan' && renderCommentInput(ctx, 'input:plan', {})}
347      {renderCommentList(ctx, planComments)}
348    </Box>
349  )
350}
351
352function renderWarnings(ctx: Ctx) {
353  const { el, lang, m } = ctx
354  const { Box, Text } = el
355  const { review } = m
356  if (review.warnings.length === 0) return null
357  return (
358    <Box flexDirection="column" marginTop={1}>
359      <Text dimColor>
360        {t(lang, 'warnings')} ({t(lang, 'source')}: {t(lang, `parsed_${review.parsedFrom}`)})
361      </Text>
362      {review.warnings.slice(0, 5).map(warning => (
363        <Text dimColor>- {warning}</Text>
364      ))}
365    </Box>
366  )
367}
368
369function renderTree(ctx: Ctx) {
370  const { el, lang, plan, m, a } = ctx
371  const { Box, Text, Button } = el
372  const roots = buildTree(plan.files)
373  const collapsed = new Set(m.ui.collapsed)
374  const rows = visibleRows(roots, collapsed)
375  const dirKeys = allDirKeys(roots)
376  const allCollapsed = dirKeys.length > 0 && dirKeys.every(key => collapsed.has(key))
377  return (
378    <Box flexDirection="column" marginTop={1}>
379      <Box flexDirection="row">
380        <Text bold>{t(lang, 'changes')}</Text>
381        <Text> </Text>
382        <Button key="tree-toggle-all" plain dimColor onPress={() => a.setUi(state => ({ ...state, collapsed: allCollapsed ? [] : dirKeys }))}>
383          {allCollapsed ? t(lang, 'expandAll') : t(lang, 'collapseAll')}
384        </Button>
385      </Box>
386      {plan.files.length === 0 && <Text dimColor>{t(lang, 'parsed_markdown')}</Text>}
387      {rows.map(node => renderTreeRow(ctx, node, collapsed))}
388    </Box>
389  )
390}
391
392function commentsFor(comments: readonly ReviewComment[], node: TreeNode): ReviewComment[] {
393  if (node.kind === 'file') return comments.filter(one => one.target.path === node.path)
394  const under = new Set(pathsUnder(node))
395  const folderPath = `${node.path}/`
396  return comments.filter(one => {
397    const path = one.target.path
398    if (!path) return false
399    if (path === folderPath) return true
400    if (path.endsWith('/')) return path.startsWith(folderPath)
401    return under.has(path)
402  })
403}
404
405function renderTreeRow(ctx: Ctx, node: TreeNode, collapsed: ReadonlySet<string>) {
406  const { el, lang, m, a } = ctx
407  const { Box, Text, Button } = el
408  const indent = '  '.repeat(node.depth)
409  const isCollapsed = collapsed.has(node.key)
410  const isSelected = m.ui.selected === node.key
411  const related = commentsFor(m.comments, node)
412  const own =
413    node.kind === 'file'
414      ? related.filter(one => !one.target.symbol && !one.target.selection)
415      : related.filter(one => one.target.path === `${node.path}/`)
416  const select = () =>
417    a.setUi(state => ({ ...withoutKeys(state, ['commenting', 'pendingSelection']), selected: isSelected ? '' : node.key, view: 'tree' as const }))
418  const openFile = async () => {
419    await a.setUi(state => ({ ...withoutKeys(state, ['commenting', 'pendingSelection']), selected: node.key, view: 'file' as const }))
420    void a.loadFile(node.path)
421  }
422  const toggle = () =>
423    a.setUi(state => ({
424      ...state,
425      collapsed: isCollapsed ? state.collapsed.filter(key => key !== node.key) : [...state.collapsed, node.key],
426    }))
427  const chip = node.status === 'unchanged' ? null : <Text color={colorOf(node.status)}> [{statusLabel(lang, node.status)}]</Text>
428  return (
429    <Box flexDirection="column">
430      <Box flexDirection="row">
431        <Text>{indent}</Text>
432        {node.kind === 'dir' ? (
433          <Button key={`toggle:${node.key}`} plain onPress={toggle}>
434            {isCollapsed ? '▸ ' : '▾ '}
435          </Button>
436        ) : (
437          <Text>{'  '}</Text>
438        )}
439        <Button key={`row:${node.key}`} plain onPress={node.kind === 'dir' ? select : openFile}>
440          {isSelected ? <Text bold inverse>{node.name}</Text> : node.name}
441          {node.kind === 'dir' ? '/' : ''}
442          {chip}
443          {related.length > 0 ? <Text dimColor> ({related.length})</Text> : null}
444        </Button>
445      </Box>
446      {isSelected && node.kind === 'dir' && (
447        <Box flexDirection="row" paddingLeft={node.depth * 2 + 4}>
448          <Button key={`comment:${node.key}`} onPress={() => a.setUi(state => ({ ...state, commenting: node.key }))}>
449            {t(lang, 'addComment')}
450          </Button>
451        </Box>
452      )}
453      {m.ui.commenting === node.key && (
454        <Box paddingLeft={node.depth * 2 + 4}>{renderCommentInput(ctx, `input:${node.key}`, { path: `${node.path}/` })}</Box>
455      )}
456      {own.length > 0 && <Box paddingLeft={node.depth * 2 + 2}>{renderCommentList(ctx, own)}</Box>}
457    </Box>
458  )
459}
460
461function renderFileView(ctx: Ctx, file: PlanFile) {
462  const { el, lang, m, a } = ctx
463  const { Box, Text, Button } = el
464  const fileComments = m.comments.filter(one => one.target.path === file.path && !one.target.symbol && !one.target.selection)
465  const selectionComments = m.comments.filter(one => one.target.path === file.path && one.target.selection)
466  const content = m.files[file.path]
467  const back = () => a.setUi(state => ({ ...withoutKeys(state, ['commenting', 'pendingSelection']), view: 'tree' as const }))
468  return (
469    <Box flexDirection="column">
470      <Box flexDirection="row">
471        <Button key="back" plain onPress={back}>
472          {`‹ ${t(lang, 'back')}`}
473        </Button>
474        <Text bold> {file.path}</Text>
475        <Text color={colorOf(file.status)}> [{statusLabel(lang, file.status)}]</Text>
476      </Box>
477      {file.description && <Text>{file.description}</Text>}
478      <Box flexDirection="row" marginTop={1}>
479        <Button key="comment-file" onPress={() => a.setUi(state => ({ ...state, commenting: `file:${file.path}` }))}>
480          {t(lang, 'commentOnFile')}
481        </Button>
482        <Text> </Text>
483        <Button key="comment-selection" onPress={() => a.commentOnSelection(file.path)}>
484          {t(lang, 'commentOnSelection')}
485        </Button>
486      </Box>
487      {m.ui.commenting === `file:${file.path}` && renderCommentInput(ctx, 'input:file', { path: file.path })}
488      {m.ui.commenting === `sel:${file.path}` && m.ui.pendingSelection && (
489        <Box flexDirection="column">
490          <Text dimColor>"{m.ui.pendingSelection.slice(0, 200).replace(/\s+/g, ' ')}"</Text>
491          {renderCommentInput(ctx, 'input:selection', { path: file.path, selection: m.ui.pendingSelection })}
492        </Box>
493      )}
494      {renderCommentList(ctx, [...fileComments, ...selectionComments])}
495      <Box flexDirection="column" marginTop={1}>
496        <Text bold>{t(lang, 'symbols')}</Text>
497        {file.symbols.length === 0 && <Text dimColor>{t(lang, 'noSymbols')}</Text>}
498        {file.symbols.map(symbol => renderSymbol(ctx, file, symbol))}
499      </Box>
500      <Box flexDirection="column" marginTop={1}>
501        {file.status === 'new' && (
502          <Box flexDirection="column">
503            <Text bold>{t(lang, 'newFileTitle')}</Text>
504            {codeBlock(ctx, `sel:${file.path}:preview`, newFileStub(file), file.path, 'source', { path: file.path })}
505          </Box>
506        )}
507        {file.status === 'modified' && (
508          <Box flexDirection="column">
509            <Text bold>{t(lang, 'diffTitle')}</Text>
510            {content === undefined && <Text dimColor>…</Text>}
511            {content === null && <Text dimColor>{t(lang, 'fileMissing')}</Text>}
512            {typeof content === 'string' && codeBlock(ctx, `sel:${file.path}:diff`, stubDiff(content, file), file.path, 'diff', { path: file.path })}
513          </Box>
514        )}
515        {file.status === 'deleted' && (
516          <Box flexDirection="column">
517            <Text color="diffRemoved">{t(lang, 'deletedFile')}</Text>
518            {typeof content === 'string' && codeBlock(ctx, `sel:${file.path}:deleted`, deletedDiff(content), file.path, 'diff', { path: file.path })}
519          </Box>
520        )}
521      </Box>
522    </Box>
523  )
524}
525
526function renderSymbol(ctx: Ctx, file: PlanFile, symbol: PlanSymbol) {
527  const { el, lang, m, a } = ctx
528  const { Box, Text, Button } = el
529  const key = bodyKey(file.path, symbol.name)
530  const isExpanded = m.ui.expanded.includes(key)
531  const body = m.bodies[key]
532  const own = m.comments.filter(one => one.target.path === file.path && one.target.symbol === symbol.name)
533  const toggleExpand = async () => {
534    if (isExpanded) {
535      await a.setUi(state => ({ ...state, expanded: state.expanded.filter(one => one !== key) }))
536      return
537    }
538    await a.setUi(state => ({ ...state, expanded: [...state.expanded, key] }))
539    if (!body || body.status === 'failed') void a.generateBody(file, symbol)
540  }
541  return (
542    <Box flexDirection="column" marginTop={1}>
543      <Box flexDirection="row">
544        <Text bold>{symbol.name}</Text>
545        <Text dimColor> {symbol.kind}</Text>
546      </Box>
547      {codeBlock(ctx, `sel:${key}`, symbol.signature, file.path, 'source', { path: file.path, symbol: symbol.name })}
548      {symbol.description && <Text dimColor>{symbol.description}</Text>}
549      <Box flexDirection="row">
550        <Button key={`expand:${key}`} plain onPress={toggleExpand}>
551          {isExpanded ? t(lang, 'collapseImpl') : t(lang, 'expandImpl')}
552        </Button>
553        <Text> </Text>
554        {isExpanded && body?.status === 'ready' && (
555          <Button key={`regen:${key}`} plain dimColor onPress={() => a.generateBody(file, symbol)}>
556            {t(lang, 'regenerate')}
557          </Button>
558        )}
559        <Text> </Text>
560        <Button key={`comment:${key}`} plain onPress={() => a.setUi(state => ({ ...state, commenting: `sym:${key}` }))}>
561          {t(lang, 'addComment')}
562        </Button>
563      </Box>
564      {isExpanded && body?.status === 'loading' && <Text dimColor>{t(lang, 'generating')}</Text>}
565      {isExpanded && body?.status === 'failed' && (
566        <Text color="error">
567          {t(lang, 'generationFailed')}: {body.error ?? ''}
568        </Text>
569      )}
570      {isExpanded && body?.status === 'ready' && codeBlock(ctx, `sel:${key}:body`, body.code ?? '', file.path, 'source', { path: file.path, symbol: symbol.name })}
571      {m.ui.commenting === `sym:${key}` && renderCommentInput(ctx, `input:${key}`, { path: file.path, symbol: symbol.name })}
572      {renderCommentList(ctx, own)}
573    </Box>
574  )
575}
576
577function renderAsk(ctx: Ctx) {
578  const { el, lang, m, a } = ctx
579  const { Box, Text, Input, Markdown, Button } = el
580  const focus = m.ui.selected?.startsWith('file:') ? m.ui.selected.slice(5) : undefined
581  return (
582    <Box flexDirection="column">
583      <Input
584        key="ask"
585        placeholder={t(lang, 'askPlaceholder')}
586        onSubmit={value => {
587          const question = value.trim()
588          if (question) void a.ask(question, focus)
589        }}
590      />
591      {m.qa
592        .slice()
593        .reverse()
594        .map(item => (
595          <Box flexDirection="column" marginTop={1}>
596            <Text bold>? {item.question}</Text>
597            {item.status === 'asking' && <Text dimColor>{t(lang, 'asking')}</Text>}
598            {item.status === 'failed' && (
599              <Text color="error">
600                {t(lang, 'askFailed')}: {item.answer ?? ''}
601              </Text>
602            )}
603            {item.status === 'done' && <Markdown text={item.answer ?? ''} />}
604            {(item.followups ?? []).map(turn => (
605              <Box flexDirection="column" marginLeft={2} marginTop={1}>
606                <Text bold>↳ {turn.question}</Text>
607                {turn.status === 'asking' && <Text dimColor>{t(lang, 'asking')}</Text>}
608                {turn.status === 'failed' && (
609                  <Text color="error">
610                    {t(lang, 'askFailed')}: {turn.answer ?? ''}
611                  </Text>
612                )}
613                {turn.status === 'done' && <Markdown text={turn.answer ?? ''} />}
614              </Box>
615            ))}
616            {item.status === 'done' && (
617              <Button
618                key={`qa-comment:${item.id}`}
619                plain
620                dimColor
621                onPress={() => a.addComment(item.target ?? (item.focus ? { path: item.focus } : {}), threadText(item))}
622              >
623                {t(lang, 'makeComment')}
624              </Button>
625            )}
626          </Box>
627        ))}
628    </Box>
629  )
630}
631
hooks/paths.ts 84 lines
1// Where the mod keeps its files: the live review, the saved plan versions and
2// the Markdown records. All of it sits under one folder in the user's Claude
3// configuration directory, one subfolder per project, so nothing lands in
4// the project itself. Pure: hooks/register.tsx reads and writes the files.
5
6/** The folder under the home directory that holds every project's review files. */
7export const REVIEWS_FOLDER = '.claude/plan-crutches'
8
9/** A short, stable hash of a text, for folder names that must not collide. */
10export function shortHash(text: string): string {
11  let hash = 0x811c9dc5
12  for (let i = 0; i < text.length; i++) {
13    hash ^= text.charCodeAt(i)
14    hash = Math.imul(hash, 0x01000193) >>> 0
15  }
16  return hash.toString(16).padStart(8, '0')
17}
18
19export function slugify(text: string): string {
20  return (
21    text
22      .toLowerCase()
23      .replace(/[^a-z0-9Ѐ-ӿ]+/g, '-')
24      .replace(/^-+|-+$/g, '')
25      .slice(0, 48) || 'plan'
26  )
27}
28
29function trimSlashes(path: string): string {
30  return path.replace(/\/+$/, '')
31}
32
33function baseName(path: string): string {
34  const parts = trimSlashes(path).split(/[\\/]/).filter(Boolean)
35  return parts[parts.length - 1] ?? 'project'
36}
37
38/** The name of a project's subfolder: its folder name and a hash of its whole path. */
39export function projectSlug(cwd: string): string {
40  return `${slugify(baseName(cwd))}-${shortHash(trimSlashes(cwd))}`
41}
42
43/** The root of the mod's files for every project. */
44export function reviewsRoot(home: string): string {
45  return `${trimSlashes(home)}/${REVIEWS_FOLDER}`
46}
47
48/** The folder of one project's review files. */
49export function projectDir(home: string, cwd: string): string {
50  return `${reviewsRoot(home)}/${projectSlug(cwd)}`
51}
52
53/** The live review record of a project. */
54export function reviewFile(dir: string): string {
55  return `${trimSlashes(dir)}/review.json`
56}
57
58/** The generated bodies of a project's live review, beside the record. */
59export function bodiesFile(dir: string): string {
60  return `${trimSlashes(dir)}/bodies.json`
61}
62
63/** The file of one saved plan version. */
64export function versionFile(dir: string, version: number): string {
65  return `${trimSlashes(dir)}/versions/v${version}.json`
66}
67
68export function isAbsolutePath(path: string): boolean {
69  return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
70}
71
72/**
73 * Where the Markdown records of a project go, from the `plansDir` option:
74 * empty or `~`-relative or absolute means a shared folder with one subfolder
75 * per project; any other path is a folder inside the project.
76 */
77export function recordsDir(plansDir: string, cwd: string, home: string): string {
78  const option = plansDir.trim().replace(/\/+$/, '')
79  if (!option) return projectDir(home, cwd)
80  if (option === '~' || option.startsWith('~/')) return `${trimSlashes(home)}${option.slice(1)}/${projectSlug(cwd)}`
81  if (isAbsolutePath(option)) return `${option}/${projectSlug(cwd)}`
82  return `${trimSlashes(cwd)}/${option}`
83}
84
hooks/select.tsx 144 lines
1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3/** What the hooks module hands a selectable code block. Plain data only. */
4export type SelectableProps = {
5  lines: string[]
6  path: string
7  format: 'source' | 'diff'
8  startLine: number
9  target: { path: string; symbol?: string }
10  hint: string
11  placeholder: string
12  cancelLabel: string
13  selectedLabel: string
14}
15
16type State = {
17  anchor?: number
18  head?: number
19  dragging: boolean
20  popup: boolean
21}
22
23const POPUP_ROWS = 5
24const POPUP_MIN_COLUMNS = 24
25
26const EMPTY: State = { dragging: false, popup: false }
27
28function range(state: State): [number, number] | null {
29  if (state.anchor === undefined || state.head === undefined) return null
30  return [Math.min(state.anchor, state.head), Math.max(state.anchor, state.head)]
31}
32
33/** The text a selection stands for: diff markers dropped, hunk headers skipped. */
34function selectedText(lines: readonly string[], format: 'source' | 'diff', from: number, to: number): string {
35  const picked: string[] = []
36  for (let i = from; i <= to; i++) {
37    const line = lines[i] ?? ''
38    if (format === 'diff') {
39      if (line.startsWith('@@')) continue
40      picked.push(line.slice(1))
41    } else {
42      picked.push(line)
43    }
44  }
45  return picked.join('\n')
46}
47
48function popupTop(from: number, to: number, rows: number): number {
49  const below = to + 1
50  if (rows === 0 || below + POPUP_ROWS <= rows) return below
51  return Math.max(0, from - POPUP_ROWS)
52}
53
54const Selectable: ClientModule<SelectableProps, State> = (props, surface) => {
55  const { Box, Text, Code, Input, Button } = surface.elements
56  const state = surface.state ?? EMPTY
57  const lineCount = props.lines.length
58  const clampRow = (y: number): number => Math.max(0, Math.min(lineCount - 1, y))
59
60  surface.onPointer((event: ClientPointerEvent) => {
61    const current = surface.state ?? EMPTY
62    if (event.type === 'down' && event.button === 'left') {
63      if (current.popup) {
64        const selected = range(current)
65        if (selected) {
66          const top = popupTop(selected[0], selected[1], surface.rows)
67          if (event.y >= top && event.y < top + POPUP_ROWS) return
68        }
69      }
70      const row = clampRow(event.y)
71      surface.setState({ anchor: row, head: row, dragging: true, popup: false })
72      return
73    }
74    if (event.type === 'move' && current.dragging && event.button === 'left') {
75      const row = clampRow(event.y)
76      if (row !== current.head) surface.setState({ ...current, head: row })
77      return
78    }
79    if (event.type === 'up' && current.dragging) {
80      const selected = range(current)
81      surface.setState(selected ? { ...current, dragging: false, popup: true } : EMPTY)
82    }
83  })
84
85  const selected = range(state)
86  const source = props.lines.join('\n')
87  if (!selected || lineCount === 0) {
88    return (
89      <Box flexDirection="column">
90        <Code source={source || ' '} path={props.path} format={props.format} startLine={props.format === 'source' ? props.startLine : undefined} wrap="truncate-end" />
91        <Text dimColor>{props.hint}</Text>
92      </Box>
93    )
94  }
95
96  const [from, to] = selected
97  const width = Math.max(POPUP_MIN_COLUMNS, Math.min(surface.columns || 60, 72) - 2)
98  const top = popupTop(from, to, surface.rows)
99  const text = selectedText(props.lines, props.format, from, to)
100  const close = () => surface.setState(EMPTY)
101  const submit = (value: string) => {
102    const comment = value.trim()
103    if (comment) {
104      surface.post({
105        type: 'comment',
106        target: {
107          path: props.target.path,
108          ...(props.target.symbol ? { symbol: props.target.symbol } : {}),
109          selection: text.slice(0, 2000),
110          lines: [props.startLine + from, props.startLine + to],
111        },
112        text: comment,
113      })
114    }
115    close()
116  }
117
118  return (
119    <Box flexDirection="column">
120      {props.lines.map((line, index) => {
121        const inside = index >= from && index <= to
122        return (
123          <Text wrap="truncate-end" inverse={inside} color={inside ? 'permission' : undefined}>
124            {line || ' '}
125          </Text>
126        )
127      })}
128      {state.popup && (
129        <Box position="absolute" top={top} left={1} width={width} flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1}>
130          <Text dimColor>
131            {props.selectedLabel.split('{n}').join(String(to - from + 1))} · {props.startLine + from}-{props.startLine + to}
132          </Text>
133          <Input key="selection-comment" placeholder={props.placeholder} autoFocus onSubmit={submit} />
134          <Button key="selection-cancel" plain dimColor onPress={close}>
135            {props.cancelLabel}
136          </Button>
137        </Box>
138      )}
139    </Box>
140  )
141}
142
143export default Selectable
144
hooks/prompts.ts 468 lines
1import type { CommentTarget, GeneratedBody, PlanFile, PlanModel, PlanSymbol, QaItem, ReviewComment } from '../types'
2
3export const BLOCK_SCHEMA = `\`\`\`plan-crutches
4{
5  "title": "Short plan title",
6  "summary": "Markdown. What changes and why, for a reviewer. Under 9000 characters.",
7  "task": { "source": "jira" | "user", "key": "ABC-123 (Jira only)", "title": "task title", "text": "the task text verbatim, as the user or Jira gave it" },
8  "files": [
9    {
10      "path": "relative/path/to/file.ts",
11      "status": "new" | "modified" | "deleted",
12      "description": "What changes in this file and why.",
13      "symbols": [
14        {
15          "name": "functionName",
16          "kind": "function" | "method" | "class" | "type" | "const" | "other",
17          "change": "new" | "modified" | "deleted",
18          "signature": "the full signature, no body (several lines allowed)",
19          "description": "What it does, one or two sentences.",
20          "anchor": "for a new symbol, where it goes: after existingName | before existingName | top of file | end of file"
21        }
22      ]
23    }
24  ]
25}
26\`\`\``
27
28export const PLAN_BLOCK_INSTRUCTIONS = `<plan-crutches>
29Plan Crutches is installed: the user reviews your plan in a visual pane built from a structured block.
30When you write the plan file, END it with exactly one fenced code block whose info string is plan-crutches and whose body is valid JSON (no comments, no trailing commas) of this shape:
31
32${BLOCK_SCHEMA}
33
34Rules for the block:
35- List every file the plan creates, changes or deletes. A deleted file has an empty symbols list.
36- List every symbol the plan touches in a modified file, with "change": "new" for one that does not exist yet, "modified" for an existing one whose signature or body changes, "deleted" for an existing one that goes away. Use the exact name the file declares today for modified and deleted symbols. A deleted symbol needs only name, kind and a short description.
37- Give signatures only, never implementations. Methods of a changed class are separate symbols with kind "method".
38- Put the task text in "task.text" verbatim: the Jira issue text when you fetched one (source "jira", with its key), else the user's request (source "user").
39- Keep the Markdown plan above the block as you normally write it.
40
41Review protocol:
42- When ExitPlanMode answers that the plan is under review, stop: end your turn with one short sentence and wait. Do not call ExitPlanMode again and do not change the plan until the user's next message.
43- When the user's next message carries review comments, update the plan file (both the Markdown and the block), then call ExitPlanMode again.
44- When the user's next message approves the plan, call ExitPlanMode again right away.
45</plan-crutches>`
46
47export const PLAN_BLOCK_REMINDER =
48  'Plan Crutches: end the plan file with the ```plan-crutches JSON block (title, summary, task, files with status and symbols with change new/modified/deleted and signatures). Wait after ExitPlanMode reports the plan is under review.'
49
50export function denyMessage(version: number): string {
51  return (
52    `Plan Crutches: plan version ${version} is now open for the user's review in the Plan Review pane. ` +
53    'Do not call ExitPlanMode again and do not change the plan now. End your turn with one short sentence saying the plan awaits review. ' +
54    "The user's decision arrives as the next user message: either review comments (then update the plan file and call ExitPlanMode again) or an approval (then call ExitPlanMode again)."
55  )
56}
57
58export const EXTRACTION_SYSTEM =
59  'You convert a Markdown implementation plan into a JSON object for a code review tool. Answer with the JSON object only, no prose, no code fence.'
60
61export function extractionPrompt(markdown: string): string {
62  return (
63    'Read the plan below and produce JSON of exactly this shape:\n\n' +
64    BLOCK_SCHEMA.replace(/```plan-crutches\n|\n```$/g, '') +
65    '\n\nRules: list every file the plan creates, changes or deletes with its status; list every symbol the plan touches with its change (new, modified or deleted); give signatures only, never bodies; ' +
66    'copy the task text verbatim when the plan states it, else leave task.text empty with source "none"; keep summary to the essentials.\n\n' +
67    '<plan>\n' +
68    markdown.slice(0, 60000) +
69    '\n</plan>'
70  )
71}
72
73export const BODY_SYSTEM =
74  'You are a senior engineer implementing one symbol of a planned change. ' +
75  'The whole plan, the code already generated for its other symbols and the real project files are given: ' +
76  'call the planned symbols by the exact names and signatures the plan gives, reuse the helpers that exist in the project files, ' +
77  'and keep the logic consistent with the generated code and the plan summary. ' +
78  'Answer with one fenced code block holding only that symbol with its implementation, in the language of the file. No prose outside the block.'
79
80/** Caps of the body prompt, in characters. */
81export const BODY_LIMITS = {
82  summary: 8000,
83  surface: 30000,
84  bodies: 40000,
85  currentFile: 24000,
86  currentSymbol: 12000,
87  relatedFile: 10000,
88  relatedTotal: 60000,
89  fileList: 3500,
90}
91
92function cut(text: string, limit: number): string {
93  return text.length > limit ? `${text.slice(0, limit)}\n… (cut, ${text.length - limit} more characters)` : text
94}
95
96function changeTag(symbol: PlanSymbol): string {
97  return symbol.change && symbol.change !== 'new' ? `, ${symbol.change}` : ''
98}
99
100/**
101 * The whole plan as an API surface: every file with its status and description,
102 * every symbol with its full signature and description. Stable across the
103 * symbols of one plan version, so it opens the prompt and can be cached.
104 */
105export function planSurface(plan: PlanModel, limit = BODY_LIMITS.surface): string {
106  const lines: string[] = [`Plan: ${plan.title}`, '', `Plan summary:\n${cut(plan.summary, BODY_LIMITS.summary)}`, '', 'All planned files and symbols:']
107  for (const file of plan.files) {
108    lines.push(`- ${file.path} (${file.status})${file.description ? `: ${file.description}` : ''}`)
109    for (const symbol of file.symbols) {
110      lines.push(`  - [${symbol.kind}${changeTag(symbol)}] ${symbol.name}${symbol.anchor ? ` (${symbol.anchor})` : ''}`)
111      for (const part of symbol.signature.split('\n')) lines.push(`      ${part}`)
112      if (symbol.description) lines.push(`      ${symbol.description}`)
113    }
114  }
115  return cut(lines.join('\n'), limit)
116}
117
118/** Already generated implementations, the current file's own first. */
119export function generatedBodiesText(bodies: Record<string, GeneratedBody>, exceptKey: string, ownPath: string, limit = BODY_LIMITS.bodies): string {
120  const ready = Object.entries(bodies)
121    .filter(([key, body]) => key !== exceptKey && body.status === 'ready' && body.code)
122    .sort(([a], [b]) => Number(b.startsWith(`${ownPath}#`)) - Number(a.startsWith(`${ownPath}#`)))
123  if (ready.length === 0) return ''
124  const blocks: string[] = []
125  let used = 0
126  for (const [key, body] of ready) {
127    const [path, symbol] = key.split('#')
128    const block = `// ${path} · ${symbol}\n${body.code ?? ''}`
129    if (used + block.length > limit) {
130      blocks.push(`// … ${ready.length - blocks.length} more generated symbols not shown`)
131      break
132    }
133    blocks.push(block)
134    used += block.length
135  }
136  return `Implementations already generated for other planned symbols (match their names, signatures and behavior):\n\`\`\`\n${blocks.join('\n\n')}\n\`\`\``
137}
138
139export type RelatedFile = { path: string; text: string; why: string }
140
141export function relatedFilesText(files: readonly RelatedFile[], limit = BODY_LIMITS.relatedTotal): string {
142  if (files.length === 0) return ''
143  const blocks: string[] = []
144  let used = 0
145  for (const file of files) {
146    const text = cut(file.text, BODY_LIMITS.relatedFile)
147    const block = `${file.path} (${file.why}):\n\`\`\`\n${text}\n\`\`\``
148    if (used + block.length > limit) {
149      blocks.push(`… ${files.length - blocks.length} more files not shown`)
150      break
151    }
152    blocks.push(block)
153    used += block.length
154  }
155  return `Current content of related project files:\n\n${blocks.join('\n\n')}`
156}
157
158export type BodyContext = {
159  plan: PlanModel
160  file: PlanFile
161  symbol: PlanSymbol
162  fileText: string | null
163  bodies: Record<string, GeneratedBody>
164  related: readonly RelatedFile[]
165  /** For a modified symbol: its code as the file holds it today. */
166  currentCode?: string | null
167}
168
169/**
170 * The prompt that asks for one symbol's implementation. The plan surface is
171 * the first block and marked for the prompt cache: it is the same for every
172 * symbol of one plan version.
173 */
174export function bodyPrompt(context: BodyContext): { text: string; cache?: true }[] {
175  const { plan, file, symbol, fileText, bodies, related } = context
176  const own = file.symbols
177    .filter(one => one.name !== symbol.name)
178    .map(one => `- ${one.signature.split('\n')[0]}${one.change && one.change !== 'new' ? ` (${one.change})` : ''}`)
179    .join('\n')
180  const parts = [
181    `File: ${file.path} (${file.status})`,
182    `What changes in this file: ${file.description}`,
183    `Other symbols planned for this file:\n${own || '- none'}`,
184  ]
185  const generated = generatedBodiesText(bodies, `${file.path}#${symbol.name}`, file.path)
186  if (generated) parts.push(generated)
187  const relatedText = relatedFilesText(related)
188  if (relatedText) parts.push(relatedText)
189  if (fileText) parts.push(`Current content of ${file.path}:\n\`\`\`\n${cut(fileText, BODY_LIMITS.currentFile)}\n\`\`\``)
190  if (symbol.change === 'modified') {
191    const current = context.currentCode ? `Its code today, which your implementation replaces:\n\`\`\`\n${cut(context.currentCode, BODY_LIMITS.currentSymbol)}\n\`\`\`\n` : ''
192    parts.push(
193      `Modify this existing symbol: write its whole new implementation, keep the behavior the plan does not change, and keep the planned signature.\n${current}` +
194        `Signature:\n${symbol.signature}\nWhat changes: ${symbol.description}`,
195    )
196  } else {
197    parts.push(`Implement this symbol:\nSignature:\n${symbol.signature}\nWhat it does: ${symbol.description}`)
198  }
199  return [{ text: `${planSurface(plan)}\n\n`, cache: true }, { text: parts.join('\n\n') }]
200}
201
202export const PICK_FILES_SYSTEM =
203  'You pick which project files an engineer must read before implementing one symbol of a plan. Answer with a JSON array of relative file paths taken from the list, nothing else. An empty array is a valid answer.'
204
205/** Asks a cheap model which project files the implementation needs to see. */
206export function pickFilesPrompt(plan: PlanModel, file: PlanFile, symbol: PlanSymbol, fileText: string | null, fileList: readonly string[], already: readonly string[]): string {
207  const list = fileList.join('\n')
208  const parts = [
209    planSurface(plan, 12000),
210    `Symbol to implement: ${symbol.name} in ${file.path} (${file.status}).\nSignature:\n${symbol.signature}\nWhat it does: ${symbol.description}`,
211  ]
212  if (fileText) parts.push(`Current content of ${file.path}:\n\`\`\`\n${cut(fileText, 6000)}\n\`\`\``)
213  if (already.length) parts.push(`Already provided, do not list again:\n${already.map(path => `- ${path}`).join('\n')}`)
214  parts.push(
215    `Project files (${fileList.length}):\n${cut(list, BODY_LIMITS.fileList)}\n\n` +
216      'List up to 8 paths from this list whose content the engineer needs: the types and helpers the symbol uses, ' +
217      'the callers it must fit, the modules it imports. Only existing paths from the list, most useful first. JSON array only.',
218  )
219  return parts.join('\n\n')
220}
221
222/** The paths of a model reply to pickFilesPrompt that exist in `known`. */
223export function parseFileList(reply: string, known: ReadonlySet<string>, max = 8): string[] {
224  const match = reply.match(/\[[\s\S]*?\]/)
225  if (!match) return []
226  let parsed: unknown
227  try {
228    parsed = JSON.parse(match[0])
229  } catch {
230    return []
231  }
232  if (!Array.isArray(parsed)) return []
233  const out: string[] = []
234  for (const item of parsed) {
235    if (typeof item !== 'string') continue
236    const path = item.trim().replace(/^\.\//, '')
237    if (known.has(path) && !out.includes(path)) out.push(path)
238    if (out.length >= max) break
239  }
240  return out
241}
242
243/** Relative imports of a source file, as written (`./x`, `../y/z`). */
244export function relativeImports(text: string): string[] {
245  const out = new Set<string>()
246  const patterns = [
247    /\bfrom\s+['"](\.{1,2}\/[^'"]+)['"]/g,
248    /\bimport\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g,
249    /\brequire\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g,
250    /^\s*from\s+(\.+[\w.]*)\s+import\b/gm,
251  ]
252  for (const pattern of patterns) {
253    for (const match of text.matchAll(pattern)) {
254      const spec = match[1]
255      if (spec) out.add(spec)
256    }
257  }
258  return [...out]
259}
260
261/** Project paths an import could mean, for the extensions that are usual. */
262export function importCandidates(fromPath: string, spec: string): string[] {
263  const dir = fromPath.split('/').slice(0, -1)
264  let parts: string[]
265  if (/^\.+[\w.]*$/.test(spec) && !spec.includes('/')) {
266    // Python: `from ..pkg.mod import x` -> up one per extra dot, then the module path
267    const dots = spec.match(/^\.+/)?.[0].length ?? 1
268    const module = spec.slice(dots)
269    parts = [...dir.slice(0, dir.length - (dots - 1)), ...(module ? module.split('.') : [])]
270    return module ? [`${parts.join('/')}.py`, `${parts.join('/')}/__init__.py`] : [`${parts.join('/')}/__init__.py`]
271  }
272  parts = [...dir]
273  for (const piece of spec.split('/')) {
274    if (piece === '.' || piece === '') continue
275    if (piece === '..') parts.pop()
276    else parts.push(piece)
277  }
278  const base = parts.join('/')
279  const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.cs', '.php', '.swift', '.vue', '.svelte']
280  const candidates = extensions.map(ext => `${base}${ext}`)
281  for (const ext of ['.ts', '.tsx', '.js', '.mjs']) candidates.push(`${base}/index${ext}`)
282  return candidates.filter(path => !path.includes('//'))
283}
284
285function commentLine(comment: ReviewComment): string {
286  const where: string[] = []
287  if (comment.target.symbol) where.push(`symbol ${comment.target.symbol}`)
288  if (comment.target.selection) where.push(`selected code: "${comment.target.selection.slice(0, 300).replace(/\s+/g, ' ')}"`)
289  const prefix = where.length ? `[${where.join('; ')}] ` : ''
290  return `  - ${prefix}${comment.text}`
291}
292
293/** What the review adds to a revision or approval prompt beyond the comments. */
294export type ReviewNotes = {
295  /** The files the user marked OK: reviewed, no change wanted. */
296  okPaths?: readonly string[]
297  /** Whether the user edited the summary text in the plan file in this version. */
298  summaryEdited?: boolean
299}
300
301function summaryEditedNote(): string {
302  return 'The user edited the plan text (the summary) directly in the plan file. The file already holds that edit: keep the user\'s wording and build on it.'
303}
304
305export function revisionPrompt(plan: PlanModel, comments: readonly ReviewComment[], version: number, notes: ReviewNotes = {}): string {
306  const planLevel = comments.filter(one => !one.target.path)
307  const byPath = new Map<string, ReviewComment[]>()
308  for (const comment of comments) {
309    if (!comment.target.path) continue
310    const list = byPath.get(comment.target.path) ?? []
311    list.push(comment)
312    byPath.set(comment.target.path, list)
313  }
314  const sections: string[] = [
315    `Plan review (Plan Crutches), plan version ${version} of "${plan.title}". Revise the plan per the comments below, update the plan file (the Markdown and the plan-crutches block), then call ExitPlanMode again.`,
316  ]
317  if (notes.summaryEdited) sections.push(summaryEditedNote())
318  if (planLevel.length) {
319    sections.push('Comments on the whole plan:\n' + planLevel.map(one => `- ${one.text}`).join('\n'))
320  }
321  if (byPath.size) {
322    const blocks: string[] = []
323    for (const [path, list] of byPath) {
324      const isFolder = path.endsWith('/')
325      const file = plan.files.find(one => one.path === path)
326      const status = isFolder ? ' (folder)' : file ? ` (${file.status})` : ''
327      blocks.push(`- ${path}${status}\n${list.map(commentLine).join('\n')}`)
328    }
329    sections.push('Comments by file:\n' + blocks.join('\n'))
330  }
331  if (notes.okPaths && notes.okPaths.length) {
332    sections.push('Files the user marked as reviewed OK (no change wanted there; change them only when a comment above requires it):\n' + notes.okPaths.map(path => `- ${path}`).join('\n'))
333  }
334  return sections.join('\n\n')
335}
336
337export function approvePrompt(version: number, notes: ReviewNotes = {}): string {
338  const base = `Plan review (Plan Crutches): the user approved plan version ${version} without comments.`
339  if (notes.summaryEdited) return `${base} ${summaryEditedNote()} Read the plan file again, then call ExitPlanMode to proceed to implementation.`
340  return `${base} Call ExitPlanMode now to proceed to implementation.`
341}
342
343/** Where a question's selection came from, for the prompts and the record. */
344export function describeQaTarget(target: CommentTarget | undefined): string {
345  if (!target) return ''
346  const parts: string[] = []
347  if (target.path) parts.push(target.path.endsWith('/') ? `folder ${target.path}` : target.path)
348  if (target.symbol) parts.push(`symbol ${target.symbol}`)
349  if (target.lines) parts.push(`lines ${target.lines[0]}${target.lines[1] !== target.lines[0] ? `-${target.lines[1]}` : ''}`)
350  return parts.join(', ')
351}
352
353function selectionContext(target: CommentTarget | undefined): string {
354  if (!target?.selection) return ''
355  const where = describeQaTarget(target)
356  return `The user selected this text${where ? ` in ${where}` : ''}:\n"""\n${target.selection.slice(0, 4000)}\n"""\n\n`
357}
358
359/**
360 * How much of the plan an answer sees. The fork runs over the session's
361 * transcript, which may not hold the plan (a resumed or compacted session,
362 * the demo plan), so the prompt carries the plan itself; this tail of a fork
363 * is never cached, so it stays bounded.
364 */
365const ASK_SURFACE_LIMIT = 16000
366
367export function askPrompt(plan: PlanModel, question: string, focus?: string, target?: CommentTarget): string {
368  const where = focus && !target?.path ? `The user is looking at ${focus}. ` : ''
369  return (
370    `Plan Crutches question about the plan "${plan.title}" currently under review. ${where}` +
371    'Answer briefly in the language of the question, from the plan below and the project you already know. Do not change the plan and do not call tools.\n\n' +
372    `${planSurface(plan, ASK_SURFACE_LIMIT)}\n\n` +
373    selectionContext(target) +
374    `Question: ${question}`
375  )
376}
377
378/** The follow-up prompt: the thread so far, then the new question. */
379export function askMorePrompt(plan: PlanModel, item: QaItem, question: string): string {
380  const turns: string[] = [`User: ${item.question}`, `Assistant: ${item.answer ?? '(no answer)'}`]
381  for (const followup of item.followups ?? []) {
382    if (followup.status === 'asking') continue
383    turns.push(`User: ${followup.question}`)
384    turns.push(`Assistant: ${followup.answer ?? '(no answer)'}`)
385  }
386  return (
387    `Plan Crutches follow-up question about the plan "${plan.title}" currently under review. ` +
388    'Continue the conversation below. Answer briefly in the language of the question, from the plan below and the project you already know. Do not change the plan and do not call tools.\n\n' +
389    `${planSurface(plan, ASK_SURFACE_LIMIT)}\n\n` +
390    selectionContext(item.target) +
391    `Conversation so far:\n${turns.join('\n\n')}\n\nUser: ${question}`
392  )
393}
394
395/**
396 * The user's own words in the review, newest first: the latest questions,
397 * comments and, when the user typed it, the task. The explanation follows
398 * their language. Empty when the user has written nothing yet.
399 */
400export function userWords(plan: PlanModel, comments: readonly ReviewComment[] = [], qa: readonly QaItem[] = [], limit = 300): string {
401  const words: string[] = []
402  for (const item of [...qa].reverse()) {
403    for (const followup of [...(item.followups ?? [])].reverse()) words.push(followup.question)
404    words.push(item.question)
405  }
406  for (const comment of [...comments].reverse()) words.push(comment.text)
407  if (plan.task.source === 'user' && plan.task.text) words.push(plan.task.text)
408  const picked: string[] = []
409  let length = 0
410  for (const raw of words) {
411    const text = raw.replace(/\s+/g, ' ').trim()
412    if (!text) continue
413    picked.push(text.slice(0, limit))
414    length += text.length
415    if (picked.length >= 3 || length >= limit) break
416  }
417  return cut(picked.join(' / '), limit)
418}
419
420/** The sentence that fixes the answer's language: the one the user writes in, with the page's language as the fallback. */
421function answerLanguage(lang: 'en' | 'uk', words?: string): string {
422  const fallback = lang === 'uk' ? 'Ukrainian' : 'English'
423  return (
424    'Answer in the language the user writes in: the language of the user\'s own messages in this conversation' +
425    (words ? ` and of the user's own words in this review, quoted here: "${words}"` : '') +
426    `. Only when there are none, answer in ${fallback}. The headings of the answer are in that language too.`
427  )
428}
429
430/**
431 * The prompt of the popup's Explain tab: what the place the user points at
432 * is, where it sits in the project and why the plan makes the change.
433 * `fileText` is the current file when the mod has it, for the "where".
434 * `words` is the user's own text in the review (see userWords), which
435 * fixes the answer's language; `lang` is the page's language, the fallback.
436 */
437export function explainPrompt(plan: PlanModel, target: CommentTarget, focus?: string, fileText?: string | null, lang: 'en' | 'uk' = 'en', words?: string): string {
438  const where = describeQaTarget(target) || focus || 'the whole plan'
439  const file = target.path ? plan.files.find(one => one.path === target.path) : undefined
440  const symbol = file && target.symbol ? file.symbols.find(one => one.name === target.symbol) : undefined
441  const parts = [
442    `Plan Crutches: explain one place of the plan "${plan.title}" currently under review to its reviewer. ` +
443      `${answerLanguage(lang, words)} ` +
444      'Write Markdown, under 250 words, with three short sections titled What, Where and Why (the titles translated into the answer\'s language): ' +
445      'what this change is, where it sits in the project (the file, the symbol and what calls it or depends on it, as far as the plan and the code you know tell), and why the plan makes it (the task it serves and what would break without it). ' +
446      'Do not change the plan and do not call tools.',
447    planSurface(plan, ASK_SURFACE_LIMIT),
448    `The place to explain: ${where}.`,
449  ]
450  if (file) parts.push(`Planned change of ${file.path} (${file.status}): ${file.description}`)
451  if (symbol) parts.push(`Planned symbol ${symbol.name} [${symbol.kind}${changeTag(symbol)}]:\n${symbol.signature}\n${symbol.description}`)
452  if (fileText) parts.push(`Current content of ${target.path}:\n\`\`\`\n${cut(fileText, 8000)}\n\`\`\``)
453  const selection = selectionContext(target)
454  if (selection) parts.push(selection.trimEnd())
455  return parts.join('\n\n')
456}
457
458/** A question thread as text, for a comment or the Markdown record. */
459export function threadText(item: QaItem, answerLimit = 1500): string {
460  const lines: string[] = [`Q: ${item.question}`, `A: ${(item.answer ?? '').slice(0, answerLimit)}`]
461  for (const followup of item.followups ?? []) {
462    if (followup.status !== 'done') continue
463    lines.push(`Q: ${followup.question}`)
464    lines.push(`A: ${(followup.answer ?? '').slice(0, answerLimit)}`)
465  }
466  return lines.join('\n')
467}
468
hooks/i18n.ts 181 lines
1import type { FileStatus, Lang } from '../types'
2
3const STRINGS = {
4  en: {
5    appName: 'Plan Crutches',
6    reviewPage: 'Review page:',
7    openPage: 'Open in browser',
8    pageDown: 'The review page is not running. Run /plan-review to start it.',
9    noPlan: 'No plan under review. Start plan mode, or run /plan-review demo to try the pane.',
10    version: 'v',
11    status_idle: 'idle',
12    status_reviewing: 'reviewing',
13    status_revising: 'revising',
14    status_approved: 'approved',
15    comments: 'comments',
16    comment: 'comment',
17    submit: 'Submit',
18    submitWith: 'Submit {n} comments',
19    approve: 'Approve plan',
20    revisingNote: 'Claude is revising the plan. The pane refreshes when the new version arrives.',
21    approvedNote: 'Approved. Confirm in the plan dialog if Claude Code asks, then Claude implements.',
22    tab_changes: 'Changes',
23    tab_diagram: 'Diagram',
24    tab_ask: 'Ask',
25    task: 'Task',
26    taskFromJira: 'Task from Jira',
27    taskFromUser: 'Task as the user gave it',
28    taskNone: 'No task text was captured.',
29    summary: 'Summary',
30    showAll: 'Show all',
31    showLess: 'Show less',
32    changes: 'Changes by folder',
33    collapseAll: 'Collapse all',
34    expandAll: 'Expand all',
35    addComment: 'Comment',
36    commentPlaceholder: 'Your comment, Enter to save',
37    planComment: 'Comment on the plan',
38    delete: 'delete',
39    open: 'Open',
40    back: 'Back',
41    commentOnFile: 'Comment on file',
42    commentOnSelection: 'Comment on selection',
43    nothingSelected: 'Nothing is selected. Select text with the mouse first.',
44    selection: 'selection',
45    symbols: 'Symbols',
46    noSymbols: 'No symbols listed for this file.',
47    expandImpl: 'Expand implementation',
48    collapseImpl: 'Collapse',
49    regenerate: 'Regenerate',
50    generating: 'generating...',
51    generationFailed: 'Generation failed',
52    diffTitle: 'Changes in this file (signatures only)',
53    newFileTitle: 'New file (signatures only)',
54    deletedFile: 'This file will be deleted.',
55    fileMissing: 'The current file was not found in the project.',
56    diagramOnPage: 'The change diagram is on the review page: open it from the link above.',
57    askPlaceholder: 'Ask about these changes, Enter to send',
58    asking: 'asking...',
59    askFailed: 'No answer',
60    makeComment: 'Make it a comment',
61    warnings: 'Warnings',
62    parsed_block: 'structured block',
63    parsed_model: 'extracted by model',
64    parsed_markdown: 'markdown only',
65    parsed_none: 'none',
66    source: 'source',
67    lang: 'UA',
68    recorded: 'Recorded to',
69    status_new: 'new',
70    status_modified: 'mod',
71    status_deleted: 'del',
72    status_mixed: 'mixed',
73    status_unchanged: '',
74    resolved: 'resolved in v{n}',
75    sent: 'sent',
76    planLevel: 'Whole plan',
77    selectHint: 'Drag the mouse over lines to comment on them.',
78    selectedLines: '{n} lines selected',
79    cancel: 'Cancel',
80    lines: 'lines',
81  },
82  uk: {
83    appName: 'Plan Crutches',
84    reviewPage: 'Сторінка рев’ю:',
85    openPage: 'Відкрити в браузері',
86    pageDown: 'Сторінка рев’ю не запущена. Виконайте /plan-review, щоб запустити її.',
87    noPlan: 'Немає плану на ревʼю. Запустіть plan mode або виконайте /plan-review demo, щоб побачити панель.',
88    version: 'v',
89    status_idle: 'очікування',
90    status_reviewing: 'ревʼю',
91    status_revising: 'переробка',
92    status_approved: 'затверджено',
93    comments: 'коментарів',
94    comment: 'коментар',
95    submit: 'Надіслати',
96    submitWith: 'Надіслати {n} коментарів',
97    approve: 'Затвердити план',
98    revisingNote: 'Claude переробляє план. Панель оновиться, коли прийде нова версія.',
99    approvedNote: 'Затверджено. Підтвердьте в діалозі плану, якщо Claude Code спитає, далі Claude виконує.',
100    tab_changes: 'Зміни',
101    tab_diagram: 'Діаграма',
102    tab_ask: 'Запитати',
103    task: 'Задача',
104    taskFromJira: 'Задача з Jira',
105    taskFromUser: 'Задача так, як її подав користувач',
106    taskNone: 'Текст задачі не захоплено.',
107    summary: 'Опис змін',
108    showAll: 'Показати все',
109    showLess: 'Згорнути',
110    changes: 'Зміни по папках',
111    collapseAll: 'Згорнути все',
112    expandAll: 'Розгорнути все',
113    addComment: 'Коментар',
114    commentPlaceholder: 'Ваш коментар, Enter зберігає',
115    planComment: 'Коментар до плану',
116    delete: 'видалити',
117    open: 'Відкрити',
118    back: 'Назад',
119    commentOnFile: 'Коментар до файлу',
120    commentOnSelection: 'Коментар до виділеного',
121    nothingSelected: 'Нічого не виділено. Спочатку виділіть текст мишкою.',
122    selection: 'виділення',
123    symbols: 'Символи',
124    noSymbols: 'Для цього файлу символи не вказані.',
125    expandImpl: 'Розгорнути реалізацію',
126    collapseImpl: 'Згорнути',
127    regenerate: 'Перегенерувати',
128    generating: 'генерую...',
129    generationFailed: 'Генерація не вдалася',
130    diffTitle: 'Зміни в цьому файлі (лише сигнатури)',
131    newFileTitle: 'Новий файл (лише сигнатури)',
132    deletedFile: 'Цей файл буде видалено.',
133    fileMissing: 'Поточний файл у проєкті не знайдено.',
134    diagramOnPage: 'Діаграма змін є на сторінці рев’ю: відкрийте її за посиланням вище.',
135    askPlaceholder: 'Запитайте про ці зміни, Enter надсилає',
136    asking: 'запитую...',
137    askFailed: 'Відповіді немає',
138    makeComment: 'Зробити коментарем',
139    warnings: 'Попередження',
140    parsed_block: 'структурований блок',
141    parsed_model: 'витягнуто моделлю',
142    parsed_markdown: 'лише markdown',
143    parsed_none: 'немає',
144    source: 'джерело',
145    lang: 'EN',
146    recorded: 'Записано в',
147    status_new: 'новий',
148    status_modified: 'змін',
149    status_deleted: 'видал',
150    status_mixed: 'змішано',
151    status_unchanged: '',
152    resolved: 'опрацьовано у v{n}',
153    sent: 'надіслано',
154    planLevel: 'Весь план',
155    selectHint: 'Протягніть мишкою по рядках, щоб залишити коментар.',
156    selectedLines: 'вибрано рядків: {n}',
157    cancel: 'Скасувати',
158    lines: 'рядки',
159  },
160} as const
161
162export type StringKey = keyof (typeof STRINGS)['en']
163
164export function t(lang: Lang, key: StringKey, params?: Record<string, string | number>): string {
165  const table: Record<StringKey, string> = STRINGS[lang]
166  let text: string = table[key]
167  if (params) {
168    for (const [name, value] of Object.entries(params)) {
169      text = text.split(`{${name}}`).join(String(value))
170    }
171  }
172  return text
173}
174
175export function statusLabel(lang: Lang, status: FileStatus | 'mixed' | 'unchanged'): string {
176  const key = `status_${status}` as StringKey
177  return t(lang, key)
178}
179
180export const otherLang = (lang: Lang): Lang => (lang === 'en' ? 'uk' : 'en')
181
hooks/open-plan.ts 73 lines
1// The `open` form of the /plan-review command: a plan file the person names,
2// reviewed without plan mode. Pure: hooks/register.tsx reads the file and
3// starts the review.
4
5export type OpenArgument = { isOpen: boolean; path: string }
6
7/**
8 * Reads the command's arguments as `open <path>`: the keyword in any case,
9 * the path as typed, with one pair of surrounding quotes removed. `path` is
10 * empty when the person gave none.
11 */
12export function openArgument(args: string): OpenArgument {
13  const match = args.trim().match(/^open(?:\s+([\s\S]*))?$/i)
14  if (!match) return { isOpen: false, path: '' }
15  const given = (match[1] ?? '').trim()
16  const quoted = given.match(/^(["'])([\s\S]*)\1$/)
17  return { isOpen: true, path: (quoted?.[2] ?? given).trim() }
18}
19
20export function isAbsolutePath(path: string): boolean {
21  return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
22}
23
24/**
25 * Where a path the person typed lands: `~` is the home folder, an absolute
26 * path stays as it is, any other path is under `cwd`. `.` and `..` segments
27 * are folded, so the same file always gets the same path.
28 */
29export function resolvePlanPath(given: string, cwd: string, home: string): string {
30  let path = given.trim()
31  if (path === '~' || path.startsWith('~/')) path = `${home.replace(/\/+$/, '')}${path.slice(1)}`
32  if (!isAbsolutePath(path)) path = `${cwd.replace(/\/+$/, '')}/${path}`
33  return foldSegments(path)
34}
35
36function foldSegments(path: string): string {
37  const drive = path.match(/^[A-Za-z]:/)?.[0] ?? ''
38  const parts: string[] = []
39  for (const part of path.slice(drive.length).split('/')) {
40    if (part === '' || part === '.') continue
41    if (part === '..') {
42      parts.pop()
43      continue
44    }
45    parts.push(part)
46  }
47  return `${drive}/${parts.join('/')}`
48}
49
50/** One row of a folder listing, as `$.fs.list` gives it; only these fields matter here. */
51export type FolderEntry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number }
52
53/** Where the plan came from when the person named no path. */
54export type PlanSource = 'review' | 'newest'
55
56export type FoundPlan = { path: string; source: PlanSource }
57
58/**
59 * The newest plan file in `dir`: the `.md` file with the latest change.
60 * Folders, links and other files are skipped; undefined when none is left.
61 */
62export function newestPlanFile(dir: string, entries: readonly FolderEntry[]): string | undefined {
63  const newest = entries
64    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
65    .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
66  return newest ? `${dir.replace(/\/+$/, '')}/${newest.name}` : undefined
67}
68
69/** How the reply names the plan the mod found on its own. */
70export function planSourceNote(source: PlanSource): string {
71  return source === 'review' ? 'the plan file of this session' : 'the newest file under ~/.claude/plans'
72}
73
hooks/record.ts 136 lines
1import type { Diagram, OkMark, PlanModel, PlanVersion, QaItem, Review, ReviewComment } from '../types'
2import { diagramText } from './diagram'
3import { slugify } from './paths'
4import { describeQaTarget, threadText } from './prompts'
5
6export { slugify }
7
8export function recordFileName(nowMs: number, plan: PlanModel): string {
9  const date = new Date(nowMs).toISOString().slice(0, 10)
10  const key = plan.task.key ? `${plan.task.key.toLowerCase()}-` : ''
11  return `${date}-${key}${slugify(plan.title)}.md`
12}
13
14function commentLine(comment: ReviewComment): string {
15  const where: string[] = []
16  if (comment.target.path) where.push(comment.target.path)
17  if (comment.target.symbol) where.push(`#${comment.target.symbol}`)
18  if (comment.target.selection) where.push(`"${comment.target.selection.slice(0, 120).replace(/\s+/g, ' ')}"`)
19  const place = where.length ? `${where.join(' ')}: ` : ''
20  const state = comment.resolvedInVersion
21    ? ` (resolved in v${comment.resolvedInVersion})`
22    : comment.sentInVersion !== undefined
23      ? ' (sent)'
24      : ''
25  return `- ${place}${comment.text}${state}`
26}
27
28function versionLine(entry: PlanVersion): string {
29  const how = entry.source === 'restored' ? ` restored from v${entry.from ?? '?'}` : entry.source === 'edited' ? ' edited by the user' : ' received'
30  return `- v${entry.version}${how} ${entry.receivedAt}: ${entry.title}, ${entry.fileCount} files${entry.path ? ` (${entry.path})` : ''}`
31}
32
33/** The Markdown record of a reviewed plan, rewritten whole on every change. */
34export function renderRecord(args: {
35  plan: PlanModel
36  review: Review
37  comments: readonly ReviewComment[]
38  diagram?: Diagram
39  qa?: readonly QaItem[]
40  ok?: Readonly<Record<string, OkMark>>
41  versions?: readonly PlanVersion[]
42  nowMs: number
43}): string {
44  const { plan, review, comments, nowMs } = args
45  const qa = args.qa ?? []
46  const ok = args.ok ?? {}
47  const out: string[] = []
48  out.push(`# ${plan.title}`)
49  out.push('')
50  out.push(`Status: ${review.status} | Version: ${review.version} | Updated: ${new Date(nowMs).toISOString()}`)
51  if (review.planFilePath) out.push(`Plan file: ${review.planFilePath}`)
52  out.push(`Structure source: ${review.parsedFrom}`)
53  if (review.summaryEditedInVersion !== undefined) out.push(`Summary edited by the user in v${review.summaryEditedInVersion}`)
54  out.push('')
55  out.push('## Task')
56  out.push('')
57  if (plan.task.text) {
58    const source = plan.task.source === 'jira' ? `Jira${plan.task.key ? ` ${plan.task.key}` : ''}` : 'the user'
59    out.push(`Source: ${source}${plan.task.title ? ` | ${plan.task.title}` : ''}`)
60    out.push('')
61    out.push(plan.task.text)
62  } else {
63    out.push('No task text was captured.')
64  }
65  out.push('')
66  out.push('## Summary')
67  out.push('')
68  out.push(plan.summary)
69  out.push('')
70  out.push('## Changes')
71  out.push('')
72  if (plan.files.length === 0) out.push('No structured file list.')
73  for (const file of plan.files) {
74    const mark = ok[file.path] ? ` (OK in v${ok[file.path]?.version})` : ''
75    out.push(`- [${file.status}] ${file.path}${mark}${file.description ? ` — ${file.description}` : ''}`)
76    for (const symbol of file.symbols) {
77      const signature = symbol.signature.split('\n')[0] ?? symbol.name
78      const change = symbol.change && symbol.change !== 'new' ? `[${symbol.change}] ` : ''
79      out.push(`  - ${change}\`${signature}\`${symbol.description ? ` — ${symbol.description}` : ''}`)
80    }
81  }
82  out.push('')
83  out.push('## Review comments')
84  out.push('')
85  if (comments.length === 0) out.push('None.')
86  const versions = [...new Set(comments.map(one => one.createdInVersion))].sort((a, b) => a - b)
87  for (const version of versions) {
88    out.push(`### Version ${version}`)
89    out.push('')
90    for (const comment of comments.filter(one => one.createdInVersion === version)) out.push(commentLine(comment))
91    out.push('')
92  }
93  const answered = qa.filter(item => item.status === 'done')
94  if (answered.length) {
95    out.push('## Questions')
96    out.push('')
97    for (const item of answered) {
98      const where = describeQaTarget(item.target) || item.focus || ''
99      out.push(`### ${item.question.split('\n')[0]?.slice(0, 120) ?? 'Question'}${where ? ` (${where})` : ''}`)
100      out.push('')
101      if (item.target?.selection) {
102        out.push('> ' + item.target.selection.slice(0, 600).split('\n').join('\n> '))
103        out.push('')
104      }
105      out.push(threadText(item, 4000))
106      out.push('')
107    }
108  }
109  const diagram = diagramText(args.diagram?.graph)
110  if (diagram) {
111    out.push('## Diagram')
112    out.push('')
113    out.push(`Generated for version ${args.diagram?.version ?? review.version}${review.diagramSvgPath ? ` | SVG: ${review.diagramSvgPath}` : ''}`)
114    out.push('')
115    out.push(diagram)
116    out.push('')
117  }
118  if (review.warnings.length) {
119    out.push('## Warnings')
120    out.push('')
121    for (const warning of review.warnings) out.push(`- ${warning}`)
122    out.push('')
123  }
124  if (args.versions && args.versions.length) {
125    out.push('## Versions')
126    out.push('')
127    for (const entry of args.versions) out.push(versionLine(entry))
128    out.push('')
129  }
130  out.push('## History')
131  out.push('')
132  for (const entry of review.history) out.push(`- ${entry}`)
133  out.push('')
134  return out.join('\n')
135}
136