Lore inside Claude Code: an arc window (a chip for this session's arc that opens into its streams, Brief, decisions and documents) and a review panel for…

Lore is a working model for collaborating with coding agents, packaged as persistent memory and a protocol layer for agent harnesses — Claude Code (reference baseline), OpenCode, and Codex CLI.
Each session starts from what previous sessions recorded — conventions, gotchas, architecture notes, in-flight work.
Requires bash, Python 3, and Go (builds the TUI). Go can be skipped for knowledge and memory use alone, but the TUI is required for /coordinate — it allows coordinator agents to manage worker sessions.
git clone git@github.com:anticorrelator/lore.git && cd lore
bash install.sh # --framework claude-code (default) | opencode | codex
Then, in a project you work on:
cd your-project
lore init # create the knowledge store — or run /bootstrap in an agent session to seed it
--dry-run previews, --uninstall removes (data preserved). Re-run with a different --framework to switch harnesses.
Most of lore is automatic. Hooks load relevant knowledge, active work, and conversation threads at session start, and queue new insights for capture at session end. Normal coding sessions need no lore-specific commands.
TUI — lore with no arguments. Work items, live agent sessions in embedded terminals (spawn, drive, triage), knowledge browsing, review-finding follow-ups, and the settlement view.
Arc window — in Claude Code, a session working on an arc (/coordinate) shows a one-row summary above the prompt. Press its ▴, or run /arcs, to open a window with the step graph, the Brief and decisions one line each, the arc's documents, and each step's work item. It appears only in projects with a lore store, and only while an arc is active. Restart running sessions after installing to load it.
Review panel — /reviews in Claude Code opens the project's open /pr-review results: pick a review, select and edit its comments and findings, choose Comment / Approve / Request changes, preview the payload, and post it. A review a session makes announces itself. The panel changes reviews only through lore followup edit-review and lore followup post-review.
Driving work — slash commands in an agent session:
/coordinate | drive a feature end-to-end across protocol sessions |
/spec → /implement | plan a work item, then execute the plan with worker agents |
/work | create, resume, and check status of work items |
/remember | capture insights from the current conversation |
/pr-review | multi-lens PR review (--self for your own branch) |
Ceremonies — periodic maintenance:
/bootstrap | seed a new project's store by exploring the codebase |
/retro | score how well the system supported a finished work cycle |
/evolve | review and apply protocol suggestions accumulated by retro |
/renormalize | prune and rebalance the knowledge store |
Configuration — the installer persists framework and role→model settings to ~/.lore/config/. lore config show prints them; lore harness enable|disable toggles integration per harness. To turn lore off for one project in Claude Code, add {"env": {"LORE_AGENT_DISABLED": "1"}} to the project's .claude/settings.local.json, or to .claude/settings.json to share the setting. That stops lore's hooks and the arc window there.
The wider CLI (lore search, lore work, …) is used by agents and scripts.
Everything lore keeps lives per-project under ~/.lore/repos/<host>/<org>/<repo>/: knowledge entries (with provenance), work items and plans, conversational threads, and scorecard telemetry. Logic lives in this repo; data lives in ~/.lore/ and survives reinstalls.
hooks/register.tsx 731 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LoreArc, LoreFile, LorePopout, LoreReview, LoreReviewConfirm, LoreReviewList } from '../types'
5import type { Actions } from './draw'
6import { drawChip, drawPopout, drawWindow, titleOf } from './draw'
7import type { Ran, Snapshot } from './parse'
8import {
9 activeStoreOf,
10 boardOf,
11 boundArc,
12 detailOf,
13 documentOf,
14 isDue,
15 mentions,
16 pages,
17 railsOf,
18 refreshedSnapshotOf,
19 snapshotOf,
20 titlesOf,
21 workItemOf,
22} from './parse'
23import type { Fields, ReviewActions } from './review-draw'
24import { drawReviews } from './review-draw'
25import { createdReviews, reviewOf, reviewsOf, shortTitle, touchedReviews } from './review-parse'
26
27const WINDOW = 'lore-arcs'
28const REVIEWS = 'lore-reviews'
29const POLL_MS = 30_000
30// One width for the life of the session: resizing the dock reflows the whole
31// conversation, so the dock is sized once, by whichever pane opens first, and
32// the person's own drag wins after.
33const WINDOW_COLUMNS = 64
34let isSized = false
35
36const store = atom({ plugin: 'lore', key: 'store' } as const, null)
37const board = atom({ plugin: 'lore', key: 'board' } as const, null)
38const titles = atom({ plugin: 'lore', key: 'titles' } as const, [])
39const candidates = atom({ plugin: 'lore', key: 'candidates' } as const, [])
40const pinned = atom({ plugin: 'lore', key: 'pinned' } as const, null)
41const isOpen = atom({ plugin: 'lore', key: 'isOpen' } as const, false)
42const rails = atom({ plugin: 'lore', key: 'rails' } as const, null)
43const detail = atom({ plugin: 'lore', key: 'detail' } as const, null)
44const openEntries = atom({ plugin: 'lore', key: 'openEntries' } as const, [])
45const popout = atom({ plugin: 'lore', key: 'popout' } as const, null)
46const isReviewsOpen = atom({ plugin: 'lore', key: 'isReviewsOpen' } as const, false)
47const reviews = atom({ plugin: 'lore', key: 'reviews' } as const, null)
48const review = atom({ plugin: 'lore', key: 'review' } as const, null)
49const reviewChosen = atom({ plugin: 'lore', key: 'reviewChosen' } as const, null)
50const reviewOpen = atom({ plugin: 'lore', key: 'reviewOpen' } as const, [])
51const reviewEditing = atom({ plugin: 'lore', key: 'reviewEditing' } as const, null)
52const reviewConfirm = atom({ plugin: 'lore', key: 'reviewConfirm' } as const, null)
53const reviewBusy = atom({ plugin: 'lore', key: 'reviewBusy' } as const, null)
54const reviewNotice = atom({ plugin: 'lore', key: 'reviewNotice' } as const, null)
55const reviewPopout = atom({ plugin: 'lore', key: 'reviewPopout' } as const, null)
56
57// The window body's width at its last drawing: the board verb renders rails to
58// it, and only a drawing knows it.
59let paneWidth = 0
60let isPolling = false
61const inflight = new Set<string>()
62
63// Work started for later, a poll or a refetch: a reload or an ended session
64// can cut it off mid-call, and nothing waits on it to hear that.
65function detach(work: Promise<void>): void {
66 work.catch(() => undefined)
67}
68
69async function lore($: EngineInterface, args: readonly string[], timeoutMs: number, stdin?: string): Promise<Ran> {
70 try {
71 return await $.process.run(['lore', ...args], { timeoutMs, ...(stdin === undefined ? {} : { stdin }) })
72 } catch {
73 return null
74 }
75}
76
77async function remember($: EngineInterface, slugs: string[]): Promise<void> {
78 if (slugs.length === 0) return
79 await update($, candidates, prior => [...prior.filter(s => !slugs.includes(s)), ...slugs].slice(-20))
80}
81
82async function pinKey($: EngineInterface): Promise<string> {
83 return 'pin:' + (await $.session.id())
84}
85
86async function currentArc($: EngineInterface): Promise<LoreArc | null> {
87 const shown = await read($, board)
88 return shown ? boundArc(shown.arcs, await read($, candidates), await read($, pinned)) : null
89}
90
91async function poll($: EngineInterface): Promise<void> {
92 if (isPolling) return
93 isPolling = true
94 try {
95 const dir = await read($, store)
96 if (!dir) return
97 const isShown = (await read($, isOpen)) || (await read($, pinned)) !== null || (await read($, candidates)).length > 0
98 if (!isShown) {
99 // Nothing is drawn, so the board is not refreshed: the file as it stands
100 // is enough to say whether there is an arc to offer /arcs for.
101 if (!isOffered) await offerArcs($, boardOf(await readSnapshot($, dir)).arcs)
102 return
103 }
104
105 let snapshot = await readSnapshot($, dir)
106 if (isDue(snapshot, await $.clock.now())) {
107 snapshot = refreshedSnapshotOf(await lore($, ['coordinate', 'read', '--refresh', '--json'], 30_000)) ?? snapshot
108 }
109 const fresh = boardOf(snapshot)
110 await offerArcs($, fresh.arcs)
111 const mine = boundArc(fresh.arcs, await read($, candidates), await read($, pinned))
112 const key = JSON.stringify({ slugs: fresh.arcs.map(arc => arc.slug), mine, error: fresh.error })
113 if (key !== lastBoardKey || (await read($, board)) === null) {
114 lastBoardKey = key
115 await update($, board, () => fresh)
116 }
117 if (mine) {
118 const stamp = await ledgerStamp($, dir, mine.slug)
119 if (stamp !== lastLedgerStamp) {
120 lastLedgerStamp = stamp
121 isDetailDirty = true
122 }
123 }
124
125 const known = new Set((await read($, titles)).map(title => title.slug))
126 if (fresh.arcs.some(arc => !known.has(arc.slug))) {
127 const listed = titlesOf(await lore($, ['arc', 'list', '--json'], 20_000))
128 if (listed) await update($, titles, () => listed)
129 }
130 await sync($)
131 } finally {
132 isPolling = false
133 }
134}
135
136async function readSnapshot($: EngineInterface, dir: string): Promise<Snapshot | null> {
137 try {
138 const text = await $.fs.read(`${dir}/_coordination/display.json`)
139 return snapshotOf(typeof text === 'string' ? text : null)
140 } catch {
141 return null
142 }
143}
144
145// /arcs is offered once there is an active arc to show, so a lore store with
146// none gains no command. The engine has no way to withdraw one, so it stays.
147let isOffered = false
148
149async function offerArcs($: EngineInterface, arcs: readonly LoreArc[]): Promise<void> {
150 if (isOffered || arcs.length === 0) return
151 isOffered = true
152 await $.command.register({ name: 'arcs', description: "Open or close lore's arc window" })
153}
154
155let lastBoardKey = ''
156let lastLedgerStamp = ''
157let isDetailDirty = false
158
159// The Brief, decisions and file list change when the coordinator writes the
160// ledger or digest, which moves no stream row; their mtimes say when to refetch.
161async function ledgerStamp($: EngineInterface, dir: string, slug: string): Promise<string> {
162 const parts: string[] = []
163 for (const name of ['coordination.md', 'digest.md']) {
164 try {
165 parts.push(String((await $.fs.stat(`${dir}/_work/_arcs/${slug}/${name}`)).mtimeMs))
166 } catch {
167 parts.push('-')
168 }
169 }
170 try {
171 parts.push(String((await $.fs.list(`${dir}/_work/_arcs/${slug}`)).length))
172 } catch {
173 parts.push('-')
174 }
175 return parts.join(':')
176}
177
178async function once(key: string, work: () => Promise<void>): Promise<void> {
179 if (inflight.has(key)) return
180 inflight.add(key)
181 try {
182 await work()
183 } finally {
184 inflight.delete(key)
185 }
186}
187
188type Drawn = { slug: string; generation: number; width?: number } | null
189
190function railWidth(width: number): number {
191 return Math.max(16, Math.floor(width / 8) * 8)
192}
193
194function isRailsCurrent(arc: LoreArc, generation: number, width: number, drawn: Drawn): boolean {
195 return drawn?.slug === arc.slug && drawn.width === width && drawn.generation === generation
196}
197
198function isDetailCurrent(arc: LoreArc, generation: number, loaded: Drawn): boolean {
199 return loaded?.slug === arc.slug && loaded.generation === generation
200}
201
202async function sync($: EngineInterface): Promise<void> {
203 if (!(await read($, isOpen))) return
204 const shown = await read($, board)
205 const arc = await currentArc($)
206 if (!shown || !arc || paneWidth <= 0) return
207 const width = railWidth(paneWidth)
208 const { generation } = shown
209 const work: Promise<void>[] = []
210 if (!isRailsCurrent(arc, generation, width, await read($, rails))) {
211 work.push(
212 once(`rails ${arc.slug} ${width} ${generation}`, async () => {
213 const ran = await lore($, ['coordinate', 'board', '--arc', arc.slug, '--width', String(width), '--json'], 30_000)
214 const drawn = railsOf(arc.slug, width, generation, ran)
215 await update($, rails, () => drawn)
216 }),
217 )
218 }
219 if (isDetailDirty || !isDetailCurrent(arc, generation, await read($, detail))) {
220 isDetailDirty = false
221 work.push(
222 once(`detail ${arc.slug} ${generation} ${lastLedgerStamp}`, async () => {
223 const loaded = detailOf(arc.slug, generation, await lore($, ['arc', 'show', arc.slug, '--json'], 20_000))
224 await update($, detail, () => loaded)
225 }),
226 )
227 }
228 await Promise.all(work)
229}
230
231async function openWindow($: EngineInterface): Promise<void> {
232 await update($, isOpen, () => true)
233 await $.ui.open(isSized ? { id: WINDOW, title: 'Arc' } : { id: WINDOW, title: 'Arc', columns: WINDOW_COLUMNS })
234 isSized = true
235 detach(poll($))
236}
237
238async function closeWindow($: EngineInterface): Promise<void> {
239 await update($, isOpen, () => false)
240 await update($, popout, () => null)
241 await $.ui.close({ id: WINDOW })
242}
243
244// The pane, not a saved flag, says whether the window is up: a flag outlives a
245// reload or a close the engine made, and a toggle trusting it closes nothing.
246async function isPaneShown($: EngineInterface, id: string): Promise<boolean> {
247 return (await $.ui.panes()).some(pane => pane.id === id && pane.isShown)
248}
249
250async function isWindowShown($: EngineInterface): Promise<boolean> {
251 return isPaneShown($, WINDOW)
252}
253
254async function toggleWindow($: EngineInterface): Promise<void> {
255 if (await isWindowShown($)) await closeWindow($)
256 else await openWindow($)
257}
258
259// A document or work item replaces the window's body rather than opening a
260// pane of its own: a second pane adds a tab row and reflows the dock.
261async function popOut($: EngineInterface, load: () => Promise<LorePopout>): Promise<void> {
262 const loaded = await load()
263 await update($, popout, () => loaded)
264}
265
266function actionsFor($: EngineInterface): Actions {
267 return {
268 toggleWindow: () => toggleWindow($),
269 close: () => closeWindow($),
270 pin: async slug => {
271 await update($, pinned, () => slug)
272 await $.store.set(await pinKey($), slug)
273 detach(sync($))
274 },
275 toggleOpen: async key => {
276 await update($, openEntries, open => (open.includes(key) ? open.filter(k => k !== key) : [...open, key]))
277 },
278 openDocument: async (file: LoreFile) => {
279 const dir = await read($, store)
280 const loaded = await read($, detail)
281 if (!dir || !loaded) return
282 const source = `${dir}/${loaded.path}/${file.path}`
283 await popOut($, async () => {
284 try {
285 const text = await $.fs.read(source)
286 return documentOf(file.path, source, typeof text === 'string' ? text : null)
287 } catch {
288 return documentOf(file.path, source, null)
289 }
290 })
291 },
292 openWorkItem: async slug => {
293 await popOut($, async () => workItemOf(slug, await lore($, ['work', 'show', slug, '--json'], 30_000)))
294 },
295 closePopout: async () => {
296 await update($, popout, () => null)
297 },
298 }
299}
300
301
302// --- The review panel ---
303// Open reviews are the project's pending follow-ups that /pr-review made. The
304// panel reads them through `lore followup list|view --json` and changes them
305// only through `lore followup edit-review|post-review|dismiss|promote`.
306
307// /reviews is offered once the project has an open review, or a session makes
308// one, as /arcs waits for an arc.
309let isReviewsOffered = false
310let reviewStamp = ''
311
312async function offerReviews($: EngineInterface): Promise<void> {
313 if (isReviewsOffered) return
314 isReviewsOffered = true
315 await $.command.register({ name: 'reviews', description: "Open or close lore's review panel" })
316}
317
318async function loadReviews($: EngineInterface): Promise<LoreReviewList> {
319 const listed = reviewsOf(await lore($, ['followup', 'list', '--json'], 20_000))
320 await update($, reviews, () => listed)
321 if (listed.entries.length > 0) await offerReviews($)
322 const chosen = await read($, reviewChosen)
323 if (!listed.error && !listed.entries.some(entry => entry.id === chosen)) {
324 await update($, reviewChosen, () => listed.entries[0]?.id ?? null)
325 }
326 return listed
327}
328
329async function loadReview($: EngineInterface, id: string): Promise<void> {
330 const loaded = reviewOf(id, await lore($, ['followup', 'view', '--json', id], 20_000))
331 if ((await read($, reviewChosen)) === id) await update($, review, () => loaded)
332}
333
334// Edits made outside the panel (the TUI, an agent running edit-review) move
335// the review's files but not its index row; their mtimes say when to reread.
336async function reviewFilesStamp($: EngineInterface, id: string): Promise<string> {
337 const dir = await read($, store)
338 const parts: string[] = [id]
339 for (const name of ['proposed-comments.json', 'lens-findings.json', '_meta.json']) {
340 try {
341 parts.push(String((await $.fs.stat(`${dir}/_followups/${id}/${name}`)).mtimeMs))
342 } catch {
343 parts.push('-')
344 }
345 }
346 return parts.join(':')
347}
348
349async function pollReviews($: EngineInterface): Promise<void> {
350 if (!(await read($, isReviewsOpen))) return
351 await once('reviews', async () => {
352 await loadReviews($)
353 const chosen = await read($, reviewChosen)
354 if (!chosen) return
355 const stamp = await reviewFilesStamp($, chosen)
356 const shown = await read($, review)
357 // A field being typed in keeps its text: a reread would redraw it.
358 if (shown?.id === chosen && (stamp === reviewStamp || (await read($, reviewEditing)) !== null)) return
359 reviewStamp = stamp
360 await loadReview($, chosen)
361 })
362}
363
364async function openReviews($: EngineInterface): Promise<void> {
365 await update($, isReviewsOpen, () => true)
366 await $.ui.open(isSized ? { id: REVIEWS, title: 'Reviews' } : { id: REVIEWS, title: 'Reviews', columns: WINDOW_COLUMNS })
367 isSized = true
368 detach(pollReviews($))
369}
370
371async function closeReviews($: EngineInterface): Promise<void> {
372 await update($, isReviewsOpen, () => false)
373 await update($, reviewPopout, () => null)
374 await update($, reviewEditing, () => null)
375 await update($, reviewConfirm, () => null)
376 await $.ui.close({ id: REVIEWS })
377}
378
379async function toggleReviews($: EngineInterface): Promise<void> {
380 if (await isPaneShown($, REVIEWS)) await closeReviews($)
381 else await openReviews($)
382}
383
384// The verb's own words for why it refused: its JSON error, else its last line.
385function reasonOf(ran: Ran): string {
386 if (!ran) return 'lore did not answer'
387 try {
388 const answer: unknown = JSON.parse(ran.stdout)
389 if (typeof answer === 'object' && answer !== null && 'error' in answer && typeof answer.error === 'string') return answer.error
390 } catch {
391 // not JSON: fall through to the text
392 }
393 const lines = `${ran.stderr ?? ''}\n${ran.stdout}`.split('\n').map(line => line.trim()).filter(Boolean)
394 return lines.at(-1) ?? `exit ${ran.exitCode}`
395}
396
397type Target = { args: string[]; kind: 'comment' | 'general' | 'finding'; index: number }
398
399function targetOf(key: string): Target | null {
400 if (key === 'general') return { args: ['--general'], kind: 'general', index: -1 }
401 const match = /^(comment|finding):(\d+)$/.exec(key)
402 if (!match?.[1] || !match[2]) return null
403 const kind = match[1] === 'comment' ? 'comment' : 'finding'
404 return { args: [`--${kind}`, match[2]], kind, index: Number(match[2]) }
405}
406
407function isSelectedIn(shown: LoreReview, target: Target): boolean {
408 if (target.kind === 'general') return shown.general?.selected === true
409 const items = target.kind === 'comment' ? shown.comments : shown.findings
410 return items[target.index]?.selected === true
411}
412
413function withSelected(shown: LoreReview, target: Target, selected: boolean): LoreReview {
414 if (target.kind === 'general') return shown.general ? { ...shown, general: { ...shown.general, selected } } : shown
415 if (target.kind === 'comment') {
416 return { ...shown, comments: shown.comments.map(c => (c.index === target.index ? { ...c, selected } : c)) }
417 }
418 return { ...shown, findings: shown.findings.map(f => (f.index === target.index ? { ...f, selected } : f)) }
419}
420
421function withBody(shown: LoreReview, target: Target, body: string): LoreReview {
422 if (target.kind === 'general') return shown.general ? { ...shown, general: { ...shown.general, body } } : shown
423 return { ...shown, comments: shown.comments.map(c => (c.index === target.index ? { ...c, body } : c)) }
424}
425
426// Each change is drawn at once, then made by the verb; the review is reread
427// either way, so a refusal puts back what the files hold.
428async function editReview($: EngineInterface, id: string, args: string[], stdin?: string): Promise<void> {
429 const ran = await lore($, ['followup', 'edit-review', id, ...args, '--json'], 20_000, stdin)
430 if (ran?.exitCode !== 0) await update($, reviewNotice, () => ({ text: "Couldn't save that: " + reasonOf(ran), isError: true }))
431 await loadReview($, id)
432 reviewStamp = await reviewFilesStamp($, id)
433}
434
435async function chosenReview($: EngineInterface): Promise<LoreReview | null> {
436 const shown = await read($, review)
437 return shown && shown.id === (await read($, reviewChosen)) && !shown.error ? shown : null
438}
439
440function askText(shown: LoreReview, target: Target): string {
441 if (target.kind === 'general') {
442 return `Revise the general comment of review ${shown.id}, then save it with \`lore followup edit-review ${shown.id} --general --body-file -\`. Change: `
443 }
444 if (target.kind === 'comment') {
445 const comment = shown.comments[target.index]
446 const where = comment ? `${comment.path}:${comment.line}` : ''
447 return `Revise comment ${target.index + 1} of review ${shown.id} (${where}), then save it with \`lore followup edit-review ${shown.id} --comment ${target.index} --body-file -\`. Change: `
448 }
449 const finding = shown.findings[target.index]
450 return `Look into finding "${finding?.title ?? target.index}" (${finding?.file ?? ''}:${finding?.line ?? ''}) from review ${shown.id}: `
451}
452
453async function settle($: EngineInterface, done: { text: string; isError: boolean }): Promise<void> {
454 await update($, reviewNotice, () => done)
455 await update($, reviewBusy, () => null)
456 await update($, reviewOpen, () => [])
457 const listed = await loadReviews($)
458 const chosen = await read($, reviewChosen)
459 if (chosen && listed.entries.some(entry => entry.id === chosen)) await loadReview($, chosen)
460}
461
462async function postReview($: EngineInterface, shown: LoreReview): Promise<void> {
463 await update($, reviewBusy, () => 'Posting to GitHub')
464 const ran = await lore($, ['followup', 'post-review', shown.id], 180_000)
465 if (ran?.exitCode !== 0) {
466 await settle($, { text: "Couldn't post: " + reasonOf(ran), isError: true })
467 return
468 }
469 const url = /Posted: (\S+)/.exec(ran.stdout)?.[1]
470 const marked = await lore($, ['followup', 'update', shown.id, '--status', 'reviewed'], 20_000)
471 const where = `#${shown.pr ?? '?'}`
472 await settle($, {
473 text: `Posted to ${where}${url ? ' · ' + url : ''}${marked?.exitCode === 0 ? ' · marked reviewed' : ''}`,
474 isError: false,
475 })
476}
477
478async function dismissReview($: EngineInterface, shown: LoreReview): Promise<void> {
479 await update($, reviewBusy, () => 'Dismissing')
480 const ran = await lore($, ['followup', 'dismiss', '--followup-id', shown.id], 20_000)
481 await settle($, ran?.exitCode === 0 ? { text: `Dismissed ${shortTitle(shown.title)}`, isError: false } : { text: "Couldn't dismiss: " + reasonOf(ran), isError: true })
482}
483
484async function promoteFindings($: EngineInterface, shown: LoreReview): Promise<void> {
485 const chosen = shown.findings
486 .filter(finding => finding.selected)
487 .map(({ index: _index, ...finding }) => finding)
488 await update($, reviewBusy, () => 'Promoting')
489 const ran = await lore($, ['followup', 'promote', '--followup-id', shown.id, '--findings-json', JSON.stringify(chosen)], 60_000)
490 const promoted = ran?.exitCode === 0 ? /Promoted: .*?→ (.*)$/m.exec(ran.stdout)?.[1] : undefined
491 await settle($, ran?.exitCode === 0 ? { text: `Promoted to ${promoted ?? 'a work item'}`, isError: false } : { text: "Couldn't promote: " + reasonOf(ran), isError: true })
492}
493
494function reviewActionsFor($: EngineInterface): ReviewActions {
495 return {
496 close: () => closeReviews($),
497 choose: async id => {
498 await update($, reviewChosen, () => id)
499 await update($, reviewOpen, () => [])
500 await update($, reviewEditing, () => null)
501 await update($, reviewConfirm, () => null)
502 await update($, reviewNotice, () => null)
503 await loadReview($, id)
504 reviewStamp = await reviewFilesStamp($, id)
505 },
506 toggleSelected: async key => {
507 const shown = await chosenReview($)
508 const target = targetOf(key)
509 if (!shown || !target) return
510 const isSelected = isSelectedIn(shown, target)
511 await update($, review, prior => (prior?.id === shown.id ? withSelected(prior, target, !isSelected) : prior))
512 await editReview($, shown.id, [...target.args, isSelected ? '--deselect' : '--select'])
513 },
514 setAll: async (kind, selected) => {
515 const shown = await chosenReview($)
516 if (!shown) return
517 await update($, review, prior =>
518 prior?.id !== shown.id
519 ? prior
520 : kind === 'comments'
521 ? { ...prior, comments: prior.comments.map(c => ({ ...c, selected })) }
522 : { ...prior, findings: prior.findings.map(f => ({ ...f, selected })) },
523 )
524 await editReview($, shown.id, [kind === 'comments' ? '--all-comments' : '--all-findings', selected ? '--select' : '--deselect'])
525 },
526 toggleRow: async key => {
527 await update($, reviewOpen, open => (open.includes(key) ? open.filter(k => k !== key) : [...open, key]))
528 },
529 edit: async key => {
530 await update($, reviewEditing, () => key)
531 if (key) await update($, reviewOpen, open => (open.includes(key) ? open : [...open, key]))
532 },
533 save: async (key, body) => {
534 const shown = await chosenReview($)
535 const target = targetOf(key)
536 await update($, reviewEditing, () => null)
537 if (!shown || !target || target.kind === 'finding') return
538 await update($, review, prior => (prior?.id === shown.id ? withBody(prior, target, body) : prior))
539 await editReview($, shown.id, [...target.args, '--body-file', '-'], body)
540 },
541 ask: async key => {
542 const shown = await chosenReview($)
543 const target = targetOf(key)
544 if (!shown || !target) return
545 const filled = await $.prompt.fill({ text: askText(shown, target), mode: 'insert' })
546 if (!filled.isFilled) await update($, reviewNotice, () => ({ text: "Couldn't fill the prompt here.", isError: true }))
547 },
548 setEvent: async event => {
549 const shown = await chosenReview($)
550 if (!shown || shown.event === event) return
551 await update($, review, prior => (prior?.id === shown.id ? { ...prior, event } : prior))
552 await editReview($, shown.id, ['--event', event])
553 },
554 askConfirm: async (confirm: LoreReviewConfirm) => {
555 await update($, reviewConfirm, () => confirm)
556 },
557 confirm: async () => {
558 const confirm = await read($, reviewConfirm)
559 const shown = await chosenReview($)
560 await update($, reviewConfirm, () => null)
561 if (!shown) return
562 if (confirm === 'post') await postReview($, shown)
563 else if (confirm === 'dismiss') await dismissReview($, shown)
564 else if (confirm === 'promote') await promoteFindings($, shown)
565 },
566 preview: async () => {
567 const shown = await chosenReview($)
568 if (!shown) return
569 await update($, reviewBusy, () => 'Building the payload')
570 const ran = await lore($, ['followup', 'post-review', shown.id, '--dry-run'], 60_000)
571 await update($, reviewBusy, () => null)
572 const source = `lore followup post-review ${shown.id} --dry-run`
573 const text = ran?.exitCode === 0 ? ran.stdout : "Couldn't build the payload: " + reasonOf(ran)
574 await update($, reviewPopout, () => ({ title: 'What would be posted', source, blocks: pages('code', text, `\n… continues: ${source}`) }))
575 },
576 report: async () => {
577 const shown = await chosenReview($)
578 if (!shown?.report) return
579 const source = `lore followup view ${shown.id}`
580 await update($, reviewPopout, () => ({ title: shortTitle(shown.title), source, blocks: pages('markdown', shown.report ?? '', `\n\n… continues: ${source}`) }))
581 },
582 }
583}
584
585export const register: Register = on => {
586 on('session.start', async ($, e, next) => {
587 const dir = activeStoreOf(await lore($, ['active', '--harness', 'claude-code', '--json'], 10_000))
588 await update($, store, () => dir)
589 if (!dir) return next(e)
590
591 const seen: string[] = []
592 for (const message of await $.session.messages()) {
593 for (const use of message.toolUses) {
594 if (use.tool !== 'Bash') continue
595 const command = typeof use.input['command'] === 'string' ? use.input['command'] : ''
596 seen.push(...mentions(command, use.text ?? ''))
597 }
598 }
599 await remember($, seen)
600 const kept = await $.store.get(await pinKey($))
601 if (typeof kept === 'string') await update($, pinned, () => kept)
602
603 const shown = await isWindowShown($)
604 await update($, isOpen, () => shown)
605 const isReviewsShown = await isPaneShown($, REVIEWS)
606 await update($, isReviewsOpen, () => isReviewsShown)
607 $.clock.every(POLL_MS, () => {
608 detach(poll($))
609 detach(pollReviews($))
610 })
611 detach(poll($))
612 detach(isReviewsShown ? pollReviews($) : loadReviews($).then(() => undefined))
613 return next(e)
614 })
615
616 on('command.run', { command: 'arcs' }, async $ => {
617 await toggleWindow($)
618 return { text: (await isWindowShown($)) ? 'Opened the arc window.' : 'Closed the arc window.' }
619 })
620
621 on('command.run', { command: 'reviews' }, async $ => {
622 await toggleReviews($)
623 return { text: (await isPaneShown($, REVIEWS)) ? 'Opened the review panel.' : 'Closed the review panel.' }
624 })
625
626 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
627 const ran = await next(e)
628 if (e.agentId !== undefined || !(await read($, store))) return ran
629 const result = typeof ran.text === 'string' ? ran.text : ''
630 const found = mentions(e.command, result)
631 if (found.length > 0) {
632 await remember($, found)
633 detach(poll($))
634 }
635 const made = createdReviews(result).at(-1)
636 if (made) {
637 // A review this session just made becomes the panel's, open or not.
638 await offerReviews($)
639 await update($, reviewChosen, () => made)
640 await update($, reviewNotice, () => null)
641 if (await isPaneShown($, REVIEWS)) detach(pollReviews($))
642 else $.ui.toast('Review ready: /reviews to select, edit and post its comments.')
643 } else if (touchedReviews(e.command).includes((await read($, reviewChosen)) ?? '')) {
644 detach(pollReviews($))
645 }
646 return ran
647 })
648
649 on('ui.close', async ($, e, next) => {
650 if (e.id === WINDOW) await update($, isOpen, () => false)
651 if (e.id === REVIEWS) await update($, isReviewsOpen, () => false)
652 return next(e)
653 })
654
655 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
656 if (e.props.hasSurvey || !(await read($, store))) return next(e)
657 const arc = await currentArc($)
658 if (!arc) return next(e)
659 const title = titleOf(await read($, titles), arc.slug)
660 return drawChip($.ui.resolve(e), arc, title, await read($, isOpen), e.props.bodyColumns, actionsFor($))
661 })
662
663 on('ui.render', { component: 'Pane', requestId: WINDOW }, async ($, e) => {
664 const shownDocument = await read($, popout)
665 if (shownDocument) return drawPopout($.ui.resolve(e), shownDocument, e.surface === 'terminal', actionsFor($).closePopout)
666 // The body less the window's one-cell gutter each side.
667 const width = Math.max(10, e.props.bodyColumns - 2)
668 paneWidth = width
669 const shown = await read($, board)
670 const arc = await currentArc($)
671 const drawnRails = await read($, rails)
672 const loaded = await read($, detail)
673 const isStale =
674 shown !== null &&
675 arc !== null &&
676 (!isRailsCurrent(arc, shown.generation, railWidth(width), drawnRails) || !isDetailCurrent(arc, shown.generation, loaded))
677 if (isStale) $.clock.after(0, () => detach(sync($)))
678 return drawWindow(
679 $.ui.resolve(e),
680 {
681 arcs: shown ? shown.arcs : null,
682 error: shown?.error ?? null,
683 arc,
684 titles: await read($, titles),
685 rails: drawnRails,
686 detail: loaded,
687 open: await read($, openEntries),
688 width,
689 isTerminal: e.surface === 'terminal',
690 },
691 actionsFor($),
692 )
693 })
694
695 on('ui.render', { component: 'Pane', requestId: REVIEWS }, async ($, e) => {
696 const ui = $.ui.resolve(e)
697 const isTerminal = e.surface === 'terminal'
698 const shownPopout = await read($, reviewPopout)
699 if (shownPopout) {
700 return drawPopout(ui, shownPopout, isTerminal, async () => {
701 await update($, reviewPopout, () => null)
702 })
703 }
704 const listed = await read($, reviews)
705 if (listed === null) $.clock.after(0, () => detach(pollReviews($)))
706 // The mobile app draws no field or picker yet.
707 let fields: Fields | null = null
708 if (e.surface !== 'mobile') {
709 const table = $.ui.resolve(e)
710 fields = { Input: table.Input, Select: table.Select }
711 }
712 return drawReviews(
713 ui,
714 {
715 reviews: listed,
716 review: await read($, review),
717 chosen: await read($, reviewChosen),
718 open: await read($, reviewOpen),
719 editing: await read($, reviewEditing),
720 confirm: await read($, reviewConfirm),
721 busy: await read($, reviewBusy),
722 notice: await read($, reviewNotice),
723 width: Math.max(10, e.props.bodyColumns - 2),
724 isTerminal,
725 fields,
726 },
727 reviewActionsFor($),
728 )
729 })
730}
731hooks/draw.tsx 424 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type {
4 LoreArc,
5 LoreDecision,
6 LoreDetail,
7 LoreFile,
8 LorePopout,
9 LoreRails,
10 LoreStream,
11 LoreTitle,
12} from '../types'
13
14export type Actions = {
15 toggleWindow: () => Promise<void>
16 close: () => Promise<void>
17 pin: (slug: string) => Promise<void>
18 toggleOpen: (key: string) => Promise<void>
19 openDocument: (file: LoreFile) => Promise<void>
20 openWorkItem: (slug: string) => Promise<void>
21 closePopout: () => Promise<void>
22}
23
24const GLYPH: Record<string, string> = {
25 done: '✓',
26 'in-flight': '●',
27 pending: '○',
28 'blocked-on-input': '▲',
29 dropped: '✕',
30}
31
32const CONTROL_COLUMNS = 7
33const TEXT_MIN = 12
34// Brief labels are the coordinator's own words; past this they are cut, not
35// given a wider column.
36const LABEL_MAX = 15
37
38function glyph(stream: LoreStream): string {
39 if (stream.status.startsWith('blocked-on:')) return '◈'
40 return GLYPH[stream.status] ?? '?'
41}
42
43function words(stream: LoreStream): string {
44 switch (stream.status) {
45 case 'done':
46 if (stream.verdict === 'partial') return 'done · partial'
47 if (stream.verdict === 'none') return 'done · nothing landed'
48 return 'done'
49 case 'in-flight':
50 return 'running'
51 case 'pending':
52 return 'queued'
53 case 'blocked-on-input':
54 return 'waiting on you'
55 case 'dropped':
56 return 'dropped'
57 }
58 if (stream.status.startsWith('blocked-on:')) return 'waiting on ' + stream.status.slice(11).trim()
59 return stream.status
60}
61
62export type Tally = { done: number; total: number; running: number; waiting: number }
63
64export function tally(arc: LoreArc): Tally {
65 const counted = { done: 0, total: 0, running: 0, waiting: 0 }
66 for (const stream of arc.streams) {
67 if (stream.status === 'dropped') continue
68 counted.total += 1
69 if (stream.status === 'done') counted.done += 1
70 if (stream.status === 'in-flight') counted.running += 1
71 if (stream.status === 'blocked-on-input') counted.waiting += 1
72 }
73 return counted
74}
75
76export function titleOf(titles: LoreTitle[], slug: string): string {
77 return titles.find(title => title.slug === slug)?.title ?? slug
78}
79
80export function cut(text: string, room: number): string {
81 if (text.length <= room) return text
82 if (room <= 1) return '…'
83 return text.slice(0, room - 1) + '…'
84}
85
86export function firstSentence(text: string): string {
87 const flat = text.replace(/\s+/g, ' ').trim()
88 return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat
89}
90
91// One stream as a line of text, for when the board verb can't draw the graph.
92function plainLine(stream: LoreStream, width: number): string {
93 const lead = `${glyph(stream)} ${stream.id.padStart(2)} `
94 const tail = ` ${words(stream)}`
95 const room = width - lead.length - tail.length
96 if (room < TEXT_MIN) return cut(lead + words(stream), width)
97 return lead + cut(stream.step, room).padEnd(room) + tail
98}
99
100export function drawChip(
101 ui: ElementTable,
102 arc: LoreArc,
103 title: string,
104 isWindowOpen: boolean,
105 bodyColumns: number,
106 actions: Actions,
107): RenderElement {
108 const { Box, Text, Button } = ui
109 const { done, total, running, waiting } = tally(arc)
110 const long = [` · ${done}/${total} done · ● ${running} running`, waiting > 0 ? ` · ▲ ${waiting} waiting on you` : '']
111 const short = [` ● ${running}`, waiting > 0 ? ` ▲ ${waiting}` : '']
112 const spare = (parts: string[]) => bodyColumns - 2 - CONTROL_COLUMNS - parts.join('').length
113 const parts = spare(long) >= TEXT_MIN ? long : short
114 return (
115 <Box key="chip">
116 <Text color="cyan">▣ </Text>
117 <Text>{cut(title, Math.max(4, spare(parts)))}</Text>
118 <Text dimColor>{parts[0]}</Text>
119 {waiting > 0 ? <Text color="yellow">{parts[1]}</Text> : null}
120 <Text> </Text>
121 <Button key="toggle" label={isWindowOpen ? '▾' : '▴'} hotkey="l" onPress={actions.toggleWindow} />
122 </Box>
123 )
124}
125
126export type WindowData = {
127 arcs: LoreArc[] | null
128 error: string | null
129 arc: LoreArc | null
130 titles: LoreTitle[]
131 rails: LoreRails | null
132 detail: LoreDetail | null
133 open: string[]
134 width: number
135 isTerminal: boolean
136}
137
138export type Control = { key: string; label: string; isClose: boolean; onPress: () => Promise<void> }
139
140// The heading row every lore pane opens with: a title, a dim summary to its
141// right and the close mark. The pane's own frame is the only border.
142export function heading(
143 ui: ElementTable,
144 title: string,
145 summary: RenderElement | null,
146 control: Control,
147 isTerminal: boolean,
148): RenderElement {
149 const { Box, Text, Button } = ui
150 return (
151 <Box key="heading" justifyContent="space-between">
152 <Text weight="bold" size="heading" {...(isTerminal ? { color: 'cyan' } : {})} wrap="truncate-end">
153 {title}
154 </Text>
155 <Box flexShrink={0} gap={1}>
156 {summary}
157 <Button
158 key={control.key}
159 plain
160 dimColor
161 label={control.label}
162 {...(control.isClose ? { role: 'dismiss' as const } : {})}
163 onPress={control.onPress}
164 />
165 </Box>
166 </Box>
167 )
168}
169
170export function drawWindow(ui: ElementTable, data: WindowData, actions: Actions): RenderElement {
171 const { Box, Text } = ui
172 const { arc } = data
173 const title = arc ? titleOf(data.titles, arc.slug) : 'Arc'
174 const frame = (summary: RenderElement | null, ...body: RenderElement[]) => (
175 <Box flexDirection="column" paddingX={1}>
176 {heading(ui, title, summary, { key: 'close', label: '×', isClose: true, onPress: actions.close }, data.isTerminal)}
177 {body}
178 </Box>
179 )
180
181 if (data.arcs === null) return frame(null, <Text dimColor>Reading the board…</Text>)
182 if (data.error) return frame(null, <Text color="red">{data.error}</Text>)
183 if (!arc) return frame(null, picker(ui, data.arcs, data.titles, data.width, actions))
184
185 return frame(
186 summaryOf(ui, arc, data.isTerminal),
187 streams(ui, arc, data.rails, data.width, data.isTerminal, actions),
188 sections(ui, data.detail, arc, data.open, data.width, actions),
189 )
190}
191
192function summaryOf(ui: ElementTable, arc: LoreArc, isTerminal: boolean): RenderElement {
193 const { Box, Text } = ui
194 const { done, total, running, waiting } = tally(arc)
195 return (
196 <Box key="summary">
197 <Text dimColor>
198 {done}/{total} done{running > 0 ? ` · ${running} running` : ''}
199 </Text>
200 {waiting > 0 ? <Text {...(isTerminal ? { color: 'yellow' } : {})}> · ▲ {waiting}</Text> : null}
201 </Box>
202 )
203}
204
205function picker(ui: ElementTable, arcs: LoreArc[], titles: LoreTitle[], width: number, actions: Actions): RenderElement {
206 const { Box, Text, Button } = ui
207 return (
208 <Box key="picker" flexDirection="column" marginTop={1}>
209 <Text dimColor>No arc is bound to this session. Pin one:</Text>
210 {arcs.length === 0 ? <Text dimColor>No active arcs.</Text> : null}
211 {arcs.map((arc, i) => (
212 <Button
213 key={'pin:' + arc.slug}
214 plain
215 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
216 label={cut(titleOf(titles, arc.slug), width - 3)}
217 onPress={() => actions.pin(arc.slug)}
218 />
219 ))}
220 </Box>
221 )
222}
223
224function statusColor(status: string, isTerminal: boolean): { color: string } | { dimColor: true } {
225 if (status.includes('waiting on you')) return isTerminal ? { color: 'yellow' } : { color: 'warning' }
226 return { dimColor: true }
227}
228
229// One rail line: the graph and step in the surface's monospace face, so the
230// rails line up off the terminal too, and the status words in their colour.
231function railLine(ui: ElementTable, text: string, wordsAt: number, isTerminal: boolean): RenderElement[] {
232 const { Text } = ui
233 if (wordsAt < 0) return [<Text mono>{text}</Text>]
234 const status = text.slice(wordsAt)
235 return [<Text mono>{text.slice(0, wordsAt)}</Text>, <Text mono {...statusColor(status, isTerminal)}>{status}</Text>]
236}
237
238function streams(
239 ui: ElementTable,
240 arc: LoreArc,
241 rails: LoreRails | null,
242 width: number,
243 isTerminal: boolean,
244 actions: Actions,
245): RenderElement {
246 const { Box, Text, Button } = ui
247 const byId = new Map(arc.streams.map(stream => [stream.id, stream]))
248 const drawn = rails && rails.slug === arc.slug && !rails.isOld && rails.rows.length > 0 ? rails : null
249 const open = (stream: LoreStream) => () => actions.openWorkItem(stream.workItem ?? '')
250
251 if (arc.streams.length === 0) {
252 return (
253 <Box key="streams" marginTop={1}>
254 <Text dimColor>No streams yet.</Text>
255 </Box>
256 )
257 }
258
259 if (!drawn) {
260 return (
261 <Box key="streams" flexDirection="column" marginTop={1}>
262 {arc.streams.map(stream => {
263 const line = plainLine(stream, width)
264 const at = line.lastIndexOf(words(stream))
265 const parts = railLine(ui, line, at, isTerminal)
266 return stream.workItem ? (
267 <Button key={'stream:' + stream.id} plain onPress={open(stream)}>
268 {parts}
269 </Button>
270 ) : (
271 <Box key={'line:' + stream.id}>{parts}</Box>
272 )
273 })}
274 {rails?.slug === arc.slug && rails.isOld ? <Text dimColor>run lore rebuild for the graph</Text> : null}
275 </Box>
276 )
277 }
278
279 return (
280 <Box key="streams" flexDirection="column" marginTop={1}>
281 {drawn.rows.flatMap(row => {
282 const stream = byId.get(row.id)
283 return row.lines.map((line, i) => {
284 const parts = railLine(ui, line.text, line.wordsAt, isTerminal)
285 return i === 0 && stream?.workItem ? (
286 <Button key={'stream:' + row.id} plain onPress={open(stream)}>
287 {parts}
288 </Button>
289 ) : (
290 <Box key={`line:${row.id}:${i}`}>{parts}</Box>
291 )
292 })
293 })}
294 </Box>
295 )
296}
297
298type Entry = { key: string; label: string; text: string }
299
300// A list of collapsible entries: each one line until pressed, then its whole
301// text. The Brief and the decisions both read this way, so the window stays a
302// page of headlines until the person asks for more.
303function entries(ui: ElementTable, items: Entry[], open: string[], width: number, actions: Actions): RenderElement[] {
304 const { Box, Text, Button, Markdown } = ui
305 const labelWidth = Math.min(LABEL_MAX, Math.max(0, ...items.map(item => item.label.length)))
306 return items.map(item => {
307 const first = firstSentence(item.text)
308 const isOpen = open.includes(item.key)
309 const label = item.label ? cut(item.label, labelWidth).padEnd(labelWidth) + ' ' : ''
310 const room = Math.max(TEXT_MIN, width - 2 - label.length)
311 const isWhole = first === item.text.replace(/\s+/g, ' ').trim() && first.length <= room
312 const row = (
313 <Button key={'toggle:' + item.key} plain onPress={() => actions.toggleOpen(item.key)}>
314 <Text dimColor>{isWhole ? '•' : isOpen ? '▾' : '▸'} </Text>
315 {label ? <Text color="cyan">{label}</Text> : null}
316 <Text>{isOpen ? '' : cut(first, room)}</Text>
317 </Button>
318 )
319 return (
320 <Box key={item.key} flexDirection="column">
321 {row}
322 {isOpen ? (
323 <Box paddingLeft={2}>
324 <Markdown text={cut(item.text, 9_800)} />
325 </Box>
326 ) : null}
327 </Box>
328 )
329 })
330}
331
332function decisionEntries(decisions: LoreDecision[]): Entry[] {
333 const headings = new Set(decisions.map(decision => decision.heading))
334 return decisions.map((decision, i) => ({
335 key: 'decision:' + i,
336 label: headings.size > 1 ? decision.heading : '',
337 text: decision.text,
338 }))
339}
340
341function sectionTitle(ui: ElementTable, text: string): RenderElement {
342 const { Text } = ui
343 return (
344 <Text weight="semibold" dimColor>
345 {text}
346 </Text>
347 )
348}
349
350function sections(
351 ui: ElementTable,
352 detail: LoreDetail | null,
353 arc: LoreArc,
354 open: string[],
355 width: number,
356 actions: Actions,
357): RenderElement {
358 const { Box, Text, Button } = ui
359 if (!detail || detail.slug !== arc.slug) {
360 return (
361 <Box key="sections" marginTop={1}>
362 <Text dimColor>Reading the arc…</Text>
363 </Box>
364 )
365 }
366 if (detail.error) {
367 return (
368 <Box key="sections" marginTop={1}>
369 <Text color="red">{detail.error}</Text>
370 </Box>
371 )
372 }
373 const brief = detail.brief.map(field => ({ key: 'brief:' + field.label, label: field.label, text: field.text }))
374 return (
375 <Box key="sections" flexDirection="column">
376 {brief.length > 0 ? (
377 <Box key="brief" flexDirection="column" marginTop={1}>
378 {sectionTitle(ui, 'Brief')}
379 {entries(ui, brief, open, width, actions)}
380 </Box>
381 ) : null}
382 {detail.decisions.length > 0 ? (
383 <Box key="decisions" flexDirection="column" marginTop={1}>
384 {sectionTitle(ui, 'Decisions')}
385 {entries(ui, decisionEntries(detail.decisions), open, width, actions)}
386 </Box>
387 ) : null}
388 {detail.files.length > 0 ? (
389 <Box key="documents" flexDirection="column" marginTop={1}>
390 {sectionTitle(ui, 'Documents')}
391 <Box flexWrap="wrap" columnGap={2}>
392 {detail.files.map(file => (
393 <Button key={'doc:' + file.path} plain dimColor label={file.path} onPress={() => actions.openDocument(file)} />
394 ))}
395 </Box>
396 </Box>
397 ) : null}
398 </Box>
399 )
400}
401
402export function drawPopout(ui: ElementTable, shown: LorePopout | null, isTerminal: boolean, back: () => Promise<void>): RenderElement {
403 const { Box, Text, Markdown, Code } = ui
404 return (
405 <Box flexDirection="column" paddingX={1}>
406 {heading(
407 ui,
408 shown?.title ?? 'lore',
409 null,
410 { key: 'back', label: '← back', isClose: false, onPress: back },
411 isTerminal,
412 )}
413 {shown?.source ? (
414 <Text dimColor wrap="truncate-middle">
415 {shown.source}
416 </Text>
417 ) : null}
418 {(shown?.blocks ?? []).map((block, i) =>
419 block.kind === 'markdown' ? <Markdown key={'page:' + i} text={block.text} /> : <Code source={block.text} />,
420 )}
421 </Box>
422 )
423}
424hooks/parse.ts 253 lines1import type {
2 LoreArc,
3 LoreBlock,
4 LoreBoard,
5 LoreDetail,
6 LorePopout,
7 LoreRails,
8 LoreTitle,
9} from '../types'
10
11export type Ran = { exitCode: number; stdout: string; stderr?: string; isStdoutTruncated: boolean } | null
12
13const SNAPSHOT_VERSION = '1'
14// Markdown and Code each take at most 10,000 characters.
15const BLOCK_ROOM = 9_800
16const MAX_BLOCKS = 10
17
18function parse(text: string): unknown {
19 try {
20 return JSON.parse(text)
21 } catch {
22 return undefined
23 }
24}
25
26function record(value: unknown): Record<string, unknown> {
27 return typeof value === 'object' && value !== null && !Array.isArray(value)
28 ? (value as Record<string, unknown>)
29 : {}
30}
31
32function list(value: unknown): unknown[] {
33 return Array.isArray(value) ? value : []
34}
35
36function text(value: unknown): string {
37 return typeof value === 'string' ? value : ''
38}
39
40function succeeded(ran: Ran): unknown {
41 return ran?.exitCode === 0 ? parse(ran.stdout) : undefined
42}
43
44export function activeStoreOf(ran: Ran): string | null {
45 if (!ran) return null
46 const answer = record(parse(ran.stdout))
47 return answer.active === true && typeof answer.store === 'string' && answer.store !== ''
48 ? answer.store
49 : null
50}
51
52export type Snapshot = Record<string, unknown>
53
54export function snapshotOf(raw: string | null): Snapshot | null {
55 if (raw === null) return null
56 const snapshot = parse(raw)
57 return snapshot === undefined ? null : record(snapshot)
58}
59
60export function refreshedSnapshotOf(ran: Ran): Snapshot | null {
61 const snapshot = succeeded(ran)
62 return snapshot === undefined ? null : record(snapshot)
63}
64
65export function isDue(snapshot: Snapshot | null, nowMs: number): boolean {
66 const nextUpdate = snapshot?.next_update
67 return !snapshot || typeof nextUpdate !== 'number' || nowMs / 1000 >= nextUpdate
68}
69
70export function boardOf(snapshot: Snapshot | null): LoreBoard {
71 if (!snapshot) return { arcs: [], generation: -1, error: "Can't read the board." }
72 if (String(snapshot.schema_version) !== SNAPSHOT_VERSION) {
73 return { arcs: [], generation: -1, error: "This window doesn't know this board's format." }
74 }
75 const streams = list(snapshot.coordination_streams).map(record)
76 const arcs: LoreArc[] = list(snapshot.coordination_arcs)
77 .map(record)
78 .filter(arc => arc.status === 'active' && typeof arc.arc === 'string')
79 .map(arc => ({
80 slug: text(arc.arc),
81 streams: streams
82 .filter(row => row.arc === arc.arc)
83 .map(row => ({
84 id: text(row.stream_id),
85 step: text(row.step),
86 status: text(row.status),
87 verdict: text(row.verdict),
88 workItem: typeof row.work_item === 'string' && row.work_item !== '' ? row.work_item : null,
89 })),
90 }))
91 const generation = typeof snapshot.generation === 'number' ? snapshot.generation : -1
92 return { arcs, generation, error: null }
93}
94
95export function titlesOf(ran: Ran): LoreTitle[] | null {
96 const listed = succeeded(ran)
97 if (listed === undefined) return null
98 return list(listed)
99 .map(record)
100 .filter(arc => typeof arc.slug === 'string')
101 .map(arc => ({ slug: text(arc.slug), title: text(arc.title) || text(arc.slug) }))
102}
103
104const encoder = new TextEncoder()
105const decoder = new TextDecoder()
106
107// The board verb reports where the status words start as a byte offset into
108// the UTF-8 line; the drawing slices a JS string, which counts UTF-16 units.
109function stringIndex(line: string, byteOffset: number): number {
110 if (byteOffset < 0) return -1
111 return decoder.decode(encoder.encode(line).slice(0, byteOffset)).length
112}
113
114export function railsOf(slug: string, width: number, generation: number, ran: Ran): LoreRails {
115 if (ran?.exitCode === 3) return { slug, generation, width, rows: [], isOld: true }
116 return {
117 slug,
118 generation,
119 width,
120 isOld: false,
121 rows: list(record(succeeded(ran)).rows)
122 .map(record)
123 .map(row => ({
124 id: text(row.stream_id),
125 lines: list(row.lines)
126 .map(record)
127 .map(line => {
128 const at = typeof line.metadata_at === 'number' ? line.metadata_at : -1
129 return { text: text(line.text), wordsAt: stringIndex(text(line.text), at) }
130 }),
131 })),
132 }
133}
134
135export function detailOf(slug: string, generation: number, ran: Ran): LoreDetail {
136 const shown = succeeded(ran)
137 if (shown === undefined) {
138 return { slug, generation, path: '', brief: [], decisions: [], files: [], error: "Can't read this arc." }
139 }
140 const arc = record(shown)
141 return {
142 slug,
143 generation,
144 path: text(arc.path),
145 brief: list(arc.brief)
146 .map(record)
147 .map(field => ({ label: text(field.label), text: text(field.text) })),
148 decisions: list(arc.decisions)
149 .map(record)
150 .map(decision => ({ text: text(decision.text), heading: text(decision.heading) })),
151 files: list(arc.files)
152 .map(record)
153 .filter(file => typeof file.path === 'string')
154 .map(file => ({ path: text(file.path), size: typeof file.size === 'number' ? file.size : 0 })),
155 error: null,
156 }
157}
158
159const FENCE = /^\s*(```|~~~)/
160
161// Splits a body into blocks under the element limit at line ends. A Markdown
162// block cut inside a code fence closes it and the next block reopens it, so
163// each block renders on its own. Past MAX_BLOCKS the last block ends with
164// `continues`.
165export function pages(kind: LoreBlock['kind'], body: string, continues: string): LoreBlock[] {
166 const blocks: LoreBlock[] = []
167 const lines = body.split('\n')
168 let current: string | null = null
169 let fence: string | null = null
170 for (let i = 0; i < lines.length; i += 1) {
171 let line = lines[i] ?? ''
172 if (line.length > BLOCK_ROOM) {
173 lines.splice(i + 1, 0, line.slice(BLOCK_ROOM))
174 line = line.slice(0, BLOCK_ROOM)
175 }
176 if (current !== null && current.length + line.length + 1 > BLOCK_ROOM) {
177 const isFenced = kind === 'markdown' && fence !== null
178 blocks.push({ kind, text: isFenced ? `${current}\n${fence}` : current })
179 if (blocks.length === MAX_BLOCKS) {
180 const last = blocks[MAX_BLOCKS - 1]
181 if (last) last.text = last.text.slice(0, BLOCK_ROOM - continues.length) + continues
182 return blocks
183 }
184 current = isFenced ? (fence ?? '') : null
185 }
186 current = current === null ? line : `${current}\n${line}`
187 const marker = kind === 'markdown' ? FENCE.exec(line)?.[1] : undefined
188 if (marker) fence = fence === null ? marker : null
189 }
190 blocks.push({ kind, text: current ?? '' })
191 return blocks
192}
193
194export function documentOf(title: string, source: string, body: string | null): LorePopout {
195 if (body === null) return { title, source, blocks: [{ kind: 'markdown', text: "Can't read this file here." }] }
196 const kind = /\.(md|markdown)$/i.test(title) ? 'markdown' : 'code'
197 return { title, source, blocks: pages(kind, body, `\n\n… continues in ${source}`) }
198}
199
200export function workItemOf(slug: string, ran: Ran): LorePopout {
201 const source = `lore work show ${slug}`
202 if (ran?.isStdoutTruncated) {
203 return { title: slug, source, blocks: [{ kind: 'markdown', text: 'This work item is too large to show here.' }] }
204 }
205 const shown = succeeded(ran)
206 if (shown === undefined) {
207 return { title: slug, source, blocks: [{ kind: 'markdown', text: "Can't read this work item." }] }
208 }
209 const item = record(shown)
210 const plan = text(item.plan_content).trim()
211 const notes = text(item.notes_content).trim()
212 const continues = `\n\n… continues: ${source}`
213 const blocks: LoreBlock[] = [
214 ...(plan ? pages('markdown', plan, continues) : []),
215 ...(notes ? pages('markdown', notes, continues) : []),
216 ]
217 if (blocks.length === 0) blocks.push({ kind: 'markdown', text: 'No plan or notes yet.' })
218 return { title: text(item.title) || slug, source, blocks }
219}
220
221// An arc a Bash command touched through lore: its ledger path, or an arc verb
222// naming it.
223const COMMAND_MENTION = /(?:_work\/_arcs\/|lore arc (?:show|member (?:add|rm)|set|close|archive) )([a-z0-9][a-z0-9-]*)/g
224
225// The one arc a result names for this session: `lore arc open`'s own line.
226// Other output lists arcs (`lore arc list --json` prints every arc's path), so
227// it binds nothing.
228const OPENED = /\[arc\] Opened: ([a-z0-9][a-z0-9-]*)/g
229
230function slugs(text: string, pattern: RegExp): string[] {
231 const found: string[] = []
232 for (const match of text.matchAll(pattern)) {
233 if (match[1]) found.push(match[1])
234 }
235 return found
236}
237
238export function mentions(command: string, result: string): string[] {
239 return [...slugs(command, COMMAND_MENTION), ...slugs(result, OPENED)]
240}
241
242export function boundArc(arcs: LoreArc[], seen: string[], pin: string | null): LoreArc | null {
243 if (pin) {
244 const chosen = arcs.find(arc => arc.slug === pin)
245 if (chosen) return chosen
246 }
247 for (let i = seen.length - 1; i >= 0; i -= 1) {
248 const chosen = arcs.find(arc => arc.slug === seen[i])
249 if (chosen) return chosen
250 }
251 return null
252}
253hooks/review-draw.tsx 363 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type {
4 LoreNotice,
5 LoreReview,
6 LoreReviewConfirm,
7 LoreReviewEvent,
8 LoreReviewList,
9} from '../types'
10import { cut, firstSentence, heading } from './draw'
11import { fromField, postableOf, shortTitle, toField } from './review-parse'
12
13export type ReviewActions = {
14 close: () => Promise<void>
15 choose: (id: string) => Promise<void>
16 toggleSelected: (key: string) => Promise<void>
17 setAll: (kind: 'comments' | 'findings', selected: boolean) => Promise<void>
18 toggleRow: (key: string) => Promise<void>
19 edit: (key: string | null) => Promise<void>
20 save: (key: string, body: string) => Promise<void>
21 ask: (key: string) => Promise<void>
22 setEvent: (event: LoreReviewEvent) => Promise<void>
23 askConfirm: (confirm: LoreReviewConfirm) => Promise<void>
24 confirm: () => Promise<void>
25 preview: () => Promise<void>
26 report: () => Promise<void>
27}
28
29// The field and picker, which every surface but the mobile app draws.
30export type Fields = Pick<ElementTable<'terminal'>, 'Input' | 'Select'>
31
32export type ReviewData = {
33 reviews: LoreReviewList | null
34 review: LoreReview | null
35 chosen: string | null
36 open: string[]
37 editing: string | null
38 confirm: LoreReviewConfirm
39 busy: string | null
40 notice: LoreNotice
41 width: number
42 isTerminal: boolean
43 fields: Fields | null
44}
45
46const EVENT_WORDS: Record<LoreReviewEvent, string> = {
47 COMMENT: 'Comment',
48 APPROVE: 'Approve',
49 REQUEST_CHANGES: 'Request changes',
50}
51
52// Checkbox, space, the row's own toggle mark and its space.
53const ROW_LEAD = 6
54
55function plural(n: number, word: string): string {
56 return `${n} ${word}${n === 1 ? '' : 's'}`
57}
58
59export function drawReviews(ui: ElementTable, data: ReviewData, actions: ReviewActions): RenderElement {
60 const { Box, Text } = ui
61 const entries = data.reviews?.entries ?? []
62 const summary = data.reviews && entries.length > 0 ? <Text dimColor>{entries.length} open</Text> : null
63 const frame = (...body: (RenderElement | null)[]) => (
64 <Box flexDirection="column" paddingX={1}>
65 {heading(ui, 'Reviews', summary, { key: 'close', label: '×', isClose: true, onPress: actions.close }, data.isTerminal)}
66 {body}
67 </Box>
68 )
69
70 if (data.reviews === null) return frame(<Text dimColor>Reading this project's reviews…</Text>)
71 if (data.reviews.error) return frame(<Text color="red">{data.reviews.error}</Text>)
72 if (entries.length === 0) {
73 return frame(
74 <Box key="empty" flexDirection="column" marginTop={1}>
75 <Text dimColor>No open reviews in this project.</Text>
76 <Text dimColor>/pr-review a pull request and its comments land here.</Text>
77 </Box>,
78 )
79 }
80
81 const review = data.review && data.review.id === data.chosen ? data.review : null
82 return frame(
83 entries.length > 1 ? chooser(ui, data, actions) : null,
84 review ? body(ui, review, data, actions) : <Text dimColor>Reading the review…</Text>,
85 )
86}
87
88function chooser(ui: ElementTable, data: ReviewData, actions: ReviewActions): RenderElement {
89 const { Box, Button } = ui
90 const entries = data.reviews?.entries ?? []
91 const label = (entry: { title: string; pr: string }) => cut(`${entry.pr ? entry.pr + ' ' : ''}${shortTitle(entry.title)}`, data.width - 4)
92 const Select = data.fields?.Select
93 if (Select) {
94 return (
95 <Box key="chooser" marginTop={1}>
96 <Select
97 key="choose"
98 options={entries.map(entry => ({ value: entry.id, label: label(entry) }))}
99 {...(data.chosen ? { value: data.chosen } : {})}
100 onSelect={id => actions.choose(id)}
101 />
102 </Box>
103 )
104 }
105 return (
106 <Box key="chooser" flexDirection="column" marginTop={1}>
107 {entries.map(entry => (
108 <Button key={'choose:' + entry.id} plain dimColor={entry.id !== data.chosen} label={label(entry)} onPress={() => actions.choose(entry.id)} />
109 ))}
110 </Box>
111 )
112}
113
114function body(ui: ElementTable, review: LoreReview, data: ReviewData, actions: ReviewActions): RenderElement {
115 const { Box, Text, Button } = ui
116 if (review.error) {
117 return (
118 <Box key="review" marginTop={1}>
119 <Text color="red">{review.error}</Text>
120 </Box>
121 )
122 }
123 const entries = data.reviews?.entries ?? []
124 const selected = review.comments.filter(comment => comment.selected).length
125 // A chooser already names the PR beside each title.
126 const where = [review.pr && entries.length === 1 ? `#${review.pr}` : '', review.repo, review.comments.length > 0 ? `${selected} of ${plural(review.comments.length, 'comment')} selected` : '']
127 .filter(Boolean)
128 .join(' · ')
129 const findingsSelected = review.findings.filter(finding => finding.selected).length
130
131 return (
132 <Box key="review" flexDirection="column">
133 {entries.length === 1 ? (
134 <Box key="title" marginTop={1}>
135 <Text weight="semibold" wrap="truncate-end">
136 {shortTitle(review.title)}
137 </Text>
138 </Box>
139 ) : null}
140 <Text dimColor wrap="truncate-end">
141 {where}
142 </Text>
143 {data.notice ? (
144 <Box key="notice">
145 <Text {...(data.notice.isError ? { color: 'red' } : { color: data.isTerminal ? 'green' : 'success' })}>{data.notice.text}</Text>
146 </Box>
147 ) : null}
148 {review.canPost ? eventRow(ui, review.event, actions) : null}
149 {review.general ? (
150 <Box key="general" flexDirection="column" marginTop={1}>
151 {sectionTitle(ui, 'General comment', null)}
152 {row(ui, data, actions, {
153 key: 'general',
154 selected: review.general.selected,
155 lead: '',
156 title: firstSentence(review.general.body) || '(empty)',
157 detail: '',
158 body: review.general.body,
159 isEditable: true,
160 })}
161 </Box>
162 ) : null}
163 {review.comments.length > 0 ? (
164 <Box key="comments" flexDirection="column" marginTop={1}>
165 {sectionTitle(ui, 'Comments', allNone(ui, 'comments', actions))}
166 {review.comments.map(comment =>
167 row(ui, data, actions, {
168 key: 'comment:' + comment.index,
169 selected: comment.selected,
170 lead: String(comment.ordinal ?? comment.index + 1),
171 title: comment.title || firstSentence(comment.body),
172 detail: [`${comment.path}${comment.line ? ':' + comment.line : ''}`, comment.severity, comment.lenses.join(', ')].filter(Boolean).join(' · '),
173 body: comment.body,
174 isEditable: true,
175 }),
176 )}
177 </Box>
178 ) : null}
179 {review.findings.length > 0 ? (
180 <Box key="findings" flexDirection="column" marginTop={1}>
181 {sectionTitle(ui, 'Findings', allNone(ui, 'findings', actions))}
182 {review.findings.map(finding =>
183 row(ui, data, actions, {
184 key: 'finding:' + finding.index,
185 selected: finding.selected,
186 lead: finding.severity ? finding.severity.slice(0, 1).toUpperCase() : '·',
187 title: finding.title || firstSentence(finding.body),
188 detail: [`${finding.file}${finding.line ? ':' + finding.line : ''}`, finding.severity, finding.lens].filter(Boolean).join(' · '),
189 body: finding.grounding ? `${finding.body}\n\n*Grounding:* ${finding.grounding}` : finding.body,
190 isEditable: false,
191 }),
192 )}
193 </Box>
194 ) : null}
195 {footer(ui, review, data, findingsSelected, actions)}
196 {review.comments.length === 0 && review.findings.length === 0 && !review.general ? (
197 <Text dimColor>This review has no comments or findings to act on.</Text>
198 ) : null}
199 {!review.canPost && review.comments.length > 0 ? (
200 <Text dimColor>This review can't be posted from here: its PR details are incomplete.</Text>
201 ) : null}
202 {data.busy ? <Text dimColor>{data.busy}…</Text> : null}
203 </Box>
204 )
205}
206
207function sectionTitle(ui: ElementTable, text: string, right: RenderElement | null): RenderElement {
208 const { Box, Text } = ui
209 return (
210 <Box justifyContent="space-between">
211 <Text weight="semibold" dimColor>
212 {text}
213 </Text>
214 {right}
215 </Box>
216 )
217}
218
219function allNone(ui: ElementTable, kind: 'comments' | 'findings', actions: ReviewActions): RenderElement {
220 const { Box, Text, Button } = ui
221 return (
222 <Box flexShrink={0}>
223 <Button key={'all:' + kind} plain dimColor label="all" onPress={() => actions.setAll(kind, true)} />
224 <Text dimColor> · </Text>
225 <Button key={'none:' + kind} plain dimColor label="none" onPress={() => actions.setAll(kind, false)} />
226 </Box>
227 )
228}
229
230function eventRow(ui: ElementTable, event: LoreReviewEvent, actions: ReviewActions): RenderElement {
231 const { Box, Button } = ui
232 return (
233 <Box key="event" marginTop={1} gap={1} flexWrap="wrap">
234 {(Object.keys(EVENT_WORDS) as LoreReviewEvent[]).map(choice => (
235 <Button
236 key={'event:' + choice}
237 variant={choice === event ? 'primary' : 'secondary'}
238 dimColor={choice !== event}
239 label={EVENT_WORDS[choice]}
240 onPress={() => actions.setEvent(choice)}
241 />
242 ))}
243 </Box>
244 )
245}
246
247type Row = {
248 key: string
249 selected: boolean
250 lead: string
251 title: string
252 detail: string
253 body: string
254 isEditable: boolean
255}
256
257// One comment, finding or the general body: a checkbox and a one-line
258// headline, opening into its location, its whole text and what can be done
259// to it.
260function row(ui: ElementTable, data: ReviewData, actions: ReviewActions, item: Row): RenderElement {
261 const { Box, Text, Button, Markdown } = ui
262 const isOpen = data.open.includes(item.key)
263 const isEditing = data.editing === item.key
264 const Input = data.fields?.Input
265 const lead = item.lead ? item.lead.padStart(2) + ' ' : ''
266 const room = Math.max(8, data.width - ROW_LEAD - lead.length)
267 return (
268 <Box key={item.key} flexDirection="column">
269 <Box>
270 <Button key={'select:' + item.key} plain label={item.selected ? '[x]' : '[ ]'} onPress={() => actions.toggleSelected(item.key)} />
271 <Text> </Text>
272 <Button key={'row:' + item.key} plain onPress={() => actions.toggleRow(item.key)}>
273 <Text dimColor>{isOpen ? '▾' : '▸'} </Text>
274 {lead ? <Text dimColor>{lead}</Text> : null}
275 <Text {...(item.selected ? {} : { dimColor: true })}>{cut(item.title, room)}</Text>
276 </Button>
277 </Box>
278 {isOpen ? (
279 <Box flexDirection="column" paddingLeft={4}>
280 {item.detail ? (
281 <Text dimColor wrap="truncate-middle">
282 {item.detail}
283 </Text>
284 ) : null}
285 {isEditing && Input ? (
286 <Box flexDirection="column">
287 <Input
288 key={'input:' + item.key}
289 value={toField(item.body)}
290 submitLabel="save"
291 autoFocus
292 onSubmit={value => actions.save(item.key, fromField(value))}
293 />
294 <Text dimColor>⏎ marks a line break · Enter saves</Text>
295 </Box>
296 ) : (
297 <Markdown text={cut(item.body, 9_800)} />
298 )}
299 <Box gap={2}>
300 {item.isEditable && Input ? (
301 <Button
302 key={'edit:' + item.key}
303 plain
304 dimColor
305 label={isEditing ? 'cancel' : 'edit'}
306 onPress={() => actions.edit(isEditing ? null : item.key)}
307 />
308 ) : null}
309 <Button key={'ask:' + item.key} plain dimColor label="ask Claude" onPress={() => actions.ask(item.key)} />
310 </Box>
311 </Box>
312 ) : null}
313 </Box>
314 )
315}
316
317function postLabel(event: LoreReviewEvent, comments: number, hasBody: boolean): string {
318 const what = comments > 0 ? plural(comments, 'comment') : hasBody ? 'the general comment' : ''
319 if (event === 'APPROVE') return what ? `Approve with ${what}` : 'Approve'
320 if (event === 'REQUEST_CHANGES') return `Request changes${what ? ' with ' + what : ''}`
321 return `Post ${what}`
322}
323
324function footer(ui: ElementTable, review: LoreReview, data: ReviewData, findingsSelected: number, actions: ReviewActions): RenderElement | null {
325 const { Box, Text, Button } = ui
326 if (data.busy) return null
327 const postable = postableOf(review)
328 if (data.confirm) {
329 const question =
330 data.confirm === 'post'
331 ? `${postLabel(review.event, postable.comments, postable.hasBody)} on #${review.pr ?? '?'}${review.repo ? ' in ' + review.repo : ''}?`
332 : data.confirm === 'promote'
333 ? `Promote ${plural(findingsSelected, 'finding')} to a work item?`
334 : 'Dismiss this review? It moves to the archive.'
335 const yes = data.confirm === 'post' ? 'Post' : data.confirm === 'promote' ? 'Promote' : 'Dismiss'
336 return (
337 <Box key="footer" flexDirection="column" marginTop={1}>
338 <Text>{question}</Text>
339 <Box gap={1}>
340 <Button key="confirm" variant="primary" label={yes} onPress={actions.confirm} />
341 <Button key="cancel" label="Cancel" onPress={() => actions.askConfirm(null)} />
342 </Box>
343 </Box>
344 )
345 }
346 const hasComments = review.comments.length > 0 || review.general !== null
347 return (
348 <Box key="footer" marginTop={1} gap={2} flexWrap="wrap">
349 {review.canPost && postable.isPostable ? (
350 <Button key="post" variant="primary" label={postLabel(review.event, postable.comments, postable.hasBody)} onPress={() => actions.askConfirm('post')} />
351 ) : review.canPost && hasComments ? (
352 <Text dimColor>Select a comment to post</Text>
353 ) : null}
354 {findingsSelected > 0 ? (
355 <Button key="promote" label={`Promote ${plural(findingsSelected, 'finding')}`} onPress={() => actions.askConfirm('promote')} />
356 ) : null}
357 {review.canPost ? <Button key="preview" plain dimColor label="preview" onPress={actions.preview} /> : null}
358 {review.report ? <Button key="report" plain dimColor label="report" onPress={actions.report} /> : null}
359 <Button key="dismiss" plain dimColor label="dismiss" onPress={() => actions.askConfirm('dismiss')} />
360 </Box>
361 )
362}
363hooks/review-parse.ts 181 lines1import type {
2 LoreReview,
3 LoreReviewComment,
4 LoreReviewEntry,
5 LoreReviewEvent,
6 LoreReviewFinding,
7 LoreReviewList,
8} from '../types'
9import type { Ran } from './parse'
10
11const EVENTS: readonly LoreReviewEvent[] = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES']
12
13function parse(text: string): unknown {
14 try {
15 return JSON.parse(text)
16 } catch {
17 return undefined
18 }
19}
20
21function record(value: unknown): Record<string, unknown> {
22 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
23}
24
25function list(value: unknown): unknown[] {
26 return Array.isArray(value) ? value : []
27}
28
29function text(value: unknown): string {
30 return typeof value === 'string' ? value : ''
31}
32
33function count(value: unknown): number {
34 if (typeof value === 'number' && Number.isFinite(value)) return value
35 if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value)
36 return 0
37}
38
39function prRef(attachments: unknown): string {
40 const pr = list(attachments).map(record).find(attachment => attachment.type === 'pr')
41 return pr ? text(pr.ref) : ''
42}
43
44// A follow-up is a review when /pr-review made it or it is attached to a PR.
45function isReview(entry: Record<string, unknown>): boolean {
46 return entry.source === 'pr-review' || prRef(entry.attachments) !== ''
47}
48
49export function reviewsOf(ran: Ran): LoreReviewList {
50 const listed = ran?.exitCode === 0 ? parse(ran.stdout) : undefined
51 if (!Array.isArray(listed)) return { entries: [], error: "Can't list this project's reviews." }
52 const entries: LoreReviewEntry[] = listed
53 .map(record)
54 .filter(entry => typeof entry.id === 'string' && isReview(entry))
55 .map(entry => ({ id: text(entry.id), title: text(entry.title), pr: prRef(entry.attachments), updated: text(entry.updated) }))
56 .sort((a, b) => b.updated.localeCompare(a.updated))
57 return { entries, error: null }
58}
59
60// /pr-review titles its follow-ups `Review: <PR title> (#N)`; the panel shows
61// the PR number on its own line, so the title drops both.
62export function shortTitle(title: string): string {
63 return title.replace(/^Review:\s*/i, '').replace(/\s*\(#\d+\)\s*$/, '') || title
64}
65
66function commentsOf(raw: unknown): LoreReviewComment[] {
67 const comments = Array.isArray(raw) ? raw : list(record(raw).comments)
68 return comments.map(record).map((comment, index) => ({
69 index,
70 path: text(comment.path),
71 line: count(comment.line),
72 title: text(comment.title),
73 body: text(comment.body),
74 selected: comment.selected === true,
75 severity: text(comment.severity),
76 lenses: list(comment.lenses).filter((lens): lens is string => typeof lens === 'string'),
77 ordinal: typeof comment.finding_ordinal === 'number' ? comment.finding_ordinal : null,
78 }))
79}
80
81function findingsOf(raw: unknown): LoreReviewFinding[] {
82 return list(record(raw).findings)
83 .map(record)
84 .map((finding, index) => ({
85 index,
86 severity: text(finding.severity),
87 title: text(finding.title),
88 file: text(finding.file),
89 line: count(finding.line),
90 body: text(finding.body),
91 lens: text(finding.lens),
92 grounding: text(finding.grounding),
93 selected: finding.selected === true,
94 }))
95}
96
97// The review as `lore followup view --json` reports it.
98export function reviewOf(id: string, ran: Ran): LoreReview {
99 const empty: LoreReview = {
100 id,
101 title: id,
102 pr: null,
103 repo: '',
104 event: 'COMMENT',
105 general: null,
106 comments: [],
107 findings: [],
108 report: null,
109 canPost: false,
110 error: "Can't read this review.",
111 }
112 if (ran?.isStdoutTruncated) return { ...empty, error: 'This review is too large to show here.' }
113 const shown = ran?.exitCode === 0 ? parse(ran.stdout) : undefined
114 if (shown === undefined) return empty
115 const item = record(shown)
116 const raw = item.proposed_comments
117 const proposed = record(raw)
118 const isWrapped = typeof raw === 'object' && raw !== null && !Array.isArray(raw)
119 const pr = count(proposed.pr) || count(prRef(item.attachments).replace(/^#/, '')) || null
120 const owner = text(proposed.owner)
121 const repo = text(proposed.repo)
122 const event = EVENTS.find(known => known === proposed.review_event) ?? 'COMMENT'
123 return {
124 id,
125 title: text(item.title) || id,
126 pr,
127 repo: owner && repo ? `${owner}/${repo}` : '',
128 event,
129 general: isWrapped && typeof proposed.review_body === 'string' ? { body: proposed.review_body, selected: proposed.review_body_selected === true } : null,
130 comments: commentsOf(raw),
131 findings: findingsOf(item.lens_findings),
132 report: typeof item.finding_content === 'string' ? item.finding_content : null,
133 canPost: isWrapped && count(proposed.pr) > 0 && owner !== '' && repo !== '' && text(proposed.head_sha) !== '',
134 error: null,
135 }
136}
137
138export type Postable = { comments: number; hasBody: boolean; isPostable: boolean }
139
140// What a post would send, by the rule post-proposed-review.sh holds: selected
141// comments, the general body when it is selected and not blank, and an
142// approval may go alone.
143export function postableOf(review: LoreReview): Postable {
144 const comments = review.comments.filter(comment => comment.selected).length
145 const hasBody = review.general !== null && review.general.selected && review.general.body.trim() !== ''
146 return { comments, hasBody, isPostable: review.canPost && (comments > 0 || hasBody || review.event === 'APPROVE') }
147}
148
149// An Input is one line on every surface, so a body's line breaks travel as a
150// visible mark while it is edited and come back on save.
151const BREAK = '⏎'
152
153export function toField(body: string): string {
154 return body.replace(/\n/g, ` ${BREAK} `)
155}
156
157export function fromField(value: string): string {
158 return value.replace(new RegExp(` ?${BREAK} ?`, 'g'), '\n')
159}
160
161// A review a Bash call touched: one /pr-review just made (create-followup's
162// own line) or a followup verb naming it.
163const CREATED = /Created follow-up '[^']*' at \S*\/_followups\/([a-z0-9][a-z0-9-]*)/g
164const TOUCHED = /lore followup (?:edit-review|post-review|view(?: --json)?) ([a-z0-9][a-z0-9-]*)/g
165
166function ids(text: string, pattern: RegExp): string[] {
167 const found: string[] = []
168 for (const match of text.matchAll(pattern)) {
169 if (match[1]) found.push(match[1])
170 }
171 return found
172}
173
174export function createdReviews(result: string): string[] {
175 return ids(result, CREATED)
176}
177
178export function touchedReviews(command: string): string[] {
179 return ids(command, TOUCHED)
180}
181types/index.d.ts 101 lines1export type LoreStream = {
2 id: string
3 step: string
4 status: string
5 verdict: string
6 workItem: string | null
7}
8export type LoreArc = { slug: string; streams: LoreStream[] }
9export type LoreBoard = { arcs: LoreArc[]; generation: number; error: string | null }
10export type LoreTitle = { slug: string; title: string }
11export type LoreRailLine = { text: string; wordsAt: number }
12export type LoreRailRow = { id: string; lines: LoreRailLine[] }
13export type LoreRails = {
14 slug: string
15 generation: number
16 width: number
17 rows: LoreRailRow[]
18 isOld: boolean
19}
20export type LoreBriefField = { label: string; text: string }
21export type LoreDecision = { text: string; heading: string }
22export type LoreFile = { path: string; size: number }
23export type LoreDetail = {
24 slug: string
25 generation: number
26 path: string
27 brief: LoreBriefField[]
28 decisions: LoreDecision[]
29 files: LoreFile[]
30 error: string | null
31}
32export type LoreBlock = { kind: 'markdown' | 'code'; text: string }
33export type LorePopout = { title: string; source: string; blocks: LoreBlock[] }
34export type LoreReviewEntry = { id: string; title: string; pr: string; updated: string }
35export type LoreReviewList = { entries: LoreReviewEntry[]; error: string | null }
36export type LoreReviewComment = {
37 index: number
38 path: string
39 line: number
40 title: string
41 body: string
42 selected: boolean
43 severity: string
44 lenses: string[]
45 ordinal: number | null
46}
47export type LoreReviewFinding = {
48 index: number
49 severity: string
50 title: string
51 file: string
52 line: number
53 body: string
54 lens: string
55 grounding: string
56 selected: boolean
57}
58export type LoreReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'
59export type LoreReview = {
60 id: string
61 title: string
62 pr: number | null
63 repo: string
64 event: LoreReviewEvent
65 general: { body: string; selected: boolean } | null
66 comments: LoreReviewComment[]
67 findings: LoreReviewFinding[]
68 report: string | null
69 canPost: boolean
70 error: string | null
71}
72export type LoreReviewConfirm = 'post' | 'dismiss' | 'promote' | null
73export type LoreNotice = { text: string; isError: boolean } | null
74
75declare module 'claude-code' {
76 interface PluginState {
77 lore: {
78 store: string | null
79 board: LoreBoard | null
80 titles: LoreTitle[]
81 candidates: string[]
82 pinned: string | null
83 isOpen: boolean
84 rails: LoreRails | null
85 detail: LoreDetail | null
86 openEntries: string[]
87 popout: LorePopout | null
88 isReviewsOpen: boolean
89 reviews: LoreReviewList | null
90 review: LoreReview | null
91 reviewChosen: string | null
92 reviewOpen: string[]
93 reviewEditing: string | null
94 reviewConfirm: LoreReviewConfirm
95 reviewBusy: string | null
96 reviewNotice: LoreNotice
97 reviewPopout: LorePopout | null
98 }
99 }
100}
101