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…

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/.
plan-crutches JSON block. The block marks every symbol as new, modified or deleted. The mod adds this instruction to the plan mode reminder.ExitPlanMode, the mod reads the plan file, starts the review page server, opens the page and tells Claude to wait.~/.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.ExitPlanMode again. The page shows version 2 and marks your comments as resolved. Highlight changes shows what version 2 changed against version 1.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 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.
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.The pane in Claude Code stays as a second view with the page link, so a terminal session still has a review surface.
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.
| Command | What it does |
|---|---|
/plan-review | If the page server is not running, starts it. Opens the page and the pane. |
/plan-review demo | Loads 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 url | Prints the page address with its token. |
/plan-review restore | Brings back the stored review of this folder: the plan, the comments, the questions and the diagram. |
/plan-review close | Closes the pane. The page keeps running. |
/plan-review stop | Stops the page server. |
/plan-review reset | Clears the review state. |
Set them in /config or in settings.json under pluginConfigs.plan-crutches.options.
| Option | Default | Meaning |
|---|---|---|
language | en | Page and pane language, en or uk. |
model | sonnet | Model 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. |
bodyModel | opus | Model that writes method bodies. Empty means the model option. |
plansDir | ~/.claude/plan-crutches | Folder 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. |
summaryLimit | 9000 | Characters of the summary shown before Show all. |
port | 4817 | Port of the page server on 127.0.0.1. A taken port falls back to a free one. |
openIn | auto | Where the page opens: auto tries the Claude desktop browser pane, then the system browser. system uses the system browser. none only shows the link. |
nodePath | empty | Path of the node executable. Empty means node from PATH, then the usual install locations. Node 18 or newer. |
Expand implementation and Show implementation ask bodyModel for one symbol's body. The prompt carries, in this order:
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.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.
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.
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.
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.
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.
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.
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.
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.~/.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.ExitPlanMode. If the new version does not appear, run /plan-review open again.hooks/register.tsx 1923 lines1import { 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 lines1// 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`
110hooks/diagram.ts 399 lines1// 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}
399hooks/diff.ts 365 lines1// 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}
365hooks/plan-parser.ts 285 lines1import 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}
285hooks/pane.tsx 631 lines1import 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}
631hooks/paths.ts 84 lines1// 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}
84hooks/select.tsx 144 lines1import 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
144hooks/prompts.ts 468 lines1import 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}
468hooks/i18n.ts 181 lines1import 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')
181hooks/open-plan.ts 73 lines1// 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}
73hooks/record.ts 136 lines1import 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