SLOPSHOPPER

3ngram

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

newpanecommandstatusprocesstimer
★ 2v0.1.0Apache-2.0updated 2026-10-09B3dmar/3ngram/plugins/3ngram
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 3ngram
│ ┃ 3ngram commitments ✕ › fix the failing auth test and add an audit log call │ ┃ 3ngram commitments │ ┃ Checking the account and selection… ⏺ Read(src/auth.ts) │ ┃ [ Refresh ] ⎿ 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 │ │ › /commitments │ ⎿ 3ngram: Opened the 3ngram commitments panel. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ 3ngram: 3ngram: loading

Draws

Pane · 3ngram commitments
3ngram commitments Checking the account and selection… [ Refresh ]
README

3ngram for Claude Code

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.

Install

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:

OptionDefaultWhat it does
scopeemptyRead within this 3ngram scope. Empty reads the project in any scope
include_unscopedoffAlso show the scope's commitments written without a project. Needs a scope
refresh_minutes5Background refresh interval, at least 1
github_evidenceonLook up the GitHub issues and pull requests commitments mention, read-only
auto_openoffOpen the panel at session start, on a terminal wide enough to dock it

The bundled hooks

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).

What it shows, and what it does not

  • The context first. The header names the backend, the account, the project and where its name came from, the scope, and whether unscoped records are included.
  • Rows only with their selection, across a reload. Changing the scope, the unscoped opt-in or GitHub lookups reloads the plugin, and Claude Code keeps the panel's state across the reload. Nothing it held draws until the new selection has been compared with it, and rows or a detail read under the old selection are cleared, never shown. Rows held under an unchanged selection wait for the context check below, since the key or backend can have changed in between; a read still running from before the reload cannot end that wait early.
  • Rows only with their context. Every read carries a fingerprint of the backend, the key and the selection, and rows are kept only next to the read that produced them. Before each refresh the panel checks the context with 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.
  • Nothing is left running. Cancel, /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).
  • No background reads where nothing draws. A 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).
  • Unscoped means verified. A row is labelled unscoped only after its memory is read back with no project; otherwise it is "filing unknown".
  • Owners and sources. Native writes never record an owner, and the read API does not expose the session a memory was written in, so the panel says "owner unclear (not recorded)" and "source session: not exposed" instead of guessing.
  • Stale rows are marked, an open detail is not yet. When a refresh fails under the same context, the list keeps its rows marked stale with the reason. While a detail is open it keeps showing its last answer, and the stale mark and reason appear only after Back (#269).
  • Evidence is for review. A newer memory that updates or supersedes a commitment, a pending proposal touching it, or a merged pull request it mentions is shown as related evidence. "No evidence found in the inspected window" says how far the search looked, and when no source could be read at all the panel says that nothing was searched.
  • Partial results are labelled. A truncated list, an unavailable account, an unfinished check or a GitHub lookup that stopped is named in a note under the list.

Read-only, by construction

  • The module runs only 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.
  • The binary sends only GET requests (enforced by a parse-level allowlist in its tests) and runs gh only as gh api --method GET.
  • The /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.

Development

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.

Source 7 files
hooks/register.tsx 716 lines
1// 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}
716
hooks/lib/argv.ts 88 lines
1// 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}
88
hooks/lib/contract.ts 200 lines
1// 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}
200
hooks/lib/process.ts 27 lines
1// 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}
27
hooks/lib/state.ts 329 lines
1// 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}
329
hooks/lib/view.ts 402 lines
1// 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}
402
types/index.d.ts 20 lines
1// 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