SLOPSHOPPER

gcc-mods

gcc's owner surfaces inside the Claude Code TUI: clickable paths, catchup band, context gauge, docs and tasks panes, fleet and wakes, hook router and nudge…

newpanebandspinnerrowsguard
v0.1.0no licenseupdated 2026-10-06alcatraz627/its-my-claude/mods/gcc-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gcc-mods
│ ┃ gcc ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1: Docs 2: Tasks 3: Nudges 4: Fleet │ gcc-mods │ │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ gcc-mods is on for this session. /hub off │ │ ┃ ──────────── ⎿ Read 6 lines │ silences it. │ │ ┃ no live goal here ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ No live goal touches this directory. The ⎿ Added 2 lines, removed 1 line │ ┃ agent files one with: gs new "outcome" ⏺ Bash(bun test) │ ┃ --accept "kind: what you will check". ⎿ 3 pass, 1 fail │ ┃ r: refresh │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ Worked for 42s · 9 tool calls │ │ › /hub │ │ ✻ Brewing… 0 docs · 0 owed · 0 running · 0 new · 0 nudges · 0 decisions hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ gcc-mods: 🌅 gcc · nothing waiting

Draws

Band
0 docs · 0 owed · 0 running · 0 new · 0 nudges · 0 decisions hide
Pane · gcc
1: Docs 2: Tasks 3: Nudges 4: Fleet 5: Inbox 6: De ──────────────────────────────────────────────────────── no live goal here No live goal touches this directory. The agent files one with: gs new "outcome" --accept "kind: what you will check". r: refresh
Pane · gcc-preview
Nothing to preview yet.
Turn line
Worked for 42s · 9 tool calls
README

gcc-mods

gcc's owner surfaces drawn inside the Claude Code TUI, so what waits on you, what is running, and what the agent actually exercised no longer travel through the model's reply.

The owner's guide, every surface and key explained, is GUIDE.md beside this file; /hub help or h in the hub opens it in the preview pane.

Loads in every session through CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, and starts off: a session behaves as if the mod were absent until /hub turns it on for that session. The startOn switch in /config makes it start on everywhere. The folder is watched, so edits hot-reload. Built against Claude Code 2.1.291; the mods API is early access, so after a CLI update run the three checks below before trusting it.

What it draws

SurfaceWhat you get
/hub pane, six tabsDocs (files this session wrote, preview, copy path, attach, open), Tasks (the goal record at real width, gates first, answers or say why through gs), Nudges (what fired, why, snooze, feedback), Fleet (seats, landing, output file check), Inbox (ipc mail, reply, quote), Decide (pending decision pages with drafted picks, submit)
/hub tasks, /hub help, /hub band, /hub off and /hub onthe Tasks tab, this guide, the band toggle, and the per-session switch
band above the promptone action row at a time: a decision page for this project, the checkpoint after a clear or resume (load fills /catchup), continue when the agent stopped with agent-ready rows open, a core-dump button past 70 percent context, then the owed read; the proof receipt beside a done-claim; count chips
status linectx 42% · wk 61% · 2 gates · 1 seat · ✉ 1
transcriptpaths drawn absolute and clickable with the trailing period outside the link; seat landings and peer mail as one line (ctrl+o for the full row); a dim line when a Stop hook sends the reply back
wakesan idle session is woken when ipc mail, a decision-page answer, or an unread seat report lands; the wake names the sender and the read command, never the body
funa spinner word that fits the moment, a turn footer with the tool count, a greeting with what waits, landing phrases, a toast when every acceptance row is proven

Any /hub turns the mod on for the current session; /hub off silences it again (every hook passes through, panes close, status clears). Every cluster is also a machine-wide switch in /config (startOn, hygiene, continuity, tasks, tasksWrite, fleet, wake, mailWake, router, nudges, decide, receipt, fun, sound). Turn one off rather than argue with it.

The router

A settings hook marks owner-facing text with hook_owner_wrap (in scripts/hooks/hook-common.sh):

<owner surface="toast|log|band|pane:nudges|pane:tasks|ask" hook="<script>" model="…">…</owner>

This mod draws the block on that surface and removes it from what the model reads, handing the model the model text instead when one is given. Without the mod (Codex, claude -p) the tag is plain text and the old paste-verbatim behaviour stands; the Codex adapter unwraps it in gcc_ctx_of. Adopters today: subagent-box.sh and task-table-inject.sh.

Checks

/Users/alcatraz627/.local/bin/claude plugin validate ~/.claude/mods/gcc-mods
tsc -p <a tsconfig that includes the engine's types, see the header of .claude-plugin/types/claude-code/index.d.ts once laid>
/Users/alcatraz627/.local/bin/claude plugin test ~/.claude/mods/gcc-mods

The shell alias claude adds a flag the plugin subcommand refuses, so call the binary by path.

Files stay the truth

The mod reads gs and the /tasks views, the checkpoint index, claude-ipc, the warn ledger and the decision pages. It writes only through their own CLIs (gs prove, gs accept, hook-snooze.sh, hook-feedback.sh, claude-ipc reply, the decision-page submit route). Nothing lives only in $.state.

Design record: ~/.claude/assets/reports/20261006-mods-scour/PROPOSAL.md and the four evidence files beside it.

