3ngram for Claude Code: a read-only commitment panel over your 3ngram memory.

A read-only commitment panel inside Claude Code, plus the 3ngram session hooks. It lists the current project's open, waiting and overdue commitments from your 3ngram memory, says where each came from, and shows related evidence that one may be done. It never changes a record and never adds a backlog.
Needs Claude Code 2.1.287 or later in the terminal (2.1.286 or later in the Desktop app's Code tab), where mods are on by default; no feature flag is needed, and the early-access CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored from 2.1.287. Tested with 2.1.291 and 2.1.292. The plugin API (mods) is early access and can change between releases.
The panel reads through the 3ngram-hook binary, so build it first (see cmd/3ngram-hook); it uses the same API key and the same project as the session hooks.
/plugin install 3ngram --marketplace B3dmar/3ngram
Answer y to add the marketplace, then pick a scope. For one session from a checkout instead:
claude --plugin-dir plugins/3ngram
Run /commitments to open the panel. Change the options with /plugin configure 3ngram or in /config:
| Option | Default | What it does |
|---|---|---|
scope | empty | Read within this 3ngram scope. Empty reads the project in any scope |
include_unscoped | off | Also show the scope's commitments written without a project. Needs a scope |
refresh_minutes | 5 | Background refresh interval, at least 1 |
github_evidence | on | Look up the GitHub issues and pull requests commitments mention, read-only |
auto_open | off | Open the panel at session start, on a terminal wide enough to dock it |
The plugin also registers the four 3ngram session hooks (SessionStart briefing, PreToolUse precheck, Stop heartbeat, SessionEnd close) through scripts/run-hook. If you already registered them in settings.json, the plugin's copies stand down for each event instance the guard can confirm your settings cover. This is best effort, not exact: where it cannot tell, the plugin copy runs too, and a few setups it cannot see are listed in the hook README. Registering the hooks in one place, settings or the plugin, avoids both. A machine without 3ngram-hook, or with a build older than the guard, gets no hooks from the plugin and no error. The plugin supports macOS and Linux; Windows is not supported in this version (there is no Windows build of 3ngram-hook).
3ngram-hook commitments context, which makes no request, so rows read under another key or backend are cleared before the new read starts. A list or detail read whose content would be shown is checked once more after it returns, so a key changed while it ran never shows that read. An open detail also closes when a refresh shows its row changed: its filing, its commitment status, or the state of a GitHub reference it names. After a failure, rows stay (marked stale) only when the failed attempt is verified to be the same context; anything else clears the list and the detail view. An open detail hides whenever the rows do, and does not come back by itself./clear, /resume, /branch and a reload stop the list and detail reads in flight (the fresh read after /clear, /resume or /branch starts once the session state has been reset), and so does a refresh that clears the open detail; every read has a 12 s ceiling; a refresh asked for during a read runs once it ends. One known gap: a Cancel, /clear, reload or detach that lands while a read is recording a context change can let one more read run (#269).claude -p run or an Agent SDK session reads nothing in the background, so it makes no REST reads and runs no gh. The background reads start if a client attaches to that session later, and stop again when the last one detaches; a startup read whose timer has already fired can still run once after that (#269).3ngram-hook commitments context|list|show, through an allowlist checked before every spawn. It never calls the network, an MCP server, the file system or the store, and it reads no environment variable: claude plugin validate plugins/3ngram lists every call it makes.gh only as gh api --method GET./commitments reply and the status line carry no record content: the model and the status bar see counts and a fixed line, never a topic.pnpm --filter @3ngram/claude-code-plugin test # pure logic, node --test (runs in CI)
pnpm --filter @3ngram/claude-code-plugin check # tsc over the pure logic and specs
claude plugin validate plugins/3ngram # manifest, module, calls
claude plugin test plugins/3ngram # engine-kit tests (local only)
The engine-kit tests need the Claude Code CLI, which is not in the lockfile, so CI runs the specs under spec/ and the engine tests are run locally before each change. The specs parse the binary's golden envelopes in cmd/3ngram-hook/testdata/commitments/, so a contract change fails on both sides.
hooks/register.tsx 716 lines1// SPDX-License-Identifier: Apache-2.0
2
3import type { EngineInterface, PluginOptions, Register } from 'claude-code'
4import { atom, read, update } from 'claude-code'
5import type { Selection } from './lib/argv.ts'
6import { contextArgv, isAllowedArgv, listArgv, selectionKey, showArgv } from './lib/argv.ts'
7import { parseEnvelope } from './lib/contract.ts'
8import { classifyEmptyOutput, classifySpawnError } from './lib/process.ts'
9import type { FailureKind, PanelEvent, PanelState } from './lib/state.ts'
10import { initialState, needsVerification, reduce, unconfirmed, visibleDetail } from './lib/state.ts'
11import { detailView, panelView, statusLine } from './lib/view.ts'
12
13// The 3ngram commitment panel: a read-only pane over `3ngram-hook
14// commitments`. Everything it shows comes from that binary's JSON envelopes;
15// the module itself never reads the API key, never calls the network or an
16// MCP server, and never writes a file or the store. All of that is pinned by
17// tests/panel.test.ts and by `claude plugin validate` (it reads no env).
18
19const PANE = '3ngram-commitments'
20const TITLE = '3ngram commitments'
21const PROCESS_CEILING_MS = 12_000
22const PROBE_CEILING_MS = 3_000
23const MIN_REFRESH_MS = 60_000
24const MAX_STDOUT = 8 * 1024 * 1024
25const MAX_LABEL = 90
26
27const panel = atom({ plugin: '3ngram', key: 'panel' } as const, initialState)
28
29// The contract (types/index.d.ts) names the value opaquely; this module is its
30// only writer, and every write goes through reduce(), so reading it back as
31// PanelState is sound.
32async function readPanel($: EngineInterface): Promise<PanelState> {
33 return (await read($, panel)) as unknown as PanelState
34}
35
36// Module state, on purpose: a hot reload drops it together with the children
37// and timers, which the engine ends on unload.
38//
39// active is the token of the refresh holding the single-flight guard, taken
40// synchronously before its first await so two triggers in one tick cannot
41// both start one. A refresh asked for while one runs is not dropped: queued
42// makes it run once the current one ends. listRead and detailRead are the
43// children in flight, so Cancel, a session transition and a reload can stop
44// them; a stopped read's results are ignored by generation or sequence.
45let active: number | null = null
46let tokens = 0
47let queued = false
48let listRead: { token: number; stop: () => void } | null = null
49let detailRead: { seq: number; stop: () => void } | null = null
50// The refresh interval and the startup kick of the last session.start, ended
51// before a later session.start arms new ones. One-shot kicks from commands
52// and transitions fire at once and are not kept.
53let tick: { cancel: () => void } | null = null
54let kick: { cancel: () => void } | null = null
55// stops counts stopReads calls, so a read that had not started its child
56// when it was stopped still sees that it was, and never starts one.
57let stops = 0
58// wantedDetail is the sequence of the detail last asked for: a detail read
59// that is no longer it stops its child instead of registering it.
60let wantedDetail = 0
61// resetPending is set by a session.end that announces a /clear or a /resume,
62// so the classic.SessionStart that follows the state reset reads again, and
63// a resume at launch (no session.end before it) does not read twice.
64let resetPending = false
65// requeuedFor is the fingerprint a read was last re-run for after its context
66// changed mid-read, so a probe and a list that keep disagreeing about one
67// context re-run it once, not forever.
68let requeuedFor: string | null = null
69// selectionConfirmed gates what the pane draws. The host keeps $.state across
70// a reload, and a reload is how a changed scope, include_unscoped or GitHub
71// option takes effect, so rows and a detail read under the old options are
72// still held while startup awaits the session's directory. A record held
73// under the same selection can still be another account's or backend's (the
74// key rotated between sessions). The gate is closed from the moment the
75// module loads (this line), and closed again, before any await, at every
76// session.start; it opens only once the held state has been compared with
77// the current selection and its record, if any, with the current context:
78// cleared when either differs, or confirmed by the context probe
79// (confirmSelection). A failure that settles the held state the same way
80// opens it too, so the pane shows the failure instead of staying blank.
81let selectionConfirmed = false
82// startEpoch counts session.starts. Every startup and refresh takes the
83// epoch it began in, so one begun before the latest session.start can never
84// open the gate that start closed.
85let startEpoch = 0
86
87// Opening the gate redraws the pane and republishes the status line from the
88// state now held: a dispatch made while the gate was closed (a failure, a
89// check or refresh start) published the unconfirmed status, which would
90// otherwise stay until the next dispatch.
91async function confirmSelection($: EngineInterface, epoch: number): Promise<void> {
92 if (selectionConfirmed || epoch !== startEpoch) return
93 selectionConfirmed = true
94 $.ui.invalidate('ui.render')
95 const held = await readPanel($)
96 // A later session.start may have closed the gate during the read.
97 if (selectionConfirmed && epoch === startEpoch) $.ui.status(statusLine(held))
98}
99
100// releaseDetailRead forgets the handle of a detail read that has ended, if it
101// is still the one held.
102function releaseDetailRead(seq: number): void {
103 if (detailRead?.seq === seq) detailRead = null
104}
105
106function stopReads(): void {
107 stops++
108 // A stop is final: a refresh queued behind the stopped one is dropped too.
109 queued = false
110 listRead?.stop()
111 detailRead?.stop()
112 listRead = null
113 detailRead = null
114 active = null
115}
116
117type RunOutcome = { kind: 'output'; stdout: string } | { kind: 'failure'; failure: FailureKind }
118
119function selectionOf(options: PluginOptions, cwd: string): Selection {
120 const scope = typeof options.scope === 'string' ? options.scope.trim() : ''
121 return {
122 cwd,
123 scope,
124 includeUnscoped: options.include_unscoped === true,
125 github: options.github_evidence === true,
126 }
127}
128
129// armRefresh starts the background reads: the startup read now, then one per
130// interval. It runs once a session can draw the pane, never twice.
131function armRefresh($: EngineInterface, options: PluginOptions): void {
132 if (tick !== null) return
133 kick = $.clock.after(0, () => fire($, startup($, options)))
134 tick = $.clock.every(refreshMs(options), () => fire($, refresh($, options)))
135}
136
137// disarmRefresh stops the background reads and anything they started.
138function disarmRefresh(): void {
139 tick?.cancel()
140 kick?.cancel()
141 tick = null
142 kick = null
143 stopReads()
144}
145
146function refreshMs(options: PluginOptions): number {
147 const minutes = typeof options.refresh_minutes === 'number' ? options.refresh_minutes : 5
148 return Math.max(MIN_REFRESH_MS, minutes * 60_000)
149}
150
151async function dispatch($: EngineInterface, event: PanelEvent): Promise<PanelState> {
152 return (await dispatchApplied($, event)).state
153}
154
155// dispatchApplied is dispatch that also answers whether the reducer took the
156// event: one it ignores (an older generation, a verification another one
157// already settled) comes back as the very state it was given.
158async function dispatchApplied(
159 $: EngineInterface,
160 event: PanelEvent,
161): Promise<{ state: PanelState; applied: boolean }> {
162 let next = initialState
163 let applied = false
164 await update($, panel, (s) => {
165 const prev = (s as unknown as PanelState | undefined) ?? initialState
166 next = reduce(prev, event)
167 applied = next !== prev
168 return next
169 })
170 // A detail the panel no longer holds (a refresh found another context, its
171 // row moved or left the list) has no use for its read. Its child is stopped
172 // now rather than reading on under the old context, and a read still on its
173 // way to starting one sees it is no longer wanted.
174 if (next.detail?.seq !== wantedDetail) wantedDetail = 0
175 if (detailRead && detailRead.seq !== next.detail?.seq) {
176 detailRead.stop()
177 detailRead = null
178 }
179 // The status line is gated like the pane: no counts of an unconfirmed
180 // selection.
181 $.ui.status(statusLine(selectionConfirmed ? next : unconfirmed(next)))
182 return { state: next, applied }
183}
184
185// fire runs work a handler or timer does not wait for, so a failure in it is
186// still seen: in the panel's state where it can be, in the debug log always.
187function fire($: EngineInterface, work: Promise<unknown>): void {
188 work.catch(() => {
189 $.ui.log('3ngram: a panel action failed', { to: 'debug' })
190 })
191}
192
193// runHook spawns one allow-listed command and collects its stdout under a
194// hard ceiling. onStart receives the function that ends the child early.
195async function runHook(
196 $: EngineInterface,
197 argv: string[],
198 ceilingMs: number,
199 onStart?: (stop: () => void) => void,
200): Promise<RunOutcome> {
201 if (!isAllowedArgv(argv)) return { kind: 'failure', failure: 'contract' }
202 let stdout = ''
203 let stderr = ''
204 let stopped: 'timeout' | 'cancelled' | 'crash' | null = null
205 const stream = $.process.spawn({ argv })
206 const stop = (why: 'timeout' | 'cancelled' | 'crash') => {
207 stopped ??= why
208 // Returning the stream ends the child (SIGTERM).
209 stream.return(undefined as never).catch(() => {
210 $.ui.log('3ngram: stopping a child failed', { to: 'debug' })
211 })
212 }
213 const timer = $.clock.after(ceilingMs, () => stop('timeout'))
214 onStart?.(() => stop('cancelled'))
215 try {
216 for await (const chunk of stream) {
217 if (chunk.stream === 'stdout') stdout += chunk.text
218 else stderr = (stderr + chunk.text).slice(-4096)
219 if (stdout.length > MAX_STDOUT) stop('crash')
220 }
221 } catch (err) {
222 if (stopped) return { kind: 'failure', failure: stopped }
223 const message = err instanceof Error ? err.message : String(err)
224 return { kind: 'failure', failure: classifySpawnError(message) }
225 } finally {
226 timer.cancel()
227 }
228 if (stopped) return { kind: 'failure', failure: stopped }
229 if (stdout.trim() === '') return { kind: 'failure', failure: classifyEmptyOutput(stderr) }
230 return { kind: 'output', stdout }
231}
232
233// probe runs `commitments context`, which makes no network call, and answers
234// the fingerprint of the context a read would use now, or null.
235async function probe($: EngineInterface, sel: Selection): Promise<string | null> {
236 const outcome = await runHook($, contextArgv(sel), PROBE_CEILING_MS)
237 if (outcome.kind !== 'output') return null
238 const parsed = parseEnvelope(outcome.stdout)
239 return parsed.ok && parsed.envelope.ok ? parsed.envelope.context.fingerprint : null
240}
241
242// verifyIfNeeded settles a pending verification: the probe's fingerprint, or
243// null when the probe, or even reading the session's directory, failed. It
244// never leaves the panel waiting on a verification nothing will finish, and
245// answers whether its own probe settled one: the record is then kept, as
246// stale, only under the context that probe confirmed. Two calls can find the
247// same verification pending (a reload landing while an earlier startup's
248// probe runs). The first answer settles it, possibly for the context from
249// before the reload; the reducer ignores the second, which answers false.
250async function verifyIfNeeded($: EngineInterface, options: PluginOptions): Promise<boolean> {
251 const s = await readPanel($)
252 if (!needsVerification(s)) return false
253 let fingerprint: string | null = null
254 try {
255 fingerprint = await probe($, selectionOf(options, await $.session.cwd()))
256 } catch {
257 fingerprint = null
258 }
259 const at = await $.clock.now()
260 const verified = await dispatchApplied($, {
261 type: 'context_verified',
262 gen: s.gen,
263 fingerprint,
264 at,
265 })
266 return verified.applied
267}
268
269async function refresh($: EngineInterface, options: PluginOptions): Promise<void> {
270 if (active !== null) {
271 queued = true
272 return
273 }
274 const token = ++tokens
275 active = token
276 const epoch = startEpoch
277 try {
278 await refreshOnce($, options, token, epoch)
279 } catch {
280 // A refresh that was already superseded reports nothing: the current
281 // generation belongs to the read that replaced it. A reported failure
282 // has cleared or verified what was held, so the pane shows it.
283 if (active === token && (await reportUnexpected($, options))) await confirmSelection($, epoch)
284 } finally {
285 if (active === token) active = null
286 }
287 if (queued && active === null) {
288 queued = false
289 await refresh($, options)
290 }
291}
292
293// refreshOnce is one read. It stops at every await where its token may have
294// been taken away (a cancel, a session transition, a reload), so a read that
295// was stopped before its child started never starts one. epoch is the
296// session.start it began under: a reload closes the gate before it takes the
297// token away, and a read still running in between must not reopen it.
298async function refreshOnce(
299 $: EngineInterface,
300 options: PluginOptions,
301 token: number,
302 epoch: number,
303): Promise<void> {
304 const sel = selectionOf(options, await $.session.cwd())
305 // Stopped while the directory was read (a /clear, a fork, a reload): the
306 // state may already be a later refresh's, so nothing is read or written.
307 if (active !== token) return
308 // With rows held, the context is checked before the read: rows read under
309 // another key or backend are cleared now, not after the read returns.
310 const held = (await readPanel($)).record !== null
311 if (active !== token) return
312 if (held) await dispatch($, { type: 'check_started' })
313 if (active !== token) return
314 const fingerprint = held ? await probe($, sel) : null
315 if (active !== token) return
316 const gen = (await readPanel($)).gen + 1
317 if (active !== token) return
318 await dispatch($, { type: 'refresh_started', gen, selectionKey: selectionKey(sel), fingerprint })
319 // refresh_started clears whatever was held under another selection, or
320 // under a context the probe did not confirm.
321 await confirmSelection($, epoch)
322 if (active !== token) return
323 const outcome = await runHook($, listArgv(sel), PROCESS_CEILING_MS, (stop) => {
324 if (active === token) listRead = { token, stop }
325 else stop()
326 })
327 if (listRead?.token === token) listRead = null
328 if (outcome.kind === 'failure' && outcome.failure === 'cancelled') return
329 const at = await $.clock.now()
330 if (outcome.kind === 'output') {
331 const parsed = parseEnvelope(outcome.stdout)
332 // The key or backend can change while the list runs, and the envelope
333 // then speaks for the context it started under. One whose content would
334 // be shown (its rows, or the stale rows a same-context error keeps) is
335 // checked once more first: on a change nothing of it is, the rows go, and
336 // the read runs again. An error for another context clears them anyway.
337 const record = (await readPanel($)).record
338 const shown =
339 parsed.ok &&
340 (parsed.envelope.ok ||
341 (record !== null &&
342 record.envelope.context.fingerprint === parsed.envelope.context.fingerprint))
343 if (parsed.ok && shown) {
344 const now = await probe($, sel)
345 if (active !== token) return
346 if (now !== parsed.envelope.context.fingerprint) {
347 const rerun = now !== null && now !== requeuedFor
348 // The message says a read follows only when one does.
349 await dispatch($, {
350 type: 'list_failed',
351 gen,
352 failure: rerun ? 'context_changed' : 'context_moved',
353 at,
354 })
355 await dispatch($, { type: 'context_verified', gen, fingerprint: now, at })
356 if (rerun) {
357 requeuedFor = now
358 queued = true
359 }
360 return
361 }
362 }
363 await dispatch(
364 $,
365 parsed.ok
366 ? { type: 'list_envelope', gen, envelope: parsed.envelope, at }
367 : { type: 'list_failed', gen, failure: parsed.reason, at },
368 )
369 } else {
370 await dispatch($, { type: 'list_failed', gen, failure: outcome.failure, at })
371 }
372 // Any read that ended without its context moving re-arms the one re-run.
373 requeuedFor = null
374 await verifyIfNeeded($, options)
375}
376
377// A refresh that failed outside its own handling (the session's directory
378// could not be read, a state write was refused) is still a visible failure,
379// never a silent one: it fails the current generation like a crash, and any
380// verification that leaves pending is settled at once.
381async function reportUnexpected($: EngineInterface, options: PluginOptions): Promise<boolean> {
382 try {
383 const s = await readPanel($)
384 await dispatch($, {
385 type: 'list_failed',
386 gen: s.gen,
387 failure: 'crash',
388 at: await $.clock.now(),
389 })
390 await verifyIfNeeded($, options)
391 return true
392 } catch {
393 $.ui.status('3ngram: unavailable')
394 return false
395 }
396}
397
398async function cancel($: EngineInterface, options: PluginOptions): Promise<void> {
399 stopReads()
400 try {
401 await dispatch($, { type: 'cancel' })
402 await verifyIfNeeded($, options)
403 } catch {
404 await reportUnexpected($, options)
405 }
406}
407
408async function openDetail(
409 $: EngineInterface,
410 options: PluginOptions,
411 memoryId: string,
412): Promise<void> {
413 const s = await dispatch($, { type: 'detail_requested', memoryId })
414 const detail = s.detail
415 if (!detail || detail.memoryId !== memoryId) return
416 const seq = detail.seq
417 const epoch = stops
418 wantedDetail = seq
419 try {
420 // An earlier detail's child is stopped now; one that has not started yet
421 // sees it is no longer wanted and never starts.
422 detailRead?.stop()
423 detailRead = null
424 const sel = selectionOf(options, await $.session.cwd())
425 const outcome = await runHook(
426 $,
427 showArgv(sel, memoryId, detail.fingerprint),
428 PROCESS_CEILING_MS,
429 (stop) => {
430 if (stops === epoch && wantedDetail === seq) {
431 detailRead?.stop()
432 detailRead = { seq, stop }
433 } else {
434 stop()
435 }
436 },
437 )
438 releaseDetailRead(seq)
439 // Stopped (Back, Cancel, a transition): close this detail quietly rather
440 // than showing the stop as an error.
441 if (outcome.kind === 'failure' && outcome.failure === 'cancelled') {
442 await dispatch($, { type: 'detail_closed', seq })
443 return
444 }
445 if (outcome.kind === 'failure') {
446 await dispatch($, { type: 'detail_failed', seq, failure: outcome.failure })
447 return
448 }
449 const parsed = parseEnvelope(outcome.stdout)
450 if (!parsed.ok) {
451 await dispatch($, { type: 'detail_failed', seq, failure: parsed.reason })
452 return
453 }
454 // As for a list read: the key or backend can change while show runs, and
455 // the answer then speaks for the context it started under. Checked once
456 // more before any of it is shown; on a change the detail fails and the
457 // list is read again, which clears the rows under the new context.
458 if (parsed.envelope.ok) {
459 const now = await probe($, sel)
460 if (stops !== epoch || wantedDetail !== seq) {
461 await dispatch($, { type: 'detail_closed', seq })
462 return
463 }
464 if (now !== parsed.envelope.context.fingerprint) {
465 await dispatch($, { type: 'detail_failed', seq, failure: 'context_changed' })
466 await refresh($, options)
467 return
468 }
469 }
470 const after = await dispatch($, { type: 'detail_envelope', seq, envelope: parsed.envelope })
471 // The binary refused because the context moved on: read the list again.
472 if (after.detail?.error?.kind === 'context_changed') await refresh($, options)
473 } catch {
474 await dispatch($, { type: 'detail_failed', seq, failure: 'crash' })
475 }
476}
477
478async function closeDetail($: EngineInterface): Promise<void> {
479 // A detail read that has not started its child yet sees the epoch move and
480 // never starts one.
481 stops++
482 detailRead?.stop()
483 detailRead = null
484 await dispatch($, { type: 'detail_closed' })
485}
486
487// startup is the first read after a session start or a reload. A reload
488// that changed the options (a scope, the unscoped opt-in, GitHub lookups)
489// leaves rows and a detail read under the old ones in the state the host
490// kept, and one under the same options can keep rows read under another key.
491// They never draw (selectionConfirmed is closed until they are confirmed)
492// and they are cleared before any read, not when the next read lands.
493async function startup($: EngineInterface, options: PluginOptions): Promise<void> {
494 const epoch = startEpoch
495 let settled = false
496 try {
497 settled = await settleHeld($, options)
498 } catch {
499 // Failed and cleared, or verified by the probe; if even that could not
500 // be written, the gate stays closed and the status line says so.
501 settled = await reportUnexpected($, options)
502 }
503 if (settled) await confirmSelection($, epoch)
504 // A record that is not settled yet is the refresh's to confirm: its probe
505 // runs before the read, and refresh_started (or a reported failure) opens
506 // the gate.
507 await refresh($, options)
508}
509
510// settleHeld compares the state the host kept with the current selection and
511// answers whether it now belongs to it: true when a different selection was
512// cleared, when no record is held, or when this startup's own probe settled
513// a pending verification (the record kept as stale under the context that
514// probe confirmed, cleared otherwise). A record held under the same
515// selection with nothing pending, or one another verification settled
516// first, is not settled here: its context is checked by the refresh that
517// follows, so startup makes no read of its own.
518async function settleHeld($: EngineInterface, options: PluginOptions): Promise<boolean> {
519 const key = selectionKey(selectionOf(options, await $.session.cwd()))
520 const held = await readPanel($)
521 if (held.selectionKey !== null && held.selectionKey !== key) {
522 await dispatch($, { type: 'session_transition' })
523 return true
524 }
525 if (held.record === null) return true
526 return verifyIfNeeded($, options)
527}
528
529function clip(text: string, max: number): string {
530 const chars = [...text]
531 return chars.length > max ? `${chars.slice(0, max).join('')}…` : text
532}
533
534export const register: Register = (on, options) => {
535 on('session.start', async ($, e, next) => {
536 // First, before any await: nothing the host kept draws until the current
537 // selection is confirmed (see selectionConfirmed).
538 selectionConfirmed = false
539 startEpoch++
540 $.ui.invalidate('ui.render')
541 $.ui.status(statusLine(unconfirmed(initialState)))
542 await $.command.register({
543 name: 'commitments',
544 description: 'Open the read-only 3ngram commitment panel',
545 })
546 // session.start fires again after a reload. Whatever this environment
547 // still holds from an earlier start (its timers, a child) is ended first,
548 // so timers never pile up; a read the reload interrupted counts as
549 // cancelled and its context is checked again.
550 tick?.cancel()
551 kick?.cancel()
552 tick = null
553 kick = null
554 stopReads()
555 const s = await readPanel($)
556 if (s.status === 'loading' || s.status === 'checking' || s.status === 'refreshing') {
557 await dispatch($, { type: 'reloaded' })
558 }
559 // A detail read the reload killed would otherwise stay "Loading…" for good.
560 if (s.detail?.status === 'loading')
561 await dispatch($, { type: 'detail_closed', seq: s.detail.seq })
562 // Background work starts from timers, never inside this dispatch, so the
563 // first prompt is never held by a read. A -p run or the SDK draws
564 // nowhere (no surface), so it reads nothing in the background (no REST
565 // reads, no gh) unless a client attaches later. The surface, not
566 // isInteractive, decides: a reload after a client attached to such a
567 // session starts with that client's surface and keeps the reads.
568 if (e.surface !== null) armRefresh($, options)
569 if (options.auto_open === true && e.isInteractive) {
570 fire($, $.ui.open({ id: PANE, title: TITLE }))
571 }
572 return next(e)
573 })
574
575 // A client joining a session that started where nothing draws: the pane
576 // can now be shown, so the background reads start.
577 on('session.attach', async ($, e, next) => {
578 armRefresh($, options)
579 return next(e)
580 })
581
582 // The last client that could draw the pane left: the background reads stop
583 // until another attaches. A session ending under its clients is
584 // session.end's to handle.
585 on('session.detach', async ($, e, next) => {
586 const result = await next(e)
587 if (e.reason === 'detach' && (await $.session.surfaces()).length === 0) disarmRefresh()
588 return result
589 })
590
591 on('session.end', async (_$, e, next) => {
592 // /clear, /resume and /branch keep the module but end the conversation:
593 // its reads stop now. Its $.state is reset to the defaults after this
594 // hook, so the fresh read starts from classic.SessionStart below.
595 if (e.reason === 'clear' || e.reason === 'resume') {
596 stopReads()
597 resetPending = true
598 }
599 return next(e)
600 })
601
602 // Fires after /clear, /resume and /branch (source fork) have reset $.state,
603 // which session.start does not. Startup and compaction reset nothing, so
604 // the matcher leaves them out. The settings hooks beneath still run.
605 // /branch reports fork and has no session.end of its own, so a fork always
606 // reads again; a launch with --fork-session costs one extra read.
607 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
608 if (resetPending || e.source === 'fork') {
609 resetPending = false
610 stopReads()
611 await dispatch($, { type: 'session_transition' })
612 if (tick !== null) $.clock.after(0, () => fire($, refresh($, options)))
613 }
614 return next(e)
615 })
616
617 on('command.run', { command: 'commitments' }, async ($) => {
618 await $.ui.open({ id: PANE, title: TITLE, focus: true })
619 $.clock.after(0, () => fire($, refresh($, options)))
620 // The model reads this line: it names the action, never a record.
621 return { text: 'Opened the 3ngram commitments panel.' }
622 })
623
624 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
625 const { Box, Text, Button } = $.ui.resolve(e)
626 const held = await readPanel($)
627 const s = selectionConfirmed ? held : unconfirmed(held)
628
629 const shownDetail = visibleDetail(s)
630 if (shownDetail) {
631 const d = detailView(shownDetail)
632 const lines = (prefix: string, items: string[], dim: boolean) =>
633 items.map((line, i) => (
634 <Text key={`${prefix}-${i}`} dimColor={dim}>
635 {line}
636 </Text>
637 ))
638 return (
639 <Box flexDirection="column">
640 <Box>
641 <Button key="back" label="Back" hotkey="b" onPress={() => fire($, closeDetail($))} />
642 </Box>
643 <Text bold>{d.title}</Text>
644 <Text bold={d.status.tone === 'error'} dimColor={d.status.tone === 'busy'}>
645 {d.status.label}
646 </Text>
647 {d.labels.length > 0 ? <Text dimColor>{d.labels.join(' · ')}</Text> : null}
648 {d.content !== null ? <Text>{d.content}</Text> : null}
649 {lines('source', d.source, true)}
650 {d.evidence.length > 0 ? <Text bold>Evidence</Text> : null}
651 {lines('evidence', d.evidence, false)}
652 {lines('window', d.window, true)}
653 {d.history.length > 0 ? <Text bold>History</Text> : null}
654 {lines('history', d.history, true)}
655 {lines('note', d.notes, true)}
656 </Box>
657 )
658 }
659
660 const view = panelView(s)
661 return (
662 <Box flexDirection="column">
663 {view.header.map((line, i) => (
664 <Text key={`header-${i}`} bold={i === 0}>
665 {line}
666 </Text>
667 ))}
668 <Text
669 bold={view.status.tone === 'stale' || view.status.tone === 'error'}
670 dimColor={view.status.tone === 'busy'}
671 >
672 {view.status.label}
673 </Text>
674 <Box>
675 <Button
676 key="refresh"
677 label="Refresh"
678 hotkey="r"
679 onPress={() => fire($, refresh($, options))}
680 />
681 {view.canCancel ? (
682 <Button
683 key="cancel"
684 label="Cancel"
685 hotkey="c"
686 onPress={() => fire($, cancel($, options))}
687 />
688 ) : null}
689 </Box>
690 {view.empty !== null ? <Text dimColor>{view.empty}</Text> : null}
691 {view.sections.map((section, si) => (
692 <Box key={`section-${si}`} flexDirection="column" marginTop={1}>
693 <Text bold>{section.title}</Text>
694 {section.rows.map((row, ri) => (
695 <Box key={`row-${si}-${ri}`} flexDirection="column">
696 <Button
697 key={`open-${si}-${ri}`}
698 label={clip(row.title, MAX_LABEL)}
699 plain
700 onPress={() => fire($, openDetail($, options, row.memoryId))}
701 />
702 <Text dimColor>{row.labels.join(' · ')}</Text>
703 </Box>
704 ))}
705 </Box>
706 ))}
707 {view.notes.map((note, i) => (
708 <Text key={`note-${i}`} dimColor>
709 {note}
710 </Text>
711 ))}
712 </Box>
713 )
714 })
715}
716hooks/lib/argv.ts 88 lines1// SPDX-License-Identifier: Apache-2.0
2
3// The only commands the panel runs. Every spawn goes through these builders,
4// and isAllowedArgv is checked before each one, so the panel can only ever
5// start the read-only `3ngram-hook commitments` subcommands.
6
7export const HOOK_BINARY = '3ngram-hook'
8
9// Selection is what the panel reads under: the session's directory (from
10// which the binary derives the project), and the plugin's options.
11export type Selection = {
12 cwd: string
13 scope: string
14 includeUnscoped: boolean
15 github: boolean
16}
17
18function selectionFlags(sel: Selection): string[] {
19 const flags = ['--json', '--cwd', sel.cwd]
20 if (sel.scope !== '') flags.push('--scope', sel.scope)
21 if (sel.includeUnscoped) flags.push('--include-unscoped')
22 return flags
23}
24
25export function contextArgv(sel: Selection): string[] {
26 return [HOOK_BINARY, 'commitments', 'context', ...selectionFlags(sel)]
27}
28
29export function listArgv(sel: Selection): string[] {
30 const argv = [HOOK_BINARY, 'commitments', 'list', ...selectionFlags(sel)]
31 if (sel.github) argv.push('--github')
32 return argv
33}
34
35export function showArgv(sel: Selection, memoryId: string, fingerprint: string): string[] {
36 const argv = [
37 HOOK_BINARY,
38 'commitments',
39 'show',
40 memoryId,
41 '--expect-fingerprint',
42 fingerprint,
43 ...selectionFlags(sel),
44 ]
45 if (sel.github) argv.push('--github')
46 return argv
47}
48
49// selectionKey identifies what a refresh asks for. Two refreshes with the same
50// key may keep each other's rows while one is in flight; a different key
51// clears them before anything is read. GitHub lookups are part of it: rows and
52// a detail read with them on carry evidence a read with them off must not.
53export function selectionKey(sel: Selection): string {
54 return JSON.stringify([sel.cwd, sel.scope, sel.includeUnscoped, sel.github])
55}
56
57const OPERATIONS = new Set(['context', 'list', 'show'])
58const VALUE_FLAGS = new Set(['--cwd', '--scope', '--expect-fingerprint'])
59const BARE_FLAGS = new Set(['--json', '--include-unscoped', '--github'])
60
61// isAllowedArgv accepts exactly the shapes the builders produce: the hook
62// binary, `commitments`, one read operation (show with its id), and only the
63// read flags. A value can never smuggle a flag in, since every value position
64// is fixed by the flag before it.
65export function isAllowedArgv(argv: readonly string[]): boolean {
66 if (argv[0] !== HOOK_BINARY || argv[1] !== 'commitments') return false
67 const operation = argv[2]
68 if (operation === undefined || !OPERATIONS.has(operation)) return false
69 let i = 3
70 if (operation === 'show') {
71 const id = argv[3]
72 if (id === undefined || id === '' || id.startsWith('-') || /[\s\p{Cc}]/u.test(id)) return false
73 i = 4
74 }
75 for (; i < argv.length; i++) {
76 const arg = argv[i] ?? ''
77 if (BARE_FLAGS.has(arg)) continue
78 const value = argv[i + 1]
79 // A flag's value never looks like a flag, so no value can be read as one.
80 if (VALUE_FLAGS.has(arg) && value !== undefined && !value.startsWith('-')) {
81 i++
82 continue
83 }
84 return false
85 }
86 return true
87}
88hooks/lib/contract.ts 200 lines1// SPDX-License-Identifier: Apache-2.0
2
3// The stdout contract of `3ngram-hook commitments` (cmd/3ngram-hook), read on
4// this side of the process boundary. The Go side is the one writer; these
5// types name only what the panel reads, and parseEnvelope checks only what the
6// panel's safety depends on (the contract version and the context
7// fingerprint). Server enums are not re-validated here.
8
9export const CONTRACT = '3ngram-hook.commitments.v1'
10
11export type Selector = {
12 kind: string
13 scope?: string
14 project?: string
15 includeUnscoped?: boolean
16}
17
18export type EnvelopeContext = {
19 fingerprint: string
20 apiHost: string
21 account?: { id: string; email: string }
22 project: { name: string; source: string }
23 requested: Selector
24 effective?: Selector
25}
26
27export type PartialPart = { part: string; reason: string; returned?: number; total?: number }
28
29export type ReadError = { kind: string; route?: string; status?: number; hint?: string }
30
31export type GitHubEvidence = {
32 kind: 'github_reference'
33 source: 'github'
34 ref: string
35 referenceForm: string
36 type: string
37 state: string
38 stateReason: string | null
39 closedAt: string | null
40 mergedAt: string | null
41 title?: string
42 url?: string
43}
44
45export type Filing = 'project' | 'unscoped' | 'unknown'
46
47export type CommitmentRow = {
48 memoryId: string
49 commitmentId: string
50 topic: string
51 status: string
52 dueAt: string | null
53 overdue: boolean
54 filing: Filing
55 ownership: { value: string; reason: string }
56 github?: GitHubEvidence[]
57}
58
59export type Counts = {
60 openOrWaiting: number
61 overdue: number
62 returned: number
63 changedDuringRead: number
64}
65
66export type GitHubWindow = { found: number; checked: number; cap: number; ambiguousSkipped: number }
67
68export type HistoryMemory = {
69 id: string
70 memoryType: string
71 topic: string
72 project: string | null
73 scope: string
74 recordedAt: string
75 isCurrent: boolean
76}
77
78export type EvidenceItem = {
79 kind: 'related_memory' | 'consolidation_proposal'
80 source: string
81 edgeType: string
82 relation: 'successor' | 'predecessor'
83 memoryId: string
84 memoryType?: string
85 topic?: string
86 recordedAt?: string
87 current?: boolean
88 proposalId?: string
89 similarity?: number
90 rationale?: string | null
91}
92
93export type Evidence = {
94 verdict: 'review' | 'none_found' | 'not_inspected'
95 items: EvidenceItem[]
96 inspected: {
97 proposals: {
98 limit: number
99 returned: number
100 mayHaveMore: boolean
101 partnerLookups: number
102 } | null
103 history: {
104 lineageNodeCap: number
105 relationshipCap: number
106 eventCap: number
107 lineageTruncated: boolean
108 relationshipsTruncated: boolean
109 eventsTruncated: boolean
110 } | null
111 github?: GitHubWindow
112 }
113 hiddenOutsideSelector: number
114 unverifiedPartners: number
115 github?: GitHubEvidence[]
116}
117
118export type CommitmentDetail = {
119 memoryId: string
120 topic: string
121 content: string
122 scope: string
123 project: string | null
124 filing: Filing
125 status: string
126 commitmentStatus?: string
127 current: boolean
128 tags: string[]
129 recordedAt: string
130}
131
132export type CommitmentHistory = {
133 events: { eventKind: string; actorKind: string; createdAt: string }[]
134 eventsTruncated: boolean
135 lineage: HistoryMemory[]
136 relationships: { memory: HistoryMemory; edge: { edgeType: string } }[]
137 lineageTruncated: boolean
138 relationshipsTruncated: boolean
139 hiddenOutsideSelector: { nodes: number; edges: number; relationships: number }
140}
141
142export type Envelope = {
143 contract: string
144 operation: 'list' | 'show' | 'context'
145 ok: boolean
146 binary: string
147 context: EnvelopeContext
148 generatedAt?: string
149 counts?: Counts
150 commitments?: CommitmentRow[]
151 partial?: PartialPart[]
152 missing?: string[]
153 error?: ReadError
154 githubSearch?: GitHubWindow
155 commitment?: CommitmentDetail
156 source?: {
157 createdBy: string | null
158 createdAt: string | null
159 session: null
160 sessionReason: string
161 }
162 history?: CommitmentHistory
163 evidence?: Evidence
164}
165
166export type ParseResult =
167 | { ok: true; envelope: Envelope }
168 | { ok: false; reason: 'unparseable' | 'contract' }
169
170function isRecord(value: unknown): value is Record<string, unknown> {
171 return typeof value === 'object' && value !== null && !Array.isArray(value)
172}
173
174// parseEnvelope accepts exactly one v1 envelope. Anything else (no JSON, a
175// different contract, an envelope without a fingerprint, an ok list without
176// rows) is refused, so the panel never shows rows it cannot attribute to a
177// context.
178export function parseEnvelope(stdout: string): ParseResult {
179 let value: unknown
180 try {
181 value = JSON.parse(stdout)
182 } catch {
183 return { ok: false, reason: 'unparseable' }
184 }
185 if (!isRecord(value) || value.contract !== CONTRACT) {
186 return { ok: false, reason: 'contract' }
187 }
188 const context = value.context
189 if (!isRecord(context) || typeof context.fingerprint !== 'string' || context.fingerprint === '') {
190 return { ok: false, reason: 'contract' }
191 }
192 if (typeof value.ok !== 'boolean' || typeof value.operation !== 'string') {
193 return { ok: false, reason: 'contract' }
194 }
195 if (value.ok && value.operation === 'list' && !Array.isArray(value.commitments)) {
196 return { ok: false, reason: 'contract' }
197 }
198 return { ok: true, envelope: value as unknown as Envelope }
199}
200hooks/lib/process.ts 27 lines1// SPDX-License-Identifier: Apache-2.0
2
3import type { FailureKind } from './state.ts'
4
5// How a spawn that produced no envelope failed, from what the engine and the
6// binary said. Kept here, pure, so the exact engine wording is pinned by a
7// spec (the engine-kit tests cannot reproduce it: the kit sanitizes errors a
8// test hook throws).
9
10// classifySpawnError reads the error a spawn rejected with. The engine's
11// wording for a binary that is not on PATH is, for example,
12// 3ngram: $.process.spawn(3ngram-hook) failed to start: ENOENT:
13// Executable not found in $PATH: "3ngram-hook"
14// "failed to start" alone is not enough: a binary that is on PATH but cannot
15// run (EACCES, a wrong-architecture ENOEXEC) fails to start too, and telling
16// that person to install the binary would be wrong.
17export function classifySpawnError(message: string): FailureKind {
18 return /ENOENT|not found in \$PATH/i.test(message) ? 'missing_binary' : 'crash'
19}
20
21// classifyEmptyOutput reads stderr when the binary exited without printing an
22// envelope. A binary from before the commitments command answers with
23// `unknown command: commitments`.
24export function classifyEmptyOutput(stderr: string): FailureKind {
25 return /unknown command: commitments/.test(stderr) ? 'too_old' : 'crash'
26}
27hooks/lib/state.ts 329 lines1// SPDX-License-Identifier: Apache-2.0
2
3import type { CommitmentRow, Envelope } from './contract.ts'
4
5// The panel's state machine. It is pure: register.tsx runs the processes and
6// feeds their outcomes in as events. The invariants it holds:
7//
8// - Rows exist only inside a record, next to the envelope (and so the context
9// fingerprint) that produced them; a new envelope replaces a record whole.
10// - A refresh for a different selection, or one whose context probe could not
11// confirm the held record's fingerprint, clears the record before anything
12// is read; a result from an older generation is ignored.
13// - After a failure, rows are kept (as stale) only when the failed attempt's
14// context is VERIFIED to be the record's: by the error envelope's own
15// fingerprint, or, when no envelope came back at all (a timeout, a missing
16// binary, a crash, a cancel), by the local `commitments context` probe.
17// Otherwise list and detail are both cleared.
18// - The detail view shows under exactly the conditions the rows do, and is
19// dropped whenever the rows stop showing.
20// - An event the state does not take (an older generation, a verification
21// already settled, a detail no longer open) returns the very state it was
22// given, so a caller can tell its event was ignored.
23
24// How a run can fail without producing an envelope.
25export type FailureKind =
26 | 'timeout'
27 | 'missing_binary'
28 | 'too_old'
29 | 'crash'
30 | 'unparseable'
31 | 'contract'
32 | 'cancelled'
33 | 'context_changed'
34 | 'context_moved'
35
36export type PanelStatus =
37 | 'idle'
38 | 'loading'
39 | 'checking'
40 | 'refreshing'
41 | 'verifying'
42 | 'ready'
43 | 'stale'
44 | 'error'
45
46export type PanelError = { kind: string; hint?: string; contextUnverified?: boolean }
47
48export type DetailState = {
49 seq: number
50 memoryId: string
51 fingerprint: string
52 status: 'loading' | 'ready' | 'error'
53 envelope: Envelope | null
54 error: PanelError | null
55}
56
57export type PanelState = {
58 gen: number
59 selectionKey: string | null
60 status: PanelStatus
61 record: { envelope: Envelope; fetchedAt: number } | null
62 error: PanelError | null
63 stale: { since: number; reason: string } | null
64 // The failure a verification is pending for.
65 pending: string | null
66 detail: DetailState | null
67 detailSeq: number
68}
69
70export const initialState: PanelState = {
71 gen: 0,
72 selectionKey: null,
73 status: 'idle',
74 record: null,
75 error: null,
76 stale: null,
77 pending: null,
78 detail: null,
79 detailSeq: 0,
80}
81
82export type PanelEvent =
83 // A refresh holding rows is about to probe the context: until the probe
84 // confirms it, nothing read under the old context shows.
85 | { type: 'check_started' }
86 // fingerprint is what the local context probe reported just before this
87 // refresh: null when it could not answer, or when no record was held.
88 | { type: 'refresh_started'; gen: number; selectionKey: string; fingerprint: string | null }
89 | { type: 'list_envelope'; gen: number; envelope: Envelope; at: number }
90 | { type: 'list_failed'; gen: number; failure: FailureKind; at: number }
91 | { type: 'cancel' }
92 | { type: 'context_verified'; gen: number; fingerprint: string | null; at: number }
93 | { type: 'detail_requested'; memoryId: string }
94 | { type: 'detail_envelope'; seq: number; envelope: Envelope }
95 | { type: 'detail_failed'; seq: number; failure: FailureKind }
96 // seq, when given, closes only that detail (a later one stays open).
97 | { type: 'detail_closed'; seq?: number }
98 | { type: 'session_transition' }
99 | { type: 'reloaded' }
100
101// Envelope error kinds that say nothing changed about WHO or WHAT is read:
102// with a matching fingerprint, the rows held are still the right rows.
103const TRANSIENT = new Set(['timeout', 'unavailable', 'rate_limited', 'cancelled'])
104
105const IN_FLIGHT = new Set<PanelStatus>(['loading', 'checking', 'refreshing'])
106
107// The statuses in which rows, and an open detail, are shown.
108const SHOWING = new Set<PanelStatus>(['ready', 'refreshing', 'stale'])
109
110function cleared(s: PanelState, status: PanelStatus, error: PanelError | null): PanelState {
111 return { ...s, status, error, record: null, detail: null, stale: null, pending: null }
112}
113
114export function reduce(s: PanelState, e: PanelEvent): PanelState {
115 switch (e.type) {
116 case 'check_started':
117 return s.record && SHOWING.has(s.status) ? { ...s, status: 'checking' } : s
118 case 'refresh_started':
119 return onRefreshStarted(s, e.gen, e.selectionKey, e.fingerprint)
120 case 'list_envelope':
121 return e.gen === s.gen ? onListEnvelope(s, e.envelope, e.at) : s
122 case 'list_failed': {
123 if (e.gen !== s.gen) return s
124 if (!s.record) return cleared(s, 'error', { kind: e.failure })
125 // The detail goes with the rows: neither shows while the context is
126 // unconfirmed, and a confirmed context brings back the rows only.
127 return { ...s, status: 'verifying', pending: e.failure, detail: null }
128 }
129 case 'cancel': {
130 if (!IN_FLIGHT.has(s.status)) return s
131 const next = { ...s, gen: s.gen + 1 }
132 return s.record
133 ? { ...next, status: 'verifying', pending: 'cancelled', detail: null }
134 : cleared(next, 'error', { kind: 'cancelled' })
135 }
136 case 'context_verified': {
137 if (e.gen !== s.gen || s.status !== 'verifying') return s
138 const reason = s.pending ?? 'unknown'
139 if (
140 s.record &&
141 e.fingerprint !== null &&
142 e.fingerprint === s.record.envelope.context.fingerprint
143 ) {
144 return { ...s, status: 'stale', stale: { since: e.at, reason }, pending: null }
145 }
146 return cleared(s, 'error', { kind: reason, contextUnverified: true })
147 }
148 case 'detail_requested':
149 return onDetailRequested(s, e.memoryId)
150 case 'detail_envelope':
151 return onDetailEnvelope(s, e.seq, e.envelope)
152 case 'detail_failed': {
153 if (!s.detail || s.detail.seq !== e.seq) return s
154 return {
155 ...s,
156 detail: { ...s.detail, status: 'error', envelope: null, error: { kind: e.failure } },
157 }
158 }
159 case 'detail_closed':
160 if (e.seq !== undefined && s.detail?.seq !== e.seq) return s
161 return { ...s, detail: null }
162 case 'session_transition':
163 return { ...cleared(s, 'idle', null), gen: s.gen + 1, selectionKey: null }
164 case 'reloaded':
165 // A reload drops the module and kills the children it was waiting on.
166 // Whatever was in flight is treated as cancelled.
167 return reduce(s, { type: 'cancel' })
168 }
169}
170
171// unconfirmed is what the pane may draw while the current selection is not
172// yet confirmed against the state the host kept: nothing read before, neither
173// rows nor a detail, only that a check is under way.
174export function unconfirmed(s: PanelState): PanelState {
175 return { ...s, status: 'checking', record: null, detail: null, stale: null, error: null }
176}
177
178function onRefreshStarted(
179 s: PanelState,
180 gen: number,
181 selectionKey: string,
182 fingerprint: string | null,
183): PanelState {
184 // A different selection, or a held record the probe could not confirm
185 // (another key, another backend, or no answer), is cleared before the read
186 // starts: its rows never show while the new context is being read.
187 const unconfirmed = s.record !== null && fingerprint !== s.record.envelope.context.fingerprint
188 if (selectionKey !== s.selectionKey || unconfirmed) {
189 return { ...cleared(s, 'loading', null), gen, selectionKey }
190 }
191 // Rows stay visible during a refresh only if they were visible before it:
192 // a record whose context is still being verified, or one an error already
193 // hid, must not reappear just because a new read started.
194 // checking hid the rows only until this confirmation.
195 const shown = s.record !== null && (SHOWING.has(s.status) || s.status === 'checking')
196 return {
197 ...s,
198 gen,
199 status: shown ? 'refreshing' : 'loading',
200 error: null,
201 pending: null,
202 detail: shown ? s.detail : null,
203 }
204}
205
206function onListEnvelope(s: PanelState, envelope: Envelope, at: number): PanelState {
207 if (envelope.ok) {
208 const fingerprint = envelope.context.fingerprint
209 // An open detail survives a refresh only under the same context AND while
210 // its commitment is still in the list with the filing it was opened
211 // under: one resolved or superseded since, or moved between this project
212 // and unscoped in place, must not keep showing its old state.
213 const detail =
214 s.detail && s.detail.fingerprint === fingerprint && sameRow(s, envelope, s.detail.memoryId)
215 ? s.detail
216 : null
217 return {
218 ...s,
219 status: 'ready',
220 record: { envelope, fetchedAt: at },
221 error: null,
222 stale: null,
223 pending: null,
224 detail,
225 }
226 }
227 const kind = envelope.error?.kind ?? 'unknown'
228 const sameContext =
229 s.record !== null && s.record.envelope.context.fingerprint === envelope.context.fingerprint
230 if (sameContext && TRANSIENT.has(kind)) {
231 return { ...s, status: 'stale', stale: { since: at, reason: kind }, pending: null }
232 }
233 const error: PanelError = envelope.error?.hint ? { kind, hint: envelope.error.hint } : { kind }
234 return cleared(s, 'error', error)
235}
236
237// sameRow reports whether the refreshed list still holds memoryId as the
238// open detail knows it: the same commitment status (open and waiting change
239// in place) and no contradicting filing. What the detail knows is its own
240// answer once it has one, the row it was opened from before that. A filing of
241// `unknown` contradicts nothing: a list row is unknown when its lookup failed
242// or was over budget, which says nothing about a move.
243function sameRow(s: PanelState, envelope: Envelope, memoryId: string): boolean {
244 const row = (envelope.commitments ?? []).find((r) => r.memoryId === memoryId)
245 if (!row) return false
246 const opened = s.record?.envelope.commitments?.find((r) => r.memoryId === memoryId)
247 const commitment = s.detail?.envelope?.commitment
248 // The detail's commitmentStatus is the list's status vocabulary (open,
249 // waiting); its memory status (active) is not, so it is never compared.
250 const heldStatus = commitment?.commitmentStatus ?? opened?.status
251 if (heldStatus !== undefined && row.status !== heldStatus) return false
252 // Live GitHub evidence can change with nothing else (a referenced PR
253 // merges): the detail read before that would keep showing the old state.
254 if (opened && githubState(opened) !== githubState(row)) return false
255 const held = commitment?.filing ?? opened?.filing
256 if (held === undefined || held === 'unknown' || row.filing === 'unknown') return true
257 return row.filing === held
258}
259
260// githubState is what a row's GitHub evidence says, as one comparable value.
261// Sorted by ref, so a reorder alone is not a change.
262function githubState(row: CommitmentRow): string {
263 return JSON.stringify(
264 (row.github ?? [])
265 .map((g) => [g.ref, g.type, g.state, g.stateReason, g.closedAt, g.mergedAt])
266 .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
267 )
268}
269
270function onDetailRequested(s: PanelState, memoryId: string): PanelState {
271 const rows = visibleRows(s)
272 if (!s.record || !rows?.some((row) => row.memoryId === memoryId)) return s
273 const seq = s.detailSeq + 1
274 return {
275 ...s,
276 detailSeq: seq,
277 detail: {
278 seq,
279 memoryId,
280 fingerprint: s.record.envelope.context.fingerprint,
281 status: 'loading',
282 envelope: null,
283 error: null,
284 },
285 }
286}
287
288function onDetailEnvelope(s: PanelState, seq: number, envelope: Envelope): PanelState {
289 const detail = s.detail
290 // A detail lands only while its rows show, under the context its list came
291 // from.
292 // While the context is being checked the detail is kept but hidden, so an
293 // answer that lands then is kept too; visibleDetail shows it only once the
294 // check confirms the context, and a disagreeing check clears it.
295 if (!detail || detail.seq !== seq || !(SHOWING.has(s.status) || s.status === 'checking')) return s
296 if (!s.record || s.record.envelope.context.fingerprint !== detail.fingerprint) {
297 return { ...s, detail: null }
298 }
299 if (envelope.context.fingerprint !== detail.fingerprint) {
300 return {
301 ...s,
302 detail: { ...detail, status: 'error', envelope: null, error: { kind: 'context_changed' } },
303 }
304 }
305 if (!envelope.ok) {
306 const error: PanelError = { kind: envelope.error?.kind ?? 'unknown' }
307 return { ...s, detail: { ...detail, status: 'error', envelope: null, error } }
308 }
309 return { ...s, detail: { ...detail, status: 'ready', envelope, error: null } }
310}
311
312// visibleRows is the ONLY way the view reads rows: none while a load, a
313// verification or an error is showing, even if a record is still held.
314export function visibleRows(s: PanelState): CommitmentRow[] | null {
315 if (!s.record || !SHOWING.has(s.status)) return null
316 return s.record.envelope.commitments ?? null
317}
318
319// visibleDetail is the only way the view reads the detail: the same gate as
320// the rows, and only under the record's own context.
321export function visibleDetail(s: PanelState): DetailState | null {
322 if (!s.detail || !s.record || !SHOWING.has(s.status)) return null
323 return s.detail.fingerprint === s.record.envelope.context.fingerprint ? s.detail : null
324}
325
326export function needsVerification(s: PanelState): boolean {
327 return s.status === 'verifying'
328}
329hooks/lib/view.ts 402 lines1// SPDX-License-Identifier: Apache-2.0
2
3import type { CommitmentRow, Envelope, GitHubEvidence, PartialPart } from './contract.ts'
4import type { DetailState, PanelState } from './state.ts'
5import { visibleRows } from './state.ts'
6
7// The view model: everything the pane shows, as plain strings, so the wording
8// is tested here and register.tsx only lays it out. State is told apart by
9// words and emphasis (`tone` picks bold or dim), never by color alone.
10
11export type Tone = 'normal' | 'busy' | 'stale' | 'error'
12
13export type RowView = { memoryId: string; title: string; labels: string[] }
14
15export type SectionView = { title: string; rows: RowView[] }
16
17export type PanelView = {
18 header: string[]
19 status: { label: string; tone: Tone }
20 sections: SectionView[]
21 notes: string[]
22 empty: string | null
23 canCancel: boolean
24}
25
26const FAILURE_TEXT: Record<string, string> = {
27 timeout: 'The read timed out.',
28 cancelled: 'The refresh was cancelled.',
29 context_moved:
30 'The account or backend changed during the read, so its rows are not shown. Refresh to read again.',
31 missing_binary: '3ngram-hook is not on PATH. Install it to use the panel.',
32 too_old: 'This 3ngram-hook has no commitments command. Rebuild it from the 3ngram repository.',
33 crash: '3ngram-hook stopped without an answer.',
34 unparseable: '3ngram-hook answered with something that is not an envelope.',
35 contract: '3ngram-hook answered with an envelope this panel does not understand.',
36 auth: 'The 3ngram API key was refused. Run `3ngram-hook verify`.',
37 no_key: 'No 3ngram API key was found (THREENGRAM_API_KEY or ~/.config/3ngram/api-key).',
38 unavailable: 'The 3ngram API is unavailable.',
39 rate_limited: 'The 3ngram API is rate limiting this key.',
40 route_missing: 'This 3ngram server is too old for the panel.',
41 selector_mismatch: 'The server answered for a wider selection than asked; nothing is shown.',
42 invalid_selector: 'The selection is not valid. Including unscoped records needs a scope.',
43 bad_response: 'The server sent a response the panel cannot read.',
44 bad_request: 'The server refused the request.',
45 context_changed: 'The account or selection changed; refreshing.',
46 outside_selector: 'That commitment is outside the current selection.',
47 not_found: 'That commitment no longer exists.',
48 usage: 'The panel called 3ngram-hook wrongly. Please report this.',
49}
50
51export function failureText(kind: string): string {
52 return FAILURE_TEXT[kind] ?? `The read failed (${kind}).`
53}
54
55function time(ms: number): string {
56 return `${new Date(ms).toISOString().slice(11, 16)} UTC`
57}
58
59function day(iso: string): string {
60 return iso.slice(0, 10)
61}
62
63export function headerLines(envelope: Envelope): string[] {
64 const c = envelope.context
65 const account = c.account ? c.account.email : 'account unavailable'
66 const source =
67 c.project.source === 'git-remote' ? 'from the git remote' : 'from the directory name'
68 const sel = c.effective ?? c.requested
69 const scope = sel.scope ? `scope ${sel.scope}` : 'any scope'
70 const unscoped =
71 sel.kind === 'scope_project' && sel.includeUnscoped
72 ? 'unscoped records included'
73 : 'unscoped records excluded'
74 return [
75 `${c.apiHost} · ${account}`,
76 `project ${c.project.name} (${source}) · ${scope} · ${unscoped}`,
77 ]
78}
79
80export function githubLabel(g: GitHubEvidence): string {
81 const kind = g.type === 'pull_request' ? 'PR' : g.type === 'issue' ? 'issue' : 'ref'
82 switch (g.state) {
83 case 'merged':
84 return `${kind} ${g.ref} merged${g.mergedAt ? ` ${day(g.mergedAt)}` : ''}`
85 case 'closed': {
86 const how =
87 g.type === 'pull_request'
88 ? ' without merging'
89 : g.stateReason === 'not_planned'
90 ? ' as not planned'
91 : ''
92 return `${kind} ${g.ref} closed${how}${g.closedAt ? ` ${day(g.closedAt)}` : ''}`
93 }
94 case 'not_found':
95 return `${g.ref} not found or not visible`
96 default:
97 return `${kind} ${g.ref} ${g.state}`
98 }
99}
100
101export function rowView(row: CommitmentRow): RowView {
102 const labels: string[] = []
103 if (row.overdue) labels.push('OVERDUE')
104 labels.push(row.status)
105 labels.push(row.dueAt ? `due ${day(row.dueAt)}` : 'no due date recorded')
106 if (row.filing === 'unscoped') labels.push('unscoped (no project)')
107 if (row.filing === 'unknown') labels.push('filing unknown')
108 labels.push(
109 row.ownership.value === 'unclear'
110 ? 'owner unclear (not recorded)'
111 : `owner ${row.ownership.value}`,
112 )
113 for (const g of row.github ?? []) labels.push(githubLabel(g))
114 return { memoryId: row.memoryId, title: row.topic, labels }
115}
116
117function sections(rows: CommitmentRow[]): SectionView[] {
118 const overdue = rows.filter((r) => r.overdue)
119 const open = rows.filter((r) => !r.overdue && r.status !== 'waiting')
120 const waiting = rows.filter((r) => !r.overdue && r.status === 'waiting')
121 return [
122 { title: `Overdue (${overdue.length})`, rows: overdue.map(rowView) },
123 { title: `Open (${open.length})`, rows: open.map(rowView) },
124 { title: `Waiting (${waiting.length})`, rows: waiting.map(rowView) },
125 ].filter((s) => s.rows.length > 0)
126}
127
128export function partialNote(p: PartialPart): string {
129 const n = (v: number | undefined) => v ?? 0
130 switch (p.part) {
131 case 'commitments':
132 return `Showing ${n(p.returned)} of ${n(p.total)} open or waiting commitments.`
133 case 'overdue':
134 return `Showing ${n(p.returned)} of ${n(p.total)} overdue commitments.`
135 case 'account':
136 return `The account could not be read (${p.reason}).`
137 case 'filing':
138 if (p.reason.startsWith('strict_read_')) {
139 return `Unscoped check: the strict read failed (${p.reason.slice('strict_read_'.length)}), so rows were checked one by one, up to the lookup limit.`
140 }
141 return `Unscoped check: verified ${n(p.returned)} of ${n(p.total)} rows (${p.reason}); the rest are labelled "filing unknown".`
142 case 'github':
143 return `GitHub: checked ${n(p.returned)} of ${n(p.total)} references (${p.reason}).`
144 default:
145 return `${p.part}: incomplete (${p.reason}).`
146 }
147}
148
149function listNotes(envelope: Envelope): string[] {
150 const notes = (envelope.partial ?? []).map(partialNote)
151 const changed = envelope.counts?.changedDuringRead ?? 0
152 if (changed > 0) notes.push(`${changed} row(s) changed while being read and are left out.`)
153 const ambiguous = envelope.githubSearch?.ambiguousSkipped ?? 0
154 if (ambiguous > 0) {
155 notes.push(
156 `${ambiguous} bare #N reference(s) skipped as ambiguous; write owner/repo#N to look them up.`,
157 )
158 }
159 if ((envelope.missing ?? []).includes('owner')) {
160 notes.push('Owners and source sessions are not exposed by the 3ngram read API.')
161 }
162 return notes
163}
164
165export function panelView(s: PanelState): PanelView {
166 const rows = visibleRows(s)
167 const envelope = rows ? s.record?.envelope : undefined
168 const view: PanelView = {
169 header: envelope ? headerLines(envelope) : ['3ngram commitments'],
170 status: statusOf(s),
171 sections: rows ? sections(rows) : [],
172 notes: envelope ? listNotes(envelope) : [],
173 empty: null,
174 canCancel: s.status === 'loading' || s.status === 'refreshing',
175 }
176 if (rows && rows.length === 0) view.empty = 'No open or waiting commitments in this selection.'
177 return view
178}
179
180function statusOf(s: PanelState): { label: string; tone: Tone } {
181 switch (s.status) {
182 case 'idle':
183 return { label: 'Not loaded yet.', tone: 'normal' }
184 case 'loading':
185 return { label: 'Loading…', tone: 'busy' }
186 case 'refreshing':
187 return { label: 'Refreshing…', tone: 'busy' }
188 case 'checking':
189 case 'verifying':
190 return { label: 'Checking the account and selection…', tone: 'busy' }
191 case 'ready':
192 return { label: `Updated ${s.record ? time(s.record.fetchedAt) : ''}`.trim(), tone: 'normal' }
193 case 'stale':
194 return {
195 label: `STALE since ${s.stale ? time(s.stale.since) : '?'}: ${failureText(s.stale?.reason ?? 'unknown')}`,
196 tone: 'stale',
197 }
198 case 'error': {
199 const kind = s.error?.kind ?? 'unknown'
200 const unverified = s.error?.contextUnverified
201 ? ' The account or selection could not be confirmed, so nothing is shown.'
202 : ''
203 return { label: `ERROR: ${failureText(kind)}${unverified}`, tone: 'error' }
204 }
205 }
206}
207
208// statusLine is the status-bar entry: counts only, never a topic.
209export function statusLine(s: PanelState): string | undefined {
210 const counts = visibleRows(s) ? s.record?.envelope.counts : undefined
211 if (s.status === 'error') return '3ngram: unavailable'
212 if (!counts) return s.status === 'idle' ? undefined : '3ngram: loading'
213 const base = `3ngram: ${counts.openOrWaiting} open · ${counts.overdue} overdue`
214 return s.status === 'stale' ? `${base} (stale)` : base
215}
216
217export type DetailView = {
218 title: string
219 status: { label: string; tone: Tone }
220 labels: string[]
221 content: string | null
222 source: string[]
223 history: string[]
224 evidence: string[]
225 window: string[]
226 notes: string[]
227}
228
229// MAX_CONTENT is how much of a commitment's content the detail draws. One
230// drawing shows at most 100,000 characters, and imported content can be far
231// longer, so the rest of the detail would otherwise be cut off unannounced.
232export const MAX_CONTENT = 20_000
233
234function boundedContent(content: string): string {
235 const chars = [...content]
236 if (chars.length <= MAX_CONTENT) return content
237 return `${chars.slice(0, MAX_CONTENT).join('')}…\n\n[Showing the first ${MAX_CONTENT.toLocaleString('en-US')} of ${chars.length.toLocaleString('en-US')} characters.]`
238}
239
240export function detailView(d: DetailState): DetailView {
241 const base: DetailView = {
242 title: 'Commitment',
243 status: { label: 'Loading…', tone: 'busy' },
244 labels: [],
245 content: null,
246 source: [],
247 history: [],
248 evidence: [],
249 window: [],
250 notes: [],
251 }
252 if (d.status === 'loading') return base
253 if (d.status === 'error' || !d.envelope) {
254 return {
255 ...base,
256 status: { label: `ERROR: ${failureText(d.error?.kind ?? 'unknown')}`, tone: 'error' },
257 }
258 }
259 const e = d.envelope
260 const c = e.commitment
261 const view: DetailView = {
262 ...base,
263 status: { label: 'Read-only. Nothing here changes a record.', tone: 'normal' },
264 }
265 if (c) {
266 view.title = c.topic
267 view.content = boundedContent(c.content)
268 view.labels = [
269 c.commitmentStatus ?? c.status,
270 c.filing === 'unscoped' ? 'unscoped (no project)' : `project ${c.project ?? ''}`,
271 ]
272 if (!c.current) view.labels.push('no longer current')
273 }
274 view.source = sourceLines(e)
275 view.history = historyLines(e)
276 view.evidence = evidenceLines(e)
277 view.window = windowLines(e)
278 view.notes = (e.partial ?? []).map(detailPartialNote)
279 return view
280}
281
282function sourceLines(e: Envelope): string[] {
283 const src = e.source
284 if (!src) return []
285 // The creation event is read from the audit events: an unavailable events
286 // section leaves it as unread as a failed history does.
287 const parts = e.partial ?? []
288 const unread = parts.some((p) => p.part === 'history')
289 ? 'the history'
290 : parts.some((p) => p.part === 'events')
291 ? 'the audit events'
292 : null
293 const created =
294 src.createdBy && src.createdAt
295 ? `Created by ${src.createdBy} on ${day(src.createdAt)}.`
296 : unread === null
297 ? 'Creation is outside the event window.'
298 : `Creation is unknown: ${unread} could not be read.`
299 return [created, 'Source session: not exposed by the 3ngram read API.']
300}
301
302function historyLines(e: Envelope): string[] {
303 const h = e.history
304 if (!h) return ['History unavailable.']
305 const lines = h.events.map((ev) => `${day(ev.createdAt)} · ${ev.eventKind} · ${ev.actorKind}`)
306 for (const rel of h.relationships) {
307 lines.push(`Linked ${rel.memory.memoryType} "${rel.memory.topic}" (${rel.edge.edgeType})`)
308 }
309 // The two counts are kept apart: one hidden memory can be both a lineage
310 // node and a direct link, so their sum is not a count of memories.
311 const { nodes, relationships } = h.hiddenOutsideSelector
312 const hidden: string[] = []
313 if (nodes > 0) hidden.push(`${nodes} lineage memor${nodes === 1 ? 'y' : 'ies'}`)
314 if (relationships > 0)
315 hidden.push(`${relationships} direct link${relationships === 1 ? '' : 's'}`)
316 if (hidden.length > 0) lines.push(`Outside this selection and hidden: ${hidden.join(', ')}.`)
317 return lines
318}
319
320function evidenceLines(e: Envelope): string[] {
321 const ev = e.evidence
322 if (!ev) return []
323 const lines: string[] = []
324 for (const item of ev.items) {
325 const topic = item.topic ? `"${item.topic}"` : 'a memory'
326 if (item.kind === 'related_memory') {
327 lines.push(`Newer ${item.memoryType ?? 'memory'} ${topic} ${item.edgeType} this commitment.`)
328 } else {
329 const would =
330 item.relation === 'successor'
331 ? `would ${verb(item.edgeType)} this commitment`
332 : `would be ${verb(item.edgeType)}d by this commitment`
333 const why = item.rationale ? ` Rationale: ${item.rationale}` : ''
334 lines.push(
335 `Pending proposal: ${topic} ${would} (similarity ${(item.similarity ?? 0).toFixed(2)}).${why}`,
336 )
337 }
338 }
339 for (const g of ev.github ?? [])
340 lines.push(`Related on GitHub: ${githubLabel(g)}${g.title ? `: ${g.title}` : ''}`)
341 if (ev.hiddenOutsideSelector > 0)
342 lines.push(
343 `${ev.hiddenOutsideSelector} proposal(s) involve memories outside this selection and are hidden.`,
344 )
345 if (ev.unverifiedPartners > 0)
346 lines.push(
347 `${ev.unverifiedPartners} proposal(s) could not be checked against this selection and are hidden.`,
348 )
349 lines.unshift(
350 ev.verdict === 'review'
351 ? 'Related evidence to review. It does not prove the commitment is done.'
352 : ev.verdict === 'not_inspected'
353 ? 'No evidence source could be read, so nothing was searched.'
354 : 'No evidence found in the inspected window.',
355 )
356 return lines
357}
358
359function verb(edgeType: string): string {
360 return edgeType === 'supersedes' ? 'supersede' : edgeType === 'extends' ? 'extend' : 'update'
361}
362
363function windowLines(e: Envelope): string[] {
364 const w = e.evidence?.inspected
365 if (!w) return []
366 const lines: string[] = []
367 lines.push(
368 w.proposals
369 ? `Proposals: the newest ${w.proposals.returned} pending (limit ${w.proposals.limit}, tenant-wide)${w.proposals.mayHaveMore ? '; more may exist' : ''}.`
370 : 'Proposals: not inspected.',
371 )
372 lines.push(
373 w.history
374 ? `History: up to ${w.history.relationshipCap} relationships and ${w.history.eventCap} events${w.history.relationshipsTruncated || w.history.eventsTruncated ? ', truncated' : ''}.`
375 : 'History: not inspected.',
376 )
377 if (w.github)
378 lines.push(
379 `GitHub: ${w.github.checked} of ${w.github.found} references checked (cap ${w.github.cap}).`,
380 )
381 return lines
382}
383
384function detailPartialNote(p: PartialPart): string {
385 switch (p.part) {
386 case 'proposals':
387 return p.reason === 'window'
388 ? 'Proposals: only the newest window was inspected.'
389 : `Proposals could not be read (${p.reason}).`
390 case 'proposal_partners':
391 return `Proposal partners: checked ${p.returned ?? 0} of ${p.total ?? 0} (${p.reason}).`
392 case 'history':
393 return `History could not be read (${p.reason}).`
394 case 'lineage':
395 case 'relationships':
396 case 'events':
397 return `${p.part[0]?.toUpperCase()}${p.part.slice(1)}: ${p.reason}.`
398 default:
399 return partialNote(p)
400 }
401}
402types/index.d.ts 20 lines1// SPDX-License-Identifier: Apache-2.0
2
3// The 3ngram plugin's $.state contract: one value, the panel's state.
4//
5// A contract is self-contained (no imports), so it names the value's shape
6// only as far as other plugins may rely on it: a generation counter and a
7// status. Its full type is PanelState in hooks/lib/state.ts, and the reducer
8// there is its only writer.
9export type ThreengramPanelState = {
10 gen: number
11 status: 'idle' | 'loading' | 'checking' | 'refreshing' | 'verifying' | 'ready' | 'stale' | 'error'
12 [field: string]: unknown
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 '3ngram': { panel: ThreengramPanelState }
18 }
19}
20