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…

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.
| Surface | What you get |
|---|---|
/hub pane, six tabs | Docs (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 on | the Tasks tab, this guide, the band toggle, and the per-session switch |
| band above the prompt | one 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 line | ctx 42% · wk 61% · 2 gates · 1 seat · ✉ 1 |
| transcript | paths 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 |
| wakes | an 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 |
| fun | a 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.
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.
/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.
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.
hooks/register.tsx 2749 lines1// 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 lines1// 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}
306types/index.d.ts 212 lines1// 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