SLOPSHOPPER

lore

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…

panebandguardcommandtoastprocess
★ 14v0.1.0MITupdated 2026-10-23anticorrelator/lore/adapters/claude-code-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lore
│ ┃ lore-arcs ✕ › fix the failing auth test and add an audit log call │ ┃ Arc × │ ┃ Reading the board… ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lore-arcs
Arc × Reading the board…
Pane · lore-reviews
Reviews × Reading this project's reviews…
README

lore

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.

Install

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.

Use

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:

/coordinatedrive a feature end-to-end across protocol sessions
/spec → /implementplan a work item, then execute the plan with worker agents
/workcreate, resume, and check status of work items
/remembercapture insights from the current conversation
/pr-reviewmulti-lens PR review (--self for your own branch)

Ceremonies — periodic maintenance:

/bootstrapseed a new project's store by exploring the codebase
/retroscore how well the system supported a finished work cycle
/evolvereview and apply protocol suggestions accumulated by retro
/renormalizeprune 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.

Data

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.

Source 6 files
hooks/register.tsx 731 lines
1import { 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}
731
hooks/draw.tsx 424 lines
1import 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}
424
hooks/parse.ts 253 lines
1import 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}
253
hooks/review-draw.tsx 363 lines
1import 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}
363
hooks/review-parse.ts 181 lines
1import 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}
181
types/index.d.ts 101 lines
1export 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