Source 3 files
hooks/register.tsx 2749 lines
1// gcc-mods draws gcc's owner surfaces in the Claude Code TUI: the /hub pane,
2// the band above the prompt, the status line. It only reads gcc's files and
3// writes through their own CLIs; GUIDE.md lists every surface and key.
4//
5// Everything that touches `$` lives in this file because the plugin validator
6// follows `$` only into functions declared here; lib.ts holds the pure helpers.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
10
11import type { Audience, Bookmark, Catchup, DecideItem, DecideSet, Doc, DocFilter, Msg, Notice, Nudge, NudgeMode, Preview, Receipt, Scope, Seat, Snippet, Snips, Tab, View, GoalView, GoalsSnap, TaskRow } from '../types'
12import {
13  CURL_RE, DP_REG, GCC, GOAL_SH, GS, HOME, LANDING, PANE, PATH_RE, RUN_RE, SUBJECT_RE, SUCCESS_RE, TRAIL_RE, VIEWS, WORDS,
14  abs, ago, agoPhrase, answerString, clip, docKindOf, dur, emptyDecide, emptyFun, emptyIdle, emptyMail, emptyMeter, emptyReceipt, firstLine, gatedRows,
15  goalCounts, greetingFor, head, hk, hourGlyph, initialView, isGate, jsonOr, openGates, parseEditLine, parseJsonl, plural, readyRows, receiptLine, sid8,
16  splitOwnerBlocks, switchesOf, tablesToBlocks, tilde,
17} from './lib'
18import type { EditLine, El, OwnerBlock, Switches } from './lib'
19
20type $T = EngineInterface
21
22// The state atoms live here with literal refs: the validator's scan reads a
23// `{ plugin, key }` only when both are spelled out at the call.
24const viewAtom = atom({ plugin: 'gcc-mods', key: 'view' } as const, initialView)
25const docsAtom = atom({ plugin: 'gcc-mods', key: 'docs' } as const, [] as Doc[])
26const goalsAtom = atom({ plugin: 'gcc-mods', key: 'goals' } as const, null as GoalsSnap | null)
27const catchupAtom = atom({ plugin: 'gcc-mods', key: 'catchup' } as const, null as Catchup | null)
28const meterAtom = atom({ plugin: 'gcc-mods', key: 'meter' } as const, emptyMeter)
29const seatsAtom = atom({ plugin: 'gcc-mods', key: 'seats' } as const, [] as Seat[])
30const mailAtom = atom({ plugin: 'gcc-mods', key: 'mail' } as const, emptyMail)
31const nudgesAtom = atom({ plugin: 'gcc-mods', key: 'nudges' } as const, [] as Nudge[])
32const noticesAtom = atom({ plugin: 'gcc-mods', key: 'notices' } as const, [] as Notice[])
33const receiptAtom = atom({ plugin: 'gcc-mods', key: 'receipt' } as const, emptyReceipt)
34const idleAtom = atom({ plugin: 'gcc-mods', key: 'idle' } as const, emptyIdle)
35const decideAtom = atom({ plugin: 'gcc-mods', key: 'decide' } as const, emptyDecide)
36const funAtom = atom({ plugin: 'gcc-mods', key: 'fun' } as const, emptyFun)
37const previewAtom = atom({ plugin: 'gcc-mods', key: 'preview' } as const, null as Preview | null)
38// The per-session master switch. Off, every hook is a pass-through and the
39// session behaves as if the mod were not loaded; /hub turns it on.
40const enabledAtom = atom({ plugin: 'gcc-mods', key: 'enabled' } as const, null as boolean | null)
41const snipsAtom = atom({ plugin: 'gcc-mods', key: 'snips' } as const, { snippets: [], bookmarks: [], at: 0, sel: null, editing: null, scope: 'all' } as Snips)
42const PREVIEW = 'gcc-preview'
43const SNIP_DIR = GCC + '/snippets'
44
45// Snippets and bookmarks: one jsonl per scope under ~/.claude/snippets, files
46// being the truth so any session reads the same lists.
47function snipFile(scope: Scope, project: string, sid: string) {
48  if (scope === 'global') return SNIP_DIR + '/global.jsonl'
49  if (scope === 'project') return SNIP_DIR + '/project-' + project.replace(/[^\w.-]+/g, '_').replace(/^_/, '') + '.jsonl'
50  return SNIP_DIR + '/session-' + sid8(sid) + '.jsonl'
51}
52
53async function loadSnips($: $T) {
54  const cwd = await cwdOf($)
55  const sid = await $.session.id().catch(() => '')
56  const now = await $.clock.now()
57  const snippets: Snippet[] = []
58  const bookmarks: Bookmark[] = []
59  for (const scope of ['global', 'project', 'session'] as const) {
60    for (const row of parseJsonl((await readText($, snipFile(scope, cwd, sid))) ?? '')) {
61      if (row.kind === 'bookmark') bookmarks.push(row as Bookmark)
62      else snippets.push(row as Snippet)
63    }
64  }
65  await update($, snipsAtom, s => ({ ...s, snippets: snippets.sort((a, b) => b.ts - a.ts), bookmarks: bookmarks.sort((a, b) => b.ts - a.ts), at: now }))
66}
67
68type SnipRow = (Snippet | Bookmark) & { kind?: string }
69
70// Change one scope's file by re-reading it and applying a single change by id,
71// so rows other sessions added since this one loaded survive. A file that
72// exists but cannot be read throws rather than being rewritten empty.
73async function mutateSnips($: $T, scope: Scope, change: (rows: SnipRow[]) => SnipRow[]) {
74  const cwd = await cwdOf($)
75  const sid = await $.session.id().catch(() => '')
76  const file = snipFile(scope, cwd, sid)
77  const raw = await readText($, file)
78  if (raw === null && (await fileExists($, file))) throw new Error('could not read ' + file)
79  const rows = change(parseJsonl(raw ?? '') as SnipRow[])
80  const lines = rows.map(r => JSON.stringify('path' in r ? { ...r, kind: 'bookmark' } : { ...r, kind: 'snippet' }))
81  await $.fs.write(file, lines.join('\n') + (lines.length ? '\n' : ''))
82  await loadSnips($)
83}
84
85async function addSnippet($: $T, text: string, scope: Scope = 'project') {
86  const cwd = await cwdOf($)
87  const sid = await $.session.id().catch(() => '')
88  const now = await $.clock.now()
89  const snip: Snippet = { id: 's-' + now.toString(36), ts: now, scope, project: cwd, sid, title: clip(firstLine(text), 60), text, notes: '', tags: [] }
90  await mutateSnips($, scope, rows => [snip, ...rows])
91  await update($, snipsAtom, s => ({ ...s, sel: snip.id, editing: 'title' as const }))
92  return snip
93}
94
95// Edit or delete (patch null) one snippet. A scope change moves it between
96// files. Returns false when no snippet has that id.
97async function editSnippet($: $T, id: string, patch: Partial<Snippet> | null): Promise<boolean> {
98  await loadSnips($)
99  const have = (await read($, snipsAtom)).snippets.find(x => x.id === id)
100  if (!have) return false
101  const moved = patch?.scope && patch.scope !== have.scope ? patch.scope : null
102  await mutateSnips($, have.scope, rows => (patch && !moved ? rows.map(r => (r.id === id ? { ...r, ...patch } : r)) : rows.filter(r => r.id !== id)))
103  if (moved) await mutateSnips($, moved, rows => [{ ...have, ...patch }, ...rows])
104  return true
105}
106
107async function toggleBookmark($: $T, path: string, scope: Scope = 'project') {
108  const cwd = await cwdOf($)
109  const sid = await $.session.id().catch(() => '')
110  const now = await $.clock.now()
111  const have = (await read($, snipsAtom)).bookmarks.find(b => b.path === path)
112  if (have) await mutateSnips($, have.scope, rows => rows.filter(r => r.id !== have.id))
113  else await mutateSnips($, scope, rows => [{ id: 'b-' + now.toString(36), ts: now, scope, project: cwd, sid, path, note: '' }, ...rows])
114  $.ui.toast(have ? 'Bookmark removed.' : 'Bookmarked (' + scope + ').')
115}
116
117// Save one prompt-box edit line. False means nothing was saved (an unknown
118// snippet or decision, or goal.sh refused) and the caller puts the draft back.
119async function saveEditLine($: $T, edit: EditLine): Promise<boolean> {
120  if (edit.kind === 'snip') {
121    const found = await editSnippet($, edit.key, { notes: edit.body })
122    $.ui.toast(found ? 'Snippet note saved. Nothing was sent.' : 'No snippet ' + edit.key + ' here. Nothing was saved or sent; your text is back in the box.', { timeoutMs: 7_000 })
123    return found
124  }
125  if (edit.kind === 'dp') {
126    const [slug, item] = edit.key.split('/')
127    const page = (await read($, decideAtom)).pending.find(s => s.slug === slug)
128    if (!page || !page.items.some(it => it.id === item)) {
129      $.ui.toast('No decision ' + edit.key + ' here. Nothing was saved or sent; your text is back in the box.', { timeoutMs: 7_000 })
130      return false
131    }
132    await update($, decideAtom, x => ({ ...x, notes: { ...x.notes, [edit.key]: edit.body } }))
133    $.ui.toast('Note kept on ' + edit.key + '. Submit sends it with the rulings; nothing was sent now.')
134    return true
135  }
136  if (!edit.body) {
137    $.ui.toast('The goal line was empty. Nothing was saved or sent.')
138    return false
139  }
140  const r = await run($, ['bash', GOAL_SH, 'set', edit.body, '--by', 'owner'], { timeoutMs: 6_000 })
141  $.ui.toast(r.ok ? 'gcc goal saved. Nothing was sent. The /goal paste line (p in the Tasks tab) arms the harness.' : 'goal.sh refused: ' + clip(r.err || r.out, 120), { timeoutMs: 7_000 })
142  if (r.ok) await refreshGoals($)
143  return r.ok
144}
145
146// The last mouse selection, or nothing: fullscreen terminal and desktop only.
147async function selectedText($: $T): Promise<string | null> {
148  try {
149    const s = await $.ui.selection()
150    return s?.text?.trim() ? s.text : null
151  } catch {
152    return null
153  }
154}
155
156const SNIP_SKILLS: [string, string, string][] = [
157  ['pin', '/pin-for-dream', 'd'],
158  ['propose', '/gcc-proposal', 'g'],
159  ['atone', '/atone', 'a'],
160  ['affirm', '/affirm', 'f'],
161]
162
163// The startOn switch from /config, set when the module registers.
164let startOn = false
165const isOn = async ($: $T) => (await read($, enabledAtom)) ?? startOn
166
167// The work a session does when the mod comes on: what a start would have done.
168async function startUp($: $T, sw: Switches) {
169  if (sw.continuity) {
170    await seedDocs($)
171    await offerCheckpoint($)
172  }
173  await refreshAll($, sw)
174}
175
176async function switchMod($: $T, on: boolean, sw: Switches) {
177  const was = await isOn($)
178  await update($, enabledAtom, () => on)
179  if (on) {
180    if (!was) await startUp($, sw)
181    $.ui.toast('gcc-mods is on for this session. /hub off silences it.')
182    return
183  }
184  $.ui.status(undefined)
185  try {
186    for (const p of await $.ui.panes()) await $.ui.close({ id: p.id })
187  } catch {
188    /* nothing open */
189  }
190  $.ui.toast('gcc-mods is off for this session. Hooks behave as before; /hub on brings it back.', { timeoutMs: 7_000 })
191}
192
193const TABS: [Tab, string, string][] = [
194  ['docs', 'Docs', '1'],
195  ['tasks', 'Tasks', '2'],
196  ['nudges', 'Nudges', '3'],
197  ['fleet', 'Fleet', '4'],
198  ['inbox', 'Inbox', '5'],
199  ['decide', 'Decide', '6'],
200  ['snips', 'Snips', '7'],
201]
202const FILTERS: [DocFilter, string][] = [
203  ['session', 'this session'],
204  ['all', 'all recent'],
205  ['reports', 'reports'],
206  ['checkpoints', 'checkpoints'],
207]
208const SNOOZE: { value: string; label: string }[] = [
209  { value: '1h', label: '1 hour' },
210  { value: '4h', label: '4 hours' },
211  { value: '1d', label: '1 day' },
212  { value: '7d', label: '7 days' },
213]
214const FEEDBACK: [string, string, string][] = [
215  ['useful', 'u', 'useful'],
216  ['false-positive', 'x', 'wrong trigger'],
217  ['obstructive', 'b', 'in the way'],
218  ['too-aggressive', 'a', 'too loud'],
219]
220const WAKE_GAP_MS = 10 * 60_000
221// A prompt that starts with this is a goal edit: saved to gcc, never sent.
222const GOAL_PREFIX = 'gcc-goal: '
223// Likewise a decision note, `dp-note <slug>/<item>: text`, saved to the Decide tab.
224const DP_NOTE = 'dp-note '
225const OUTPUT_RE = /(?:~|\/)[\w.@+%\-\/]+\.(?:md|json|txt|html)\b/
226
227// Small wrappers around the engine that never throw into a render.
228
229const setView = ($: $T, patch: Partial<View>) => update($, viewAtom, v => ({ ...v, ...patch }))
230const getView = ($: $T) => read($, viewAtom)
231
232type Ran = { ok: boolean; out: string; err: string; code: number }
233
234// Run a gcc command and never throw: every surface draws "unavailable" rather
235// than failing its render when a script is missing or slow.
236async function run($: $T, argv: string[], opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> } = {}): Promise<Ran> {
237  try {
238    const sid = await $.session.id().catch(() => '')
239    const r = await $.process.run(argv, {
240      cwd: opts.cwd,
241      timeoutMs: opts.timeoutMs ?? 15_000,
242      env: { CLAUDE_CODE_SESSION_ID: sid, ...(opts.env ?? {}) },
243    })
244    return { ok: r.exitCode === 0, out: r.stdout ?? '', err: r.stderr ?? '', code: r.exitCode }
245  } catch (e) {
246    return { ok: false, out: '', err: String(e), code: -1 }
247  }
248}
249
250async function readText($: $T, path: string): Promise<string | null> {
251  try {
252    return await $.fs.read(path)
253  } catch {
254    return null
255  }
256}
257
258async function fileExists($: $T, path: string): Promise<boolean> {
259  try {
260    return await $.fs.exists(path)
261  } catch {
262    return false
263  }
264}
265
266async function listDir($: $T, dir: string) {
267  try {
268    return await $.fs.list(dir)
269  } catch {
270    return []
271  }
272}
273
274async function tailJsonl($: $T, path: string, n: number) {
275  const r = await run($, ['tail', '-n', String(n), path], { timeoutMs: 5_000 })
276  return r.ok ? parseJsonl(r.out) : []
277}
278
279const cwdOf = ($: $T) => $.session.cwd().catch(() => HOME)
280
281// Close one of the mod's panes if it is open; quiet when it is not.
282async function closePane($: $T, id: string) {
283  try {
284    if ((await $.ui.panes()).some(p => p.id === id)) await $.ui.close({ id })
285  } catch {
286    /* not open */
287  }
288}
289
290// Open the hub on a tab. A press or a command opened it, so it seats at any width.
291// The engine raises a newly opened pane only while the prompt holds the keys, so
292// a press inside the preview would leave the hub behind it: the preview closes first.
293async function openHub($: $T, tab?: Tab) {
294  if (tab) await setView($, { tab })
295  await closePane($, PREVIEW)
296  await $.ui.open({ id: PANE, title: await hubTitle($), focus: true })
297}
298
299// The hub's tab label carries the counts, so a closed-over hub still says what waits.
300async function hubTitle($: $T): Promise<string> {
301  const c = goalCounts(await read($, goalsAtom))
302  const mail = (await read($, mailAtom)).messages.filter(m => !m.isRead).length
303  const bits = ['gcc']
304  if (c.gates) bits.push(c.gates + ' owed')
305  if (mail) bits.push('✉' + mail)
306  return bits.join(' · ')
307}
308
309// The preview pane: the full text of whatever was last picked, so long text
310// never has to fit in a tab. It takes the hub's place rather than opening
311// behind it (a pane opened from a press in another pane stays at the back);
312// its "back to hub" button swaps them again.
313async function openPreview($: $T, title: string, text: string, path: string | null = null, kind: 'text' | 'goal' = 'text', goalId: string | null = null) {
314  const now = await $.clock.now()
315  await update($, previewAtom, () => ({ title, text, path, at: now, kind, goalId, editing: false }))
316  await closePane($, PREVIEW)
317  await closePane($, PANE)
318  await $.ui.open({ id: PREVIEW, title: (kind === 'goal' ? 'goal · ' : 'preview · ') + clip(title, 24), focus: true })
319}
320
321// Put text in the prompt box at the cursor, never over what the owner typed.
322// A slash command only runs from the start of the prompt, so with a draft
323// already there it goes to the clipboard instead. A line-prefixed edit
324// (gcc-goal:, snip-note, dp-note) starts on a line of its own.
325async function fillPrompt($: $T, text: string, how: 'text' | 'line' | 'command' = 'text'): Promise<boolean> {
326  const box = await $.prompt.read()
327  // A draft that is itself a slash command runs as that command on Enter even
328  // when a hook drops the prompt, so an edit line is kept out of it too.
329  if ((how === 'command' && box.text.trim()) || (how === 'line' && box.text.trimStart().startsWith('/'))) {
330    const r = await $.ui.copy({ text })
331    $.ui.toast(r.isCopied ? 'Your prompt has a draft, so this went to the clipboard. Paste it into an empty prompt.' : 'Your prompt has a draft; clear it and try again.', { timeoutMs: 7_000 })
332    return false
333  }
334  // An edit line sits on a line of its own, so text on either side of the
335  // cursor stays the owner's and is never read into the edit.
336  const atLineStart = box.cursor === 0 || box.text[box.cursor - 1] === '\n'
337  const atLineEnd = box.cursor >= box.text.length || box.text[box.cursor] === '\n'
338  const line = (atLineStart ? '' : '\n') + text + (atLineEnd ? '' : '\n')
339  await $.prompt.fill({ text: how === 'line' ? line : text, mode: 'insert' })
340  return true
341}
342
343// A prompt-box edit line was saved and dropped; whatever else was in the draft
344// comes back once the box has cleared, so nothing the owner typed is lost.
345async function restoreDraft($: $T, rest: string) {
346  if (!rest.trim()) return
347  await $.clock.sleep(300)
348  await $.prompt.fill({ text: rest.trim(), mode: 'insert' })
349}
350
351// The goal tab: the outcome, the session goal with an editor, the acceptance rows.
352async function openGoalTab($: $T, g: GoalView) {
353  const rows = g.accept.map(a => '- **' + a.id + '** ' + a.kind + ', ' + a.status + ': ' + a.text + (a.evidence ? '\n  evidence: ' + clip(a.evidence, 160) : '')).join('\n')
354  const text = (g.containment.length ? g.containment.map(c => '> ' + c).join('\n') + '\n\n' : '') + '**Acceptance rows** (what counts as done, each proven or still open)\n\n' + rows
355  await openPreview($, g.id, text, null, 'goal', g.id)
356}
357
358async function saveSessionGoal($: $T, text: string): Promise<boolean> {
359  const r = await run($, ['bash', GOAL_SH, 'set', text.trim(), '--by', 'owner'], { timeoutMs: 6_000 })
360  $.ui.toast(r.ok ? 'Session goal saved. Nothing was sent.' : 'goal.sh refused: ' + clip(r.err || r.out, 120), { timeoutMs: 6_000 })
361  if (r.ok) await refreshGoals($)
362  return r.ok
363}
364
365// The guide ships with the mod; /hub help and h in the hub open it in the preview pane.
366async function openGuide($: $T) {
367  const path = $.plugin.root + '/GUIDE.md'
368  const text = (await readText($, path)) ?? 'GUIDE.md is missing from the plugin folder.'
369  await openPreview($, 'gcc-mods guide', text, path)
370}
371
372function previewButton($: $T, el: El, key: string, title: string, text: string, path: string | null = null, hotkey = 'p') {
373  const { Button } = el
374  return <Button key={key} label="preview" plain {...hk(hotkey)} onPress={() => openPreview($, title, text, path)} />
375}
376
377// Section frame shared by the tabs: a titled rounded box.
378function section(el: El, title: string, children: unknown, color?: string) {
379  const { Box, Text } = el
380  return (
381    <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginBottom={1}>
382      <Text bold>{title}</Text>
383      {children}
384    </Box>
385  )
386}
387
388// The row of single-letter buttons at the top of a tab or a detail frame.
389function toolbar(el: El, children: unknown) {
390  const { Box } = el
391  return (
392    <Box flexDirection="row" columnGap={3} flexWrap="wrap">
393      {children}
394    </Box>
395  )
396}
397
398// One column of a list: a fixed width, or the rest of the row when width is left out.
399// `press` marks the cell drawn as the row's button, the click target; without
400// one the first cell is. A Button takes no colour, so the pressed cell is plain.
401type Cell = { text: string; width?: number; color?: string; bold?: boolean; dim?: boolean; press?: true }
402
403// The dim header row over a list; the two leading spaces sit over the selection marker.
404function listHead(el: El, cells: { text: string; width?: number }[]) {
405  const { Box, Text } = el
406  return (
407    <Box flexDirection="row" columnGap={1}>
408      <Box width={2} flexShrink={0}>
409        <Text> </Text>
410      </Box>
411      {cells.map(c =>
412        c.width ? (
413          <Box width={c.width} flexShrink={0}>
414            <Text dimColor>{c.text}</Text>
415          </Box>
416        ) : (
417          <Text dimColor wrap="truncate-end">{c.text}</Text>
418        ),
419      )}
420    </Box>
421  )
422}
423
424// One row of a list. The marker has a column of its own so a narrow first
425// column is never clipped by it; one cell is the button that selects the row.
426function listRow(el: El, key: string, isSel: boolean, onPress: () => unknown, cells: Cell[]) {
427  const { Box, Text, Button } = el
428  const pressAt = Math.max(0, cells.findIndex(c => c.press))
429  const draw = (c: Cell, i: number) => {
430    const inner =
431      i === pressAt ? (
432        <Button key={key} label={c.text} plain dimColor={!isSel} onPress={onPress} />
433      ) : (
434        <Text color={c.color} bold={c.bold} dimColor={c.dim ?? (!isSel && !c.color)} wrap="truncate-end">{c.text}</Text>
435      )
436    return c.width ? (
437      <Box width={c.width} flexShrink={0}>
438        {inner}
439      </Box>
440    ) : (
441      inner
442    )
443  }
444  return (
445    <Box flexDirection="row" columnGap={1}>
446      <Box width={2} flexShrink={0}>
447        <Text color="cyan">{isSel ? '▸' : ' '}</Text>
448      </Box>
449      {cells.map(draw)}
450    </Box>
451  )
452}
453
454// The selected item: a cyan frame, its actions above its text.
455function detailFrame(el: El, title: string, actions: unknown, children: unknown, color = 'cyan') {
456  const { Box, Text } = el
457  return (
458    <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginBottom={1}>
459      <Text bold wrap="truncate-end">{title}</Text>
460      {actions && <Box marginBottom={1}>{toolbar(el, actions)}</Box>}
461      {children}
462    </Box>
463  )
464}
465
466// The j and k buttons: move the selection through a list of ids, wrapping.
467function moveButtons($: $T, el: El, ids: string[], current: string | null, select: (id: string) => unknown) {
468  const { Button } = el
469  const step = (by: number) => {
470    if (!ids.length) return
471    const i = Math.max(0, ids.indexOf(current ?? ''))
472    return select(ids[(i + by + ids.length) % ids.length]!)
473  }
474  return [
475    <Button key="next" label="next" plain {...hk('j')} onPress={() => step(1)} />,
476    <Button key="prev" label="prev" plain {...hk('k')} onPress={() => step(-1)} />,
477  ]
478}
479
480// Transcript hygiene: paths drawn absolute and clickable.
481
482const existsCache = new Map<string, boolean>()
483
484async function resolvePath($: $T, token: string, cwd: string): Promise<string | null> {
485  if (token.startsWith('~')) return HOME + token.slice(1)
486  if (token.startsWith('/')) return token
487  const joined = cwd.replace(/\/$/, '') + '/' + token.replace(/^\.\//, '')
488  if (token.startsWith('..')) return joined
489  const hit = existsCache.get(joined)
490  if (hit !== undefined) return hit ? joined : null
491  const ok = await fileExists($, joined)
492  existsCache.set(joined, ok)
493  return ok ? joined : null
494}
495
496async function linkify($: $T, text: string, cwd: string): Promise<string> {
497  const out: string[] = []
498  let last = 0
499  PATH_RE.lastIndex = 0
500  for (let m = PATH_RE.exec(text); m; m = PATH_RE.exec(text)) {
501    const lead = m[1] ?? ''
502    const raw = m[2] ?? ''
503    const start = m.index + lead.length
504    const before = text.slice(Math.max(0, start - 2), start)
505    const trail = raw.match(TRAIL_RE)?.[0] ?? ''
506    const token = trail ? raw.slice(0, -trail.length) : raw
507    if (before === '](' || token.length < 3 || !token.includes('/')) continue
508    const full = await resolvePath($, token, cwd)
509    if (!full) continue
510    out.push(text.slice(last, start))
511    out.push('[' + tilde(full) + '](file://' + encodeURI(full) + ')' + trail)
512    last = start + raw.length
513  }
514  if (last === 0) return text
515  out.push(text.slice(last))
516  return out.join('')
517}
518
519// Fenced code stays as written; inline code that is one bare path becomes a
520// link; everything else is linkified in place.
521async function rewritePaths($: $T, text: string, cwd: string): Promise<string> {
522  const parts = text.split(/(```[\s\S]*?```)/)
523  const done: string[] = []
524  for (const part of parts) {
525    if (part.startsWith('```')) {
526      done.push(part)
527      continue
528    }
529    const spans = part.split(/(`[^`\n]+`)/)
530    const built: string[] = []
531    for (const span of spans) {
532      if (span.startsWith('`') && span.endsWith('`') && span.length > 2) {
533        const inner = span.slice(1, -1)
534        const bare = inner.replace(TRAIL_RE, '')
535        if (/^(~|\.{0,2}\/|[\w-]+\/)/.test(bare) && !/\s/.test(bare) && bare.includes('/')) {
536          const full = await resolvePath($, bare, cwd)
537          built.push(full ? '[' + tilde(full) + '](file://' + encodeURI(full) + ')' + inner.slice(bare.length) : span)
538        } else built.push(span)
539        continue
540      }
541      built.push(await linkify($, span, cwd))
542    }
543    done.push(built.join(''))
544  }
545  return done.join('')
546}
547
548// Continuity: the checkpoint offer and the docs this session touched.
549
550type IndexRow = { ts?: string; name?: string; checkpoint_path?: string; project_root?: string; summary?: string; kind?: string }
551
552// The checkpoint the band offers: resolve.sh's pick when it has one, else the
553// newest index row for this project, else the newest _*.claude.md on disk.
554async function findCheckpoint($: $T, cwd: string, now: number): Promise<Catchup | null> {
555  const r = await run($, ['bash', GCC + '/scripts/checkpoint/resolve.sh', '--auto', '--cwd', cwd], { cwd, timeoutMs: 8_000 })
556  if (r.ok) {
557    const j = jsonOr<IndexRow>(r.out.trim().split('\n').find(l => l.startsWith('{')) ?? null, {})
558    if (j.checkpoint_path && (await fileExists($, j.checkpoint_path))) {
559      return { name: j.name ?? 'checkpoint', path: j.checkpoint_path, at: j.ts ? Date.parse(j.ts) : now, summary: j.summary ?? '', source: 'index', state: 'offered' }
560    }
561  }
562  let best: Catchup | null = null
563  const idx = await readText($, GCC + '/checkpoints/index.jsonl')
564  if (idx) {
565    for (const line of idx.trim().split('\n').slice(-200)) {
566      const j = jsonOr<IndexRow>(line, {})
567      if (!j.checkpoint_path || j.project_root !== cwd || j.kind === 'session-end') continue
568      const at = j.ts ? Date.parse(j.ts) : 0
569      if (!best || at > best.at) best = { name: j.name ?? 'checkpoint', path: j.checkpoint_path, at, summary: j.summary ?? '', source: 'index', state: 'offered' }
570    }
571    if (best && !(await fileExists($, best.path))) best = null
572  }
573  // A precompact file is a shell snapshot any session's exit rewrites; it is
574  // offered only when no real checkpoint exists.
575  let snapshot: Catchup | null = null
576  for (const f of await listDir($, cwd)) {
577    if (!/^_.*\.claude\.md$/.test(f.name) || f.kind !== 'file') continue
578    const found: Catchup = { name: f.name.replace(/\.claude\.md$/, ''), path: cwd + '/' + f.name, at: f.mtimeMs, summary: '', source: 'disk', state: 'offered' }
579    if (f.name.startsWith('_precompact')) {
580      if (!snapshot || f.mtimeMs > snapshot.at) snapshot = found
581    } else if (!best || f.mtimeMs > best.at) best = found
582  }
583  best = best ?? snapshot
584  if (best && now - best.at > 7 * 86_400_000) return null
585  return best
586}
587
588// `force` re-offers after a clear or resume; a plain load (a hot reload re-runs
589// session.start) keeps whatever the owner already did with the offer.
590async function offerCheckpoint($: $T, force = false) {
591  const have = await read($, catchupAtom)
592  if (have && !force) return
593  const cwd = await cwdOf($)
594  const now = await $.clock.now()
595  const found = await findCheckpoint($, cwd, now)
596  await update($, catchupAtom, () => found)
597}
598
599async function captureDoc($: $T, filePath: string) {
600  if (!/\.(md|html?|txt|pdf|rst)$/i.test(filePath)) return
601  const now = await $.clock.now()
602  const full = abs(filePath)
603  await update($, docsAtom, docs => {
604    const hit = docs.find(d => d.path === full)
605    if (hit) return docs.map(d => (d === hit ? { ...d, edits: d.edits + 1, at: now, isLive: true, isThisSession: true } : d))
606    const doc: Doc = { id: 'live-' + now.toString(36) + '-' + docs.length, title: full.split('/').pop() ?? full, path: full, kind: docKindOf(full), isThisSession: true, edits: 1, at: now, isLive: true }
607    return [doc, ...docs].slice(0, 200)
608  })
609}
610
611async function seedDocs($: $T) {
612  const cwd = await cwdOf($)
613  const sid = await $.session.id().catch(() => '')
614  const now = await $.clock.now()
615  const found: Doc[] = []
616  const push = (path: string, at: number, isThisSession: boolean) =>
617    found.push({ id: 'seed-' + path, title: path.split('/').pop() ?? path, path, kind: docKindOf(path), isThisSession, edits: 0, at, isLive: false })
618  for (const f of await listDir($, cwd)) if (/^_.*\.claude\.md$/.test(f.name)) push(cwd + '/' + f.name, f.mtimeMs, false)
619  const notesDir = cwd === GCC ? GCC + '/session-notes' : cwd + '/.claude/session-notes'
620  for (const f of await listDir($, notesDir)) {
621    if (!f.name.endsWith('.md') || f.name.startsWith('_')) continue
622    push(notesDir + '/' + f.name, f.mtimeMs, sid !== '' && f.name.startsWith(sid))
623  }
624  const reports = (await listDir($, GCC + '/assets/reports')).filter(d => d.kind === 'dir').sort((a, b) => b.name.localeCompare(a.name)).slice(0, 8)
625  for (const d of reports) {
626    const dir = GCC + '/assets/reports/' + d.name
627    for (const f of await listDir($, dir)) if (/\.(md|html)$/.test(f.name) && now - f.mtimeMs < 14 * 86_400_000) push(dir + '/' + f.name, f.mtimeMs, false)
628  }
629  await update($, docsAtom, docs => {
630    const have = new Set(docs.map(d => d.path))
631    return [...docs, ...found.filter(d => !have.has(d.path))].sort((a, b) => b.at - a.at).slice(0, 200)
632  })
633}
634
635const previewCache = new Map<string, { at: number; text: string }>()
636async function previewOf($: $T, doc: Doc): Promise<string> {
637  const hit = previewCache.get(doc.path)
638  if (hit && hit.at === doc.at) return hit.text
639  const text = (await readText($, doc.path)) ?? '(could not read the file)'
640  const trimmed = text.length > 12_000 ? text.slice(0, 12_000) + '\n\n… (preview cut; open the file for the rest)' : text
641  previewCache.set(doc.path, { at: doc.at, text: trimmed })
642  return trimmed
643}
644
645function visibleDocs(docs: Doc[], v: View): Doc[] {
646  return docs
647    .filter(doc => !v.hidden.includes(doc.id))
648    .filter(doc =>
649      v.docFilter === 'session'
650        ? doc.isThisSession
651        : v.docFilter === 'reports'
652          ? doc.kind === 'report' || doc.kind === 'review' || doc.kind === 'page'
653          : v.docFilter === 'checkpoints'
654            ? doc.kind === 'checkpoint'
655            : true,
656    )
657    .sort((a, b) => b.at - a.at)
658}
659
660async function docsTab($: $T, el: El, v: View, now: number) {
661  const { Box, Text, Button, Markdown } = el
662  const docs = await read($, docsAtom)
663  const list = visibleDocs(docs, v)
664  const selId = list.some(x => x.id === v.docSel) ? v.docSel : (list[0]?.id ?? null)
665  const sel = list.find(x => x.id === selId)
666  const idx = list.findIndex(x => x.id === selId)
667  const filterLabel = FILTERS.find(f => f[0] === v.docFilter)![1]
668  const move = (by: number) => {
669    if (!list.length) return
670    const i = (idx + by + list.length) % list.length
671    return setView($, { docSel: list[i]!.id })
672  }
673  const cycleFilter = () => {
674    const i = FILTERS.findIndex(f => f[0] === v.docFilter)
675    return setView($, { docFilter: FILTERS[(i + 1) % FILTERS.length]![0], docSel: null })
676  }
677  const shown = list.slice(0, 10)
678  const preview = sel ? await previewOf($, sel) : ''
679  const titleW = 36
680
681  return (
682    <Box flexDirection="column">
683      <Box flexDirection="row" columnGap={3}>
684        <Button key="filter" label={'filter: ' + filterLabel} plain {...hk('f')} onPress={cycleFilter} />
685        <Button key="next" label="next" plain {...hk('j')} onPress={() => move(1)} />
686        <Button key="prev" label="prev" plain {...hk('k')} onPress={() => move(-1)} />
687        <Button key="rescan" label="rescan" plain {...hk('r')} onPress={() => seedDocs($)} />
688        {v.hidden.length > 0 && <Button key="unhide" label={'unhide ' + v.hidden.length} plain {...hk('u')} onPress={() => setView($, { hidden: [] })} />}
689      </Box>
690      {section(
691        el,
692        list.length ? plural(list.length, 'doc') + ' · ' + filterLabel : 'no docs under this filter',
693        <Box flexDirection="column">
694          {list.length === 0 && (
695            <Text dimColor>
696              {docs.length ? plural(docs.length, 'doc') + ' under other filters. f widens it.' : 'Nothing found yet. r rescans.'}
697            </Text>
698          )}
699          {list.length > 0 && (
700            <Box flexDirection="row" columnGap={1}>
701              <Box width={titleW} flexShrink={0}>
702                <Text dimColor>  title</Text>
703              </Box>
704              <Box width={11} flexShrink={0}>
705                <Text dimColor>kind</Text>
706              </Box>
707              <Box width={5} flexShrink={0}>
708                <Text dimColor>age</Text>
709              </Box>
710              <Text dimColor>edits</Text>
711            </Box>
712          )}
713          {shown.map(doc => {
714            const isSel = doc.id === selId
715            return (
716              <Box flexDirection="row" columnGap={1}>
717                <Box width={titleW} flexShrink={0}>
718                  <Button key={'doc-' + doc.id} label={(isSel ? '▸ ' : '  ') + clip(doc.title, titleW - 2)} plain dimColor={!isSel} onPress={() => setView($, { docSel: doc.id })} />
719                </Box>
720                <Box width={11} flexShrink={0}>
721                  <Text dimColor>{doc.kind}</Text>
722                </Box>
723                <Box width={5} flexShrink={0}>
724                  <Text dimColor>{ago(now, doc.at)}</Text>
725                </Box>
726                <Text dimColor>{doc.edits ? String(doc.edits) : '·'}</Text>
727                {doc.isLive && <Text color="green"> live</Text>}
728              </Box>
729            )
730          })}
731          {list.length > shown.length && <Text dimColor>  +{list.length - shown.length} more (j/k reaches them)</Text>}
732        </Box>,
733      )}
734      {sel && (
735        <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
736          <Text bold>{sel.title}</Text>
737          <Text dimColor wrap="truncate-start">{tilde(sel.path)}</Text>
738          <Box flexDirection="row" columnGap={3} flexWrap="wrap" marginTop={1}>
739            {previewButton($, el, 'doc-preview', sel.title, preview, sel.path)}
740            <Button
741              key="open"
742              label="open"
743              plain
744              {...hk('o')}
745              onPress={async () => {
746                const r = await run($, ['open', sel.path], { timeoutMs: 5_000 })
747                $.ui.toast(r.ok ? 'Opened ' + sel.title : 'Could not open: ' + clip(r.err, 80))
748              }}
749            />
750            <Button
751              key="copy"
752              label="copy path"
753              plain
754              {...hk('c')}
755              onPress={async (press: UiPressArgument) => {
756                const r = await $.ui.copy({ text: sel.path, surface: press.surface })
757                $.ui.toast(r.isCopied ? 'Copied ' + sel.path : 'Could not copy: ' + r.reason)
758              }}
759            />
760            <Button
761              key="quote"
762              label="attach to prompt"
763              plain
764              {...hk('q')}
765              onPress={async () => {
766                await $.prompt.fill({ text: '@' + sel.path + ' ', mode: 'insert' })
767                $.ui.toast('Added @' + tilde(sel.path) + ' to your prompt. Esc to go type.')
768              }}
769            />
770            <Button key="full" label={v.docFull ? 'shorter' : 'expand here'} plain {...hk('e')} onPress={() => setView($, { docFull: !v.docFull })} />
771            <Button key="bookmark" label={(await read($, snipsAtom)).bookmarks.some(b => b.path === sel.path) ? 'unbookmark' : 'bookmark'} plain {...hk('b')} onPress={() => toggleBookmark($, sel.path)} />
772            <Button key="hide" label="hide" plain {...hk('x')} onPress={() => setView($, { hidden: [...v.hidden, sel.id], docSel: null })} />
773          </Box>
774          <Text> </Text>
775          <Markdown text={v.docFull ? preview : clip(head(preview, 9), 900)} />
776          {!v.docFull && (preview.split('\n').length > 9 || preview.length > 900) && <Text dimColor>… p shows the whole file</Text>}
777        </Box>
778      )}
779    </Box>
780  )
781}
782
783function catchupRow($: $T, el: El, c: Catchup, now: number) {
784  const { Box, Text, Button } = el
785  return (
786    <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
787      <Box flexDirection="row" columnGap={2}>
788        <Text bold>↺ resume from a checkpoint?</Text>
789        <Text dimColor>
790          {c.name} · written {agoPhrase(now, c.at)} · {c.source === 'disk' ? 'found in this folder' : 'from the checkpoint index'}
791        </Text>
792      </Box>
793      <Text dimColor wrap="truncate-end">{c.summary ? clip(c.summary, 110) : 'a previous session left this; load runs /catchup, which restores its goal, pending items and caveats'}</Text>
794      <Box flexDirection="row" columnGap={2}>
795      <Button
796        key="cu-load"
797        label="load (/catchup)"
798        variant="primary"
799        {...hk('l')}
800        onPress={async () => {
801          await update($, catchupAtom, x => (x ? { ...x, state: 'loaded' as const } : x))
802          $.ui.toast('Running /catchup on ' + c.name + '.')
803          await $.prompt.submit({ text: '/catchup ' + c.path })
804        }}
805      />
806      <Button
807        key="cu-view"
808        label="view"
809        {...hk('v')}
810        onPress={async () => {
811          await captureDoc($, c.path)
812          const docs = await read($, docsAtom)
813          const doc = docs.find(d => d.path === c.path)
814          await setView($, { tab: 'docs', docFilter: 'all', docSel: doc?.id ?? null })
815          await openHub($)
816        }}
817      />
818      <Button key="cu-skip" label="skip" {...hk('s')} onPress={() => update($, catchupAtom, x => (x ? { ...x, state: 'skipped' as const } : x))} />
819      </Box>
820    </Box>
821  )
822}
823
824// Tasks: the goal record, the reads owed by the owner, the rows left.
825
826type RawGoal = {
827  id: string
828  outcome: string
829  projects?: string[]
830  accept?: { id: string; kind: string; text: string; evidence: string | null; status: string; source: string | null }[]
831  milestones?: { id: string; name: string; status: string }[]
832  tasks?: Record<string, any>[]
833  containment?: string[]
834  cwd?: string | null
835}
836
837// A record field as drawable text: strings and numbers pass, an object gives its
838// `text` or `name`, anything else is absent. Never an object into a Text.
839function textOrNull(v: unknown): string | null {
840  if (v == null) return null
841  if (typeof v === 'string' || typeof v === 'number') return String(v)
842  const o = v as { text?: unknown; name?: unknown }
843  return typeof o.text === 'string' ? o.text : typeof o.name === 'string' ? o.name : null
844}
845
846function toGoal(g: RawGoal): GoalView {
847  return {
848    id: g.id,
849    outcome: g.outcome,
850    projects: g.projects ?? [],
851    accept: (g.accept ?? []).map(a => ({ id: a.id, kind: a.kind, text: a.text, evidence: a.evidence ?? null, status: a.status, source: a.source ?? null })),
852    milestones: (g.milestones ?? []).map(m => ({ id: m.id, name: m.name, status: m.status })),
853    tasks: (g.tasks ?? []).map(
854      (t): TaskRow => ({
855        id: String(t.id),
856        subject: String(t.subject ?? ''),
857        milestone: textOrNull(t.milestone),
858        state: String(t.state ?? ''),
859        // gs writes a gate as a string or as { text, do }; the pane draws text only,
860        // and an object reaching a Text makes the engine drop the whole pane.
861        gate: t.gate == null ? null : typeof t.gate === 'string' ? t.gate : String(t.gate.text ?? ''),
862        blockedBy: Array.isArray(t.blocked_by) ? t.blocked_by.map(String) : [],
863        lane: textOrNull(t.lane),
864        tier: textOrNull(t.tier),
865        note: textOrNull(t.note),
866      }),
867    ),
868    containment: g.containment ?? [],
869    cwd: g.cwd ?? null,
870  }
871}
872
873// Re-read the goal record through the path view. Called on start, on every
874// main-loop stop, after a gs write, and on a slow timer.
875async function refreshGoals($: $T) {
876  const cwd = await cwdOf($)
877  const now = await $.clock.now()
878  const r = await run($, ['python3', VIEWS + '/path.py', '--json'], { cwd, timeoutMs: 12_000 })
879  // The session goal lives in goal.sh's own file, not on the record.
880  const gr = await run($, ['bash', GOAL_SH, 'show', '--json'], { cwd, timeoutMs: 6_000 })
881  const sessionGoal = String(jsonOr<{ gcc?: { text?: string } }>(gr.out, {}).gcc?.text ?? '')
882  if (!r.ok) {
883    await update($, goalsAtom, prev => ({ scope: prev?.scope ?? '', armed: prev?.armed ?? '', sessionGoal, goals: prev?.goals ?? [], at: now, error: clip(r.err || r.out || 'path.py failed', 160) }))
884    return
885  }
886  const j = jsonOr<{ scope?: string; armed?: string; goals?: RawGoal[] }>(r.out, {})
887  const goals = (j.goals ?? []).map(toGoal)
888  const snap: GoalsSnap = { scope: j.scope ?? '', armed: j.armed ?? '', sessionGoal, goals, at: now, error: null }
889  await update($, goalsAtom, () => snap)
890  await update($, idleAtom, i => ({ ...i, openRows: goalCounts(snap).ready }))
891  const met = goals.filter(g => g.accept.length > 0 && g.accept.every(a => a.status === 'proven'))
892  const fun = await read($, funAtom)
893  const fresh = met.filter(g => !fun.metGoals.includes(g.id))
894  if (fresh.length) {
895    await update($, funAtom, f => ({ ...f, metGoals: [...f.metGoals, ...fresh.map(g => g.id)] }))
896    // The first good read seeds what was already met; only later ones are news.
897    if (fun.goalsSeeded) for (const g of fresh) $.ui.toast('🎯 every acceptance row is proven: ' + clip(g.outcome, 70), { timeoutMs: 8_000 })
898  }
899  if (!fun.goalsSeeded) await update($, funAtom, f => ({ ...f, goalsSeeded: true }))
900}
901
902// Row states as short words that fit the 9-wide column.
903const STATE_LABEL: Record<string, string> = { 'owner-gate': 'yours', deferred: 'later', delegated: 'handed' }
904
905async function tasksTab($: $T, el: El, v: View, write: boolean) {
906  const { Box, Text, Button, Input } = el
907  const snap = await read($, goalsAtom)
908  const goals = snap?.goals ?? []
909  if (!snap) return <Text dimColor>Reading the goal record…</Text>
910  if (goals.length === 0) {
911    return (
912      <Box flexDirection="column">
913        <Text dimColor>{snap.scope || 'no live goal here'}</Text>
914        {snap.error && <Text color="red" wrap="wrap">{snap.error}</Text>}
915        <Text wrap="wrap">No live goal touches this directory. The agent files one with: gs new "outcome" --accept "kind: what you will check".</Text>
916        <Button key="refresh" label="refresh" plain {...hk('r')} onPress={() => refreshGoals($)} />
917      </Box>
918    )
919  }
920  const gi = ((v.goalSel % goals.length) + goals.length) % goals.length
921  const goal = goals[gi]!
922  const gates = openGates(goal)
923  const gated = gatedRows(goal)
924  const ready = readyRows(goal)
925  const current = gates[0] ?? null
926  const glyph: Record<string, string> = { done: '✓', active: '◐', doing: '◐', review: '◇', todo: '○', ready: '○', open: '○', deferred: '…', dropped: '⊘', blocked: '⊘', 'owner-gate': '◆' }
927  const color: Record<string, string | undefined> = { done: 'green', active: 'yellow', doing: 'yellow', review: 'cyan', dropped: 'red', blocked: 'red' }
928
929  const prove = async (aid: string) => {
930    if (!write) return $.ui.toast('Tasks pane is read-only (tasksWrite is off in /config).')
931    const r = await run($, ['python3', GS, 'prove', goal.id, aid, '--by', 'owner pressed answers in the gcc pane'], { timeoutMs: 10_000 })
932    $.ui.toast(r.ok ? 'Recorded: ' + aid + ' answers.' : 'gs refused: ' + clip(r.err || r.out, 120))
933    await refreshGoals($)
934  }
935  const callout = async (aid: string, words: string) => {
936    if (!write) return $.ui.toast('Tasks pane is read-only (tasksWrite is off in /config).')
937    const r = await run($, ['python3', GS, 'accept', goal.id, 'reviewed: ' + words + ' (on ' + aid + ')'], { timeoutMs: 10_000 })
938    $.ui.toast(r.ok ? 'Your words are now an acceptance row on this goal.' : 'gs refused: ' + clip(r.err || r.out, 120))
939    await refreshGoals($)
940  }
941
942  const milestones = [...new Set(goal.tasks.map(t => t.milestone ?? 'no milestone'))]
943  const mName = (id: string) => goal.milestones.find(m => m.id === id)?.name ?? id
944  const goalText = snap.sessionGoal || goal.outcome
945  const doneCount = goal.tasks.filter(t => t.state === 'done').length
946
947  const goalRecord = (
948    <Box flexDirection="column">
949      <Text wrap="wrap">{goal.outcome}</Text>
950      <Text dimColor wrap="truncate-end">
951        {goal.accept.filter(a => a.status === 'proven').length}/{goal.accept.length} acceptance proven · {goal.tasks.filter(t => t.state !== 'done').length} rows open · {snap.scope.replace(/^scope:\s*/, '')}
952      </Text>
953      {snap.sessionGoal && snap.sessionGoal.trim() !== goal.outcome.trim() && (
954        <Box flexDirection="row" columnGap={1}>
955          <Box width={16} flexShrink={0}>
956            <Text dimColor>🎯 session goal</Text>
957          </Box>
958          <Text wrap="truncate-end">{snap.sessionGoal}</Text>
959        </Box>
960      )}
961      <Box flexDirection="row" columnGap={3} marginTop={1} flexWrap="wrap">
962        <Button key="goal-edit-btn" label="edit goal" plain {...hk('e')} onPress={() => openGoalTab($, goal)} />
963        <Button
964          key="goal-paste"
965          label="paste /goal line"
966          plain
967          {...hk('p')}
968          onPress={async () => {
969            const r = await run($, ['bash', GOAL_SH, 'armline'], { timeoutMs: 5_000 })
970            const line = r.ok ? r.out.trim() : '/goal ' + goalText
971            if (await fillPrompt($, line, 'command')) $.ui.toast('The /goal line is in your prompt; Enter arms it.')
972          }}
973        />
974        <Button
975          key="goal-clear"
976          label="retire gcc goal"
977          plain
978          {...hk('w')}
979          onPress={async () => {
980            const r = await run($, ['bash', GOAL_SH, 'clear'], { timeoutMs: 5_000 })
981            $.ui.toast(r.ok ? 'gcc goal retired for this session. The harness /goal, if armed, is yours to clear.' : clip(r.err || r.out, 100))
982            await refreshGoals($)
983          }}
984        />
985        {goals.length > 1 && <Button key="goal" label={'next goal ' + (gi + 1) + '/' + goals.length} plain {...hk('g')} onPress={() => setView($, { goalSel: gi + 1, rejecting: null })} />}
986        <Button key="refresh" label="refresh" plain {...hk('r')} onPress={() => refreshGoals($)} />
987        <Button key="goal-preview" label="open goal tab" plain {...hk('v')} onPress={() => openGoalTab($, goal)} />
988      </Box>
989    </Box>
990  )
991
992  const waiting = (
993    <Box flexDirection="column">
994      {gates.length + gated.length === 0 && <Text dimColor>Nothing waits on you.</Text>}
995      {gates.map(a => {
996        const isCurrent = a === current
997        return (
998          <Box flexDirection="column" marginBottom={1}>
999            <Box flexDirection="row" columnGap={1}>
1000              <Box width={3} flexShrink={0}>
1001                <Text bold color={isCurrent ? 'cyan' : undefined}>{isCurrent ? '▸' : '·'}</Text>
1002              </Box>
1003              <Box width={4} flexShrink={0}>
1004                <Text dimColor>{a.id}</Text>
1005              </Box>
1006              <Box width={9} flexShrink={0}>
1007                <Text dimColor>{a.kind}</Text>
1008              </Box>
1009              <Text bold={isCurrent} wrap="wrap">{a.text}</Text>
1010            </Box>
1011            {a.evidence && (
1012              <Box paddingLeft={17}>
1013                <Text dimColor wrap="wrap">evidence: {clip(a.evidence, 160)}</Text>
1014              </Box>
1015            )}
1016            {v.rejecting !== a.id && (
1017              <Box flexDirection="row" columnGap={3} paddingLeft={17}>
1018                <Button key={'yes-' + a.id} label="answers" plain {...hk('y', isCurrent)} onPress={() => prove(a.id)} />
1019                <Button key={'no-' + a.id} label="doesn't, say why" plain {...hk('n', isCurrent)} onPress={() => setView($, { rejecting: a.id })} />
1020              </Box>
1021            )}
1022            {v.rejecting === a.id && (
1023              <Box paddingLeft={17}>
1024                <Input
1025                  key={'why-' + a.id}
1026                  label="What is wrong"
1027                  placeholder="your words become an acceptance row; empty Enter cancels"
1028                  value=""
1029                  submitLabel="record"
1030                  autoFocus
1031                  onSubmit={async (text: string) => {
1032                    await setView($, { rejecting: null })
1033                    if (text.trim()) await callout(a.id, text.trim())
1034                  }}
1035                />
1036              </Box>
1037            )}
1038          </Box>
1039        )
1040      })}
1041      {gated.map(t => (
1042        <Box flexDirection="row" columnGap={1}>
1043          <Box width={3} flexShrink={0}>
1044            <Text color="yellow">⊘</Text>
1045          </Box>
1046          <Box width={4} flexShrink={0}>
1047            <Text dimColor>#{t.id}</Text>
1048          </Box>
1049          <Box width={40} flexShrink={0}>
1050            <Text wrap="truncate-end">{clip(firstLine(t.subject), 38)}</Text>
1051          </Box>
1052          <Text dimColor wrap="truncate-end">{(t.gate ?? '').replace(/^USER:\s*/, '')}</Text>
1053        </Box>
1054      ))}
1055    </Box>
1056  )
1057
1058  const rowsBody = (
1059    <Box flexDirection="column">
1060      <Box flexDirection="row" columnGap={1}>
1061        <Box width={3} flexShrink={0}>
1062          <Text dimColor> </Text>
1063        </Box>
1064        <Box width={4} flexShrink={0}>
1065          <Text dimColor>id</Text>
1066        </Box>
1067        <Box width={9} flexShrink={0}>
1068          <Text dimColor>state</Text>
1069        </Box>
1070        <Text dimColor>subject</Text>
1071      </Box>
1072      {milestones.map(m => {
1073        const rows = goal.tasks.filter(t => (t.milestone ?? 'no milestone') === m)
1074        const done = rows.filter(r => r.state === 'done')
1075        const rest = v.showDone ? rows : rows.filter(r => r.state !== 'done')
1076        if (!rest.length && !done.length) return null
1077        return (
1078          <Box flexDirection="column" marginTop={1}>
1079            <Text dimColor wrap="truncate-end">
1080              {'─ ' + done.length + '/' + rows.length + ' done · '}
1081              {mName(m)}
1082            </Text>
1083            {rest.map(t => (
1084              <Box flexDirection="row" columnGap={1}>
1085                <Box width={3} flexShrink={0}>
1086                  <Text color={color[t.state]}>{glyph[t.state] ?? '○'}</Text>
1087                </Box>
1088                <Box width={4} flexShrink={0}>
1089                  <Text dimColor>#{t.id}</Text>
1090                </Box>
1091                <Box width={9} flexShrink={0}>
1092                  <Text dimColor={t.state === 'done'} wrap="truncate-end">{STATE_LABEL[t.state] ?? t.state}</Text>
1093                </Box>
1094                <Text dimColor={t.state === 'done'} wrap="truncate-end">{firstLine(t.subject)}</Text>
1095                {t.blockedBy.length > 0 && <Text dimColor> after {t.blockedBy.join(',')}</Text>}
1096              </Box>
1097            ))}
1098          </Box>
1099        )
1100      })}
1101      <Box marginTop={1}>
1102        <Button key="done" label={v.showDone ? 'fold done rows' : 'show done rows'} plain {...hk('d')} onPress={() => setView($, { showDone: !v.showDone })} />
1103      </Box>
1104    </Box>
1105  )
1106
1107  return (
1108    <Box flexDirection="column">
1109      {section(el, 'Goal', goalRecord, 'cyan')}
1110      {section(el, gates.length + gated.length ? 'Waiting on you · ' + (gates.length + gated.length) : 'Waiting on you', waiting, gates.length + gated.length ? 'yellow' : undefined)}
1111      {section(el, 'Rows · ' + ready.length + ' agent-ready · ' + doneCount + '/' + goal.tasks.length + ' done', rowsBody)}
1112    </Box>
1113  )
1114}
1115
1116// The main loop stopped: note when, and whether agent-ready rows were left open.
1117async function noteMainStop($: $T, busy: boolean, wasBlocked: boolean) {
1118  const now = await $.clock.now()
1119  await refreshGoals($)
1120  const idle = await read($, idleAtom)
1121  await update($, idleAtom, i => ({ ...i, since: now }))
1122  if (!busy && idle.openRows > 0 && !wasBlocked) {
1123    $.ui.toast('Stopped with ' + plural(idle.openRows, 'agent-ready row') + ' open. The band offers continue.', { timeoutMs: 6_000 })
1124    await update($, idleAtom, i => ({ ...i, continueOfferedAt: now }))
1125  }
1126}
1127
1128function continueRow($: $T, el: El, openRows: number) {
1129  const { Box, Text, Button } = el
1130  return (
1131    <Box flexDirection="row" columnGap={2}>
1132      <Text color="yellow" wrap="truncate-end">stopped with {plural(openRows, 'agent-ready row')} open</Text>
1133      <Button
1134        key="idle-continue"
1135        label="continue"
1136        variant="primary"
1137        {...hk('c')}
1138        onPress={async () => {
1139          await update($, idleAtom, i => ({ ...i, continueOfferedAt: null }))
1140          await $.prompt.submit({
1141            text: 'Continue with the open agent-ready rows on the linked goal. Run python3 ~/.claude/scripts/goals/views/left.py first and take the first unblocked row; stop only when none is left or a gate needs me.',
1142          })
1143        }}
1144      />
1145      <Button key="idle-dismiss" label="not now" {...hk('x')} onPress={() => update($, idleAtom, i => ({ ...i, continueOfferedAt: null }))} />
1146    </Box>
1147  )
1148}
1149
1150// Fleet: sub-agent seats, running and landed, and whether each wrote its output.
1151
1152const runningSeats = (seats: Seat[]) => seats.filter(s => s.endedAt === null)
1153
1154async function landSeat($: $T, match: (s: Seat) => boolean, resolvedModel: string | null, sw: Switches) {
1155  const now = await $.clock.now()
1156  const seats = await read($, seatsAtom)
1157  const seat = seats.find(s => s.endedAt === null && match(s))
1158  if (!seat) return
1159  const hasOutput = seat.outputPath ? await fileExists($, abs(seat.outputPath)) : null
1160  const durationMs = now - seat.startedAt
1161  await update($, seatsAtom, list => list.map(s => (s.id === seat.id ? { ...s, endedAt: now, durationMs, hasOutput, resolvedModel: resolvedModel ?? s.resolvedModel } : s)))
1162  let verb = 'landed'
1163  if (sw.fun) {
1164    const f = await read($, funAtom)
1165    const pool = LANDING.filter(w => w !== f.lastLanding)
1166    verb = pool[Math.floor((now / 1000) % pool.length)] ?? 'landed'
1167    await update($, funAtom, x => ({ ...x, lastLanding: verb }))
1168  }
1169  const out = seat.outputPath ? (hasOutput ? ' · output ✓' : ' · output MISSING') : ''
1170  $.ui.toast('✓ ' + seat.name + ' ' + verb + ' in ' + dur(durationMs) + out, { timeoutMs: 7_000 })
1171  if (sw.sound) void run($, ['afplay', '/System/Library/Sounds/Glass.aiff'], { timeoutMs: 4_000 })
1172}
1173
1174async function fleetTab($: $T, el: El, v: View, now: number) {
1175  const { Box, Text, Button } = el
1176  const seats = await read($, seatsAtom)
1177  const live = runningSeats(seats)
1178  const landed = seats.filter(s => s.endedAt !== null).sort((a, b) => b.endedAt! - a.endedAt!)
1179  const all = [...live, ...landed]
1180  const selId = all.some(s => s.id === v.seatSel) ? v.seatSel : (all[0]?.id ?? null)
1181  const sel = all.find(s => s.id === selId)
1182
1183  const shownLanded = landed.slice(0, 8)
1184  const choose = (id: string) => setView($, { seatSel: id })
1185  const cols = [{ text: 'seat', width: 24 }, { text: 'model', width: 10 }, { text: 'time', width: 8 }, { text: 'output' }]
1186  const row = (s: Seat) => {
1187    const isLive = s.endedAt === null
1188    const out = isLive ? (s.isBackground ? '◐ running · bg' : '◐ running') : !s.outputPath ? '·' : s.hasOutput ? 'output ✓' : 'no output ✗'
1189    return listRow(el, 'seat-' + s.id, s.id === selId, () => choose(s.id), [
1190      { text: clip(s.name, 24), width: 24 },
1191      { text: s.resolvedModel ?? s.model, width: 10 },
1192      { text: dur((s.endedAt ?? now) - s.startedAt), width: 8 },
1193      { text: out, color: isLive ? 'yellow' : !s.outputPath ? undefined : s.hasOutput ? 'green' : 'red' },
1194    ])
1195  }
1196  const actions = sel && [
1197    sel.hasOutput && (
1198      <Button
1199        key="seat-preview"
1200        label="preview output"
hooks/lib.ts 306 lines
1// Shared ground for every gcc-mods surface: the state atoms, the gcc paths,
2// and the small pure helpers each tab repeats. Nothing here takes `$`: the
3// validator follows `$` only into functions declared in the module's own
4// file, so everything that touches the engine lives in register.tsx.
5
6import type { PluginOptions } from 'claude-code'
7
8import type { Decide, DecideSet, Doc, Fun, GoalView, GoalsSnap, Idle, Mail, Meter, Receipt, TaskRow, View } from '../types'
9
10export type El = Record<string, any>
11
12export const PLUGIN = 'gcc-mods'
13export const PANE = 'gcc'
14export const HOME = '/Users/alcatraz627'
15export const GCC = HOME + '/.claude'
16export const GS = GCC + '/scripts/goals/gs'
17export const VIEWS = GCC + '/scripts/goals/views'
18export const GOAL_SH = GCC + '/scripts/goal/goal.sh'
19export const DP_REG = GCC + '/assets/decision-pages'
20
21export const initialView: View = {
22  tab: 'tasks',
23  docFilter: 'session',
24  docSel: null,
25  docFull: false,
26  hidden: [],
27  goalSel: 0,
28  showDone: false,
29  showOthers: false,
30  rejecting: null,
31  nudgeSel: null,
32  nudgeMode: 'text',
33  snoozeFor: '4h',
34  seatSel: null,
35  msgSel: null,
36  isReplying: false,
37  isBandHidden: false,
38  decideSlug: null,
39  decideSel: null,
40  dismissed: [],
41  goalEditing: false,
42}
43
44export const emptyMeter: Meter = { ctx: null, fiveHour: null, week: null, costUsd: null, at: 0 }
45export const emptyMail: Mail = { messages: [], at: 0, lastWakeAt: 0, error: null }
46export const emptyReceipt: Receipt = { turnStartedAt: 0, lastRun: null, lastShot: null, lastCurl: null, claim: null }
47export const emptyIdle: Idle = { since: null, openRows: 0, continueOfferedAt: null }
48export const emptyDecide: Decide = { pending: [], picks: {}, notes: {}, at: 0, submitted: [], answered: [] }
49export const emptyFun: Fun = { greetedAt: null, metGoals: [], lastLanding: '', goalsSeeded: false }
50
51// The switches from plugin.json's userConfig, read once at register time.
52export type Switches = {
53  hygiene: boolean
54  continuity: boolean
55  tasks: boolean
56  tasksWrite: boolean
57  fleet: boolean
58  wake: boolean
59  mailWake: boolean
60  router: boolean
61  nudges: boolean
62  decide: boolean
63  receipt: boolean
64  fun: boolean
65  sound: boolean
66  startOn: boolean
67}
68export function switchesOf(options: PluginOptions): Switches {
69  const on = (k: keyof Switches, dflt = true) => (typeof options[k] === 'boolean' ? (options[k] as boolean) : dflt)
70  return {
71    hygiene: on('hygiene'),
72    continuity: on('continuity'),
73    tasks: on('tasks'),
74    tasksWrite: on('tasksWrite'),
75    fleet: on('fleet'),
76    wake: on('wake'),
77    mailWake: on('mailWake', false),
78    router: on('router'),
79    nudges: on('nudges'),
80    decide: on('decide'),
81    receipt: on('receipt'),
82    fun: on('fun'),
83    sound: on('sound', false),
84    startOn: on('startOn', false),
85  }
86}
87
88export function ago(now: number, at: number): string {
89  const m = Math.floor((now - at) / 60_000)
90  if (m < 1) return 'now'
91  if (m < 60) return m + 'm'
92  if (m < 48 * 60) return Math.floor(m / 60) + 'h'
93  return Math.floor(m / 1440) + 'd'
94}
95
96// How long ago, as a phrase that stands alone: "just now", "5m ago".
97export const agoPhrase = (now: number, at: number) => (ago(now, at) === 'now' ? 'just now' : ago(now, at) + ' ago')
98
99export function dur(ms: number): string {
100  const s = Math.max(0, Math.floor(ms / 1000))
101  if (s < 60) return s + 's'
102  return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
103}
104
105export const abs = (p: string) => (p.startsWith('~') ? HOME + p.slice(1) : p)
106export const tilde = (p: string) => (p.startsWith(HOME) ? '~' + p.slice(HOME.length) : p)
107export const head = (text: string, n: number) => text.split('\n').slice(0, n).join('\n')
108export const clip = (text: string, n: number) => (text.length > n ? text.slice(0, n - 1) + '…' : text)
109export const firstLine = (text: string) => text.split('\n').find(l => l.trim())?.trim() ?? ''
110
111// Optional-true props (hotkey, autoFocus) are refused as false or undefined,
112// so they are spread in only when they apply.
113export const hk = (key: string, when = true) => (when ? { hotkey: key } : {})
114
115export function jsonOr<T>(text: string | null | undefined, fallback: T): T {
116  if (!text) return fallback
117  try {
118    return JSON.parse(text) as T
119  } catch {
120    return fallback
121  }
122}
123
124export function parseJsonl(text: string): Record<string, any>[] {
125  const rows: Record<string, any>[] = []
126  for (const line of text.split('\n')) {
127    const t = line.trim()
128    if (!t) continue
129    try {
130      rows.push(JSON.parse(t))
131    } catch {
132      /* skip */
133    }
134  }
135  return rows
136}
137
138export const sid8 = (sid: string) => sid.slice(0, 8)
139
140export function plural(n: number, one: string, many = one + 's') {
141  return n + ' ' + (n === 1 ? one : many)
142}
143
144// Goal record derivations the band, the status line and the Tasks tab share.
145export const isGate = (a: GoalView['accept'][number]) => a.status !== 'proven' && a.kind !== 'functional' && a.kind !== 'deployed'
146export const openGates = (g: GoalView) => g.accept.filter(isGate)
147export const rowIsReady = (t: TaskRow) => !t.gate && t.blockedBy.length === 0 && /^(todo|ready|active|doing|open)$/.test(t.state)
148export const readyRows = (g: GoalView) => g.tasks.filter(rowIsReady)
149export const gatedRows = (g: GoalView) => g.tasks.filter(t => t.gate && !/^(done|dropped)$/.test(t.state))
150
151export function goalCounts(snap: GoalsSnap | null) {
152  const goals = snap?.goals ?? []
153  return {
154    gates: goals.reduce((n, g) => n + openGates(g).length + gatedRows(g).length, 0),
155    ready: goals.reduce((n, g) => n + readyRows(g).length, 0),
156    goals: goals.length,
157  }
158}
159
160export function docKindOf(path: string): Doc['kind'] {
161  if (/\/_.*\.claude\.md$/.test(path) || /\/checkpoints\//.test(path)) return 'checkpoint'
162  if (/\/session-notes\//.test(path)) return 'notes'
163  if (/\/assets\/reports\//.test(path)) return /review/i.test(path) ? 'review' : 'report'
164  if (/plan/i.test(path)) return 'plan'
165  if (/\.html?$/.test(path)) return 'page'
166  return 'doc'
167}
168
169// The owner tag a settings hook wraps its owner-facing text in (hook_owner_wrap
170// in scripts/hooks/hook-common.sh). An optional model="…" is what the model
171// reads in the block's place once it has been drawn.
172//   <owner surface="toast|log|band|pane:nudges|pane:tasks|ask" hook="<script>" model="…">…</owner>
173const BLOCK_RE = /<owner\b([^>]*)>([\s\S]*?)<\/owner>\s*/g
174const ATTR_RE = /(\w+)=("([^"]*)"|'([^']*)'|([^\s>]+))/g
175export type OwnerBlock = { surface: string; hook: string; text: string; model: string }
176
177// A line the owner typed to edit something in gcc rather than to talk to the
178// model: `gcc-goal: <goal>`, `snip-note <id>: <note>`, `dp-note <slug>/<item>: <note>`.
179// The edit is that one line; `rest` is every other line of the draft, which
180// belongs to the owner and goes back in the box.
181export type EditLine = { kind: 'goal' | 'snip' | 'dp'; key: string; body: string; rest: string }
182
183export function parseEditLine(text: string): EditLine | null {
184  const lines = text.split('\n')
185  for (let i = 0; i < lines.length; i++) {
186    const line = lines[i]!
187    const goal = line.match(/^gcc-goal: ?(.*)$/)
188    const snip = line.match(/^snip-note (\S+): ?(.*)$/)
189    const dp = line.match(/^dp-note (\S+\/\S+): ?(.*)$/)
190    const hit = goal ? (['goal', '', goal[1]] as const) : snip ? (['snip', snip[1], snip[2]] as const) : dp ? (['dp', dp[1], dp[2]] as const) : null
191    if (!hit) continue
192    const rest = [...lines.slice(0, i), ...lines.slice(i + 1)].join('\n').trim()
193    return { kind: hit[0], key: hit[1] ?? '', body: (hit[2] ?? '').trim(), rest }
194  }
195  return null
196}
197
198export function splitOwnerBlocks(text: string): { rest: string; blocks: OwnerBlock[] } {
199  const blocks: OwnerBlock[] = []
200  const rest = text.replace(BLOCK_RE, (_m, attrs: string, body: string) => {
201    const a: Record<string, string> = {}
202    for (const m of attrs.matchAll(ATTR_RE)) a[m[1]!] = m[3] ?? m[4] ?? m[5] ?? ''
203    blocks.push({ surface: (a.surface ?? 'toast').toLowerCase(), hook: a.hook ?? 'hook', text: body.trim(), model: (a.model ?? '').trim() })
204    return a.model ? a.model.trim() + '\n' : ''
205  })
206  return { rest: rest.trim(), blocks }
207}
208
209// The decision page's answer string: "<id><code>" tokens on one line, then one
210// "<id> note — <text>" line per note, the format the web page copies out.
211export function answerString(set: DecideSet, picks: Record<string, string>, notes: Record<string, string>): string {
212  const pickOf = (it: DecideSet['items'][number]) => picks[set.slug + '/' + it.id] ?? it.options.find(o => o.rec)?.code ?? 'a'
213  const noteOf = (it: DecideSet['items'][number]) => (notes[set.slug + '/' + it.id] ?? '').trim()
214  const decisions = set.items.filter(it => it.kind !== 'section')
215  const sections = set.items.filter(it => it.kind === 'section')
216  const lines: string[] = []
217  if (decisions.length) lines.push(decisions.map(it => it.id + pickOf(it)).join(' '))
218  for (const it of decisions) if (noteOf(it)) lines.push(it.id + ' note — ' + noteOf(it))
219  // A section card is listed only when it deviates, the web page's own format.
220  for (const it of sections) {
221    const agree = pickOf(it) !== 'disagree'
222    if (!agree || noteOf(it)) lines.push(it.id + ': ' + (agree ? 'agree' : 'DISAGREE') + (noteOf(it) ? ' — ' + noteOf(it) : ''))
223  }
224  if (sections.length && !decisions.length && lines.length === 0) lines.push('all items: agree as drafted')
225  return lines.join('\n')
226}
227
228// The same vocabulary declared-ready-stop.sh keys on, so the receipt and the
229// gate agree about what a done-claim is.
230export const SUCCESS_RE = /\b(done|works|working|shipped|fixed|passing|passes|verified|complete|completed|good to go|all set|ready to (ship|go|commit))\b/i
231export const SUBJECT_RE = /\b(the (fix|feature|change|bug|test|tests|build|code|implementation|patch|hook|script|mod|pane|band)|it|this|everything|all (the )?(tests|of it))\b/i
232export const RUN_RE = /(pytest|python3?\s+[^|]*\.py|go\s+test|cargo\s+(test|run)|npm\s+(test|run|start)|pnpm\s+(test|run|dev|start)|yarn\s+(test|dev|start)|node\s+[^|]+|swift\s+(test|run)|xcodebuild\s+test|make\s+(test|run|check)|\.\/[A-Za-z0-9._/-]+|bash\s+[^|]*\.sh|jest|vitest|playwright\s+test|claude\s+plugin\s+(test|validate)|tsc\b)/
233export const CURL_RE = /curl\s+[^|]*(localhost|127\.0\.0\.1|:\d{4})/
234
235export function receiptLine(r: Receipt, now: number): string {
236  const run = r.lastRun ? r.lastRun.cmd + ' ' + (r.lastRun.ok ? 'ok' : 'FAILED') + ' ' + ago(now, r.lastRun.at) + ' ago' : 'nothing run'
237  const shot = r.lastShot ? 'read ' + (r.lastShot.path.split('/').pop() ?? 'image') + ' ' + ago(now, r.lastShot.at) + ' ago' : 'none'
238  const curl = r.lastCurl ? r.lastCurl.url + ' ' + ago(now, r.lastCurl.at) + ' ago' : 'none'
239  return 'run: ' + run + ' · screenshot: ' + shot + ' · curl: ' + curl
240}
241
242// The pane's markdown renderer wraps wide tables into rubble, so a table is
243// drawn as an aligned monospace block instead, cut to the pane's width.
244export function tablesToBlocks(text: string, cols: number): string {
245  const lines = text.split('\n')
246  const out: string[] = []
247  let i = 0
248  while (i < lines.length) {
249    if (!/^\s*\|.*\|\s*$/.test(lines[i] ?? '')) {
250      out.push(lines[i]!)
251      i += 1
252      continue
253    }
254    const rows: string[][] = []
255    while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i] ?? '')) {
256      const cells = lines[i]!.trim().slice(1, -1).split('|').map(c => c.trim())
257      if (!cells.every(c => /^:?-{2,}:?$/.test(c))) rows.push(cells)
258      i += 1
259    }
260    const n = Math.max(...rows.map(r => r.length))
261    const room = Math.max(8, Math.floor((cols - 2 - 3 * (n - 1)) / n))
262    const widths = Array.from({ length: n }, (_, c) => Math.min(room, Math.max(...rows.map(r => (r[c] ?? '').length))))
263    const fit = (s: string, w: number) => (s.length > w ? s.slice(0, w - 1) + '…' : s).padEnd(w)
264    out.push('```text')
265    rows.forEach((r, ri) => {
266      out.push(widths.map((w, c) => fit(r[c] ?? '', w)).join(' │ '))
267      if (ri === 0) out.push(widths.map(w => '─'.repeat(w)).join('─┼─'))
268    })
269    out.push('```')
270  }
271  return out.join('\n')
272}
273
274// A path-looking token: home-relative, absolute under the usual roots, or a
275// gcc-shaped relative path. The boundary before it keeps URLs out.
276export const PATH_RE =
277  /(^|[\s(\[{'"<])((?:~|\.\.?)?\/(?:[\w.@+%-]+\/)*[\w.@+%-]+|(?:scripts|skills|assets|features|rules|conventions|docs|src|hooks|adapters|lib|tests?|config|i-dream|atone|goals|checkpoints|kanban|personas|shared|widgets|logs|rules-provenance|session-notes)\/(?:[\w.@+%-]+\/)*[\w.@+%-]+)/g
278export const TRAIL_RE = /[.,;:)\]'"]+$/
279
280export const WORDS = {
281  seats: ['Herding seats', 'Conducting', 'Running the fleet', 'Delegating'],
282  squeeze: ['Squeezing', 'Tidying the window', 'Breathing out', 'Compacting thoughts'],
283  mail: ['Pondering mail', 'Reading the post'],
284  night: ['Night-shifting', 'Moonlighting', 'Owling'],
285  dawn: ['Brewing', 'Warming up', 'Stretching'],
286  plain: ['Mulling', 'Tinkering', 'Noodling', 'Chewing on it', 'Puttering', 'Rummaging', 'Fiddling', 'Cooking'],
287}
288export const LANDING = ['landed', 'touched down', 'is back with the goods', 'reports in', 'has returned', 'came home']
289
290// A glyph for the hour, for the status line's left edge.
291export function hourGlyph(hour: number) {
292  if (hour < 5) return '🌙'
293  if (hour < 9) return '🌅'
294  if (hour < 17) return '☀'
295  if (hour < 21) return '🌇'
296  return '🌙'
297}
298
299export function greetingFor(hour: number) {
300  if (hour < 5) return 'Still up'
301  if (hour < 12) return 'Good morning'
302  if (hour < 17) return 'Good afternoon'
303  if (hour < 22) return 'Good evening'
304  return 'Late one'
305}
306
types/index.d.ts 212 lines
1// The values gcc-mods keeps in $.state for the session. Every one is a VIEW
2// over a gcc file or command; the files (gs, jsonl, claude-ipc, decision
3// pages) stay the source of truth and nothing here is written back except
4// through their own CLIs.
5
6export type Tab = 'docs' | 'tasks' | 'nudges' | 'fleet' | 'inbox' | 'decide' | 'snips'
7
8export type Scope = 'global' | 'project' | 'session'
9// A selection the owner kept, with a title and notes, in a jsonl file per scope.
10export type Snippet = { id: string; ts: number; scope: Scope; project: string; sid: string; title: string; text: string; notes: string; tags: string[] }
11// A doc the owner bookmarked, in the same files.
12export type Bookmark = { id: string; ts: number; scope: Scope; project: string; sid: string; path: string; note: string }
13export type Snips = { snippets: Snippet[]; bookmarks: Bookmark[]; at: number; sel: string | null; editing: 'title' | 'notes' | 'tags' | null; scope: Scope | 'all' }
14
15export type DocKind = 'report' | 'checkpoint' | 'notes' | 'plan' | 'review' | 'doc' | 'page'
16export type Doc = {
17  id: string
18  title: string
19  path: string
20  kind: DocKind
21  isThisSession: boolean
22  edits: number
23  at: number
24  isLive: boolean
25}
26export type DocFilter = 'session' | 'all' | 'reports' | 'checkpoints'
27
28export type AcceptRow = {
29  id: string
30  kind: string
31  text: string
32  evidence: string | null
33  status: string
34  source: string | null
35}
36export type TaskRow = {
37  id: string
38  subject: string
39  milestone: string | null
40  state: string
41  gate: string | null
42  blockedBy: string[]
43  lane: string | null
44  tier: string | null
45  note: string | null
46}
47export type Milestone = { id: string; name: string; status: string }
48export type GoalView = {
49  id: string
50  outcome: string
51  projects: string[]
52  accept: AcceptRow[]
53  milestones: Milestone[]
54  tasks: TaskRow[]
55  containment: string[]
56  cwd: string | null
57}
58export type GoalsSnap = { scope: string; armed: string; sessionGoal: string; goals: GoalView[]; at: number; error: string | null }
59
60export type Catchup = {
61  name: string
62  path: string
63  at: number
64  summary: string
65  source: string
66  state: 'offered' | 'loaded' | 'skipped'
67}
68
69export type Meter = {
70  ctx: number | null
71  fiveHour: number | null
72  week: number | null
73  costUsd: number | null
74  at: number
75}
76
77export type Seat = {
78  id: string
79  agentId: string | null
80  name: string
81  type: string
82  model: string
83  resolvedModel: string | null
84  desc: string
85  startedAt: number
86  endedAt: number | null
87  durationMs: number | null
88  outputPath: string | null
89  hasOutput: boolean | null
90  isBackground: boolean
91  wokeAt: number | null
92}
93
94// One ipc message this session sent or received. A sent one is stored as read,
95// so it never counts as new.
96export type Msg = {
97  id: string
98  from: string
99  to: string
100  isSent: boolean
101  kind: string
102  at: number
103  text: string
104  isRead: boolean
105}
106export type Mail = { messages: Msg[]; at: number; lastWakeAt: number; error: string | null }
107
108export type Audience = 'owner' | 'model' | 'both'
109export type Nudge = {
110  id: string
111  hook: string
112  event: string
113  audience: Audience
114  action: string
115  chars: number
116  text: string
117  detail: string
118  at: number
119  heeded: string
120  snoozed: string | null
121  feedback: string | null
122}
123export type NudgeMode = 'text' | 'why' | 'snooze' | 'feedback'
124
125export type Notice = { id: string; hook: string; text: string; at: number }
126
127export type Receipt = {
128  turnStartedAt: number
129  lastRun: { cmd: string; at: number; ok: boolean } | null
130  lastShot: { path: string; at: number } | null
131  lastCurl: { url: string; at: number } | null
132  claim: { text: string; at: number } | null
133}
134
135export type Idle = { since: number | null; openRows: number; continueOfferedAt: number | null }
136
137export type DecideOption = { code: string; label: string; rec: boolean }
138// kind 'section': a page's agree/DISAGREE card (config `sections`), answered as
139// `id: DISAGREE — note` only when it deviates, as the web page does.
140export type DecideItem = { id: string; question: string; context: string; options: DecideOption[]; kind?: 'decision' | 'section' }
141export type DecideSet = {
142  slug: string
143  title: string
144  intro: string
145  project: string
146  session: string
147  created: string
148  items: DecideItem[]
149}
150export type Decide = {
151  pending: DecideSet[]
152  picks: Record<string, string>
153  notes: Record<string, string>
154  at: number
155  submitted: string[]
156  answered: string[]
157}
158
159// goalsSeeded: the goal record has been read successfully once, so a goal met
160// from here on is news and earns its toast.
161export type Fun = { greetedAt: number | null; metGoals: string[]; lastLanding: string; goalsSeeded: boolean }
162
163// What the preview pane shows: the full text of whatever was last picked.
164export type Preview = { title: string; text: string; path: string | null; at: number; kind: 'text' | 'goal'; goalId: string | null; editing: boolean }
165
166export type View = {
167  tab: Tab
168  docFilter: DocFilter
169  docSel: string | null
170  docFull: boolean
171  hidden: string[]
172  goalSel: number
173  showDone: boolean
174  showOthers: boolean
175  rejecting: string | null
176  nudgeSel: string | null
177  nudgeMode: NudgeMode
178  snoozeFor: string
179  seatSel: string | null
180  msgSel: string | null
181  isReplying: boolean
182  isBandHidden: boolean
183  decideSlug: string | null
184  decideSel: string | null
185  dismissed: string[]
186  goalEditing: boolean
187}
188
189declare module 'claude-code' {
190  interface PluginState {
191    'gcc-mods': {
192      view: View
193      docs: Doc[]
194      goals: GoalsSnap | null
195      catchup: Catchup | null
196      meter: Meter
197      seats: Seat[]
198      mail: Mail
199      nudges: Nudge[]
200      notices: Notice[]
201      receipt: Receipt
202      idle: Idle
203      decide: Decide
204      fun: Fun
205      preview: Preview | null
206      // null until /hub or /hub off decides it this session; the startOn switch fills in.
207      enabled: boolean | null
208      snips: Snips
209    }
210  }
211}
212