One keystroke to the right GSD move: a docked palette of the commands that fit now, verification status, capture and attach.

A Claude Code mod that puts the right GSD move one keystroke away. It shows what gsd-core already computed and offers it as buttons that fill the prompt; you press Enter.
Status: v0.2.0. Validated, type-checked and unit-tested against Claude Code 2.1.291; live check pending.
/gsd: a palette pane, docked on the right in the gsd-status-mod style and opened on session start in a GSD project (option openOnStart, on by default). It opens on its own only in Claude Code's fullscreen layout (/tui fullscreen); on the main screen you get one hint, once, and /gsd opens it above the prompt. If it has to wait for width (an unasked pane needs 144 columns, 110 once you have opened it by hand), it says so. See "Panes (and Orca)" in the repository README..planning it is reading (said when that is not your working folder, for example from a linked worktree).MISSING, STALE, GAPS_FOUND, PASSED...), gsd-core's next action, and the command for it (v).smart-entry actions as buttons 1-9, the recommended one highlighted. A move whose command is not installed in this session is shown greyed and fills nothing./gsd-grab [todo|note|seed|backlog]: fills /gsd-capture with the text you selected in the transcript./gsd-attach phase <N> | plan <NN-MM>: puts @-mentions for that phase's CONTEXT/RESEARCH/PLAN/VERIFICATION/UAT files, or that plan's PLAN and SUMMARY, into your prompt.gsd-tools planning inspect (one schema-versioned snapshot of .planning) and gsd-tools smart-entry --json (the moves), read after each main-loop turn and on /gsd. Nothing is parsed out of markdown by this mod.
$CLAUDE_CONFIG_DIR or ~/.claude), proven with runtime-identity before first use. It never runs a gsd-tools.cjs found inside the working tree: mods are not sandboxed, and that would run a cloned repo's code unasked./gsd:progress); the palette uses whichever spelling this session's command list actually has.claude plugin validate . claude plugin test . claude --plugin-dir .
hooks/register.tsx 424 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { PilotAction, PilotSnapshot, PilotStatus } from '../types'
5import { buildSnapshot } from './snapshot'
6import type { InspectJson, SmartJson } from './snapshot'
7
8// gsd-pilot: one keystroke to the right GSD move.
9//
10// It shows what gsd-core already computed (the commands that fit now, the
11// active phase's verification state) and offers each as a button that FILLS the
12// prompt; the person presses Enter. It computes no GSD verdict of its own, runs
13// no GSD command itself, and writes nothing.
14
15const snapshot = atom({ plugin: 'gsd-pilot', key: 'snapshot' } as const, null as PilotSnapshot | null)
16const status = atom({ plugin: 'gsd-pilot', key: 'status' } as const, { kind: 'none', text: 'Not read yet.' } as PilotStatus)
17
18// ---- gsd-tools adapter (kept in this file: the loader follows `$` only into
19// functions declared in the same file as the hooks, never across an import).
20
21// The one door to gsd-tools. Rules (from the design review, ideas/lens-1/3):
22// - Run only the gsd-core the person installed under their Claude config dir,
23// never a gsd-tools.cjs found inside the working tree: mods are not
24// sandboxed, so running a cloned repo's copy would run its code unasked.
25// - Prove the install is gsd-core (`runtime-identity`) before first use.
26// - Read stdout only: gsd-tools prints config warnings on stderr.
27// - Follow `@file:` (results over ~50 KB come back as a temp-file path).
28// - Never from a render hook; callers cache per turn.
29
30type GsdResult<T> = { ok: true; value: T } | { ok: false; reason: string }
31
32const TIMEOUT_MS = 8_000
33
34let identity: { path: string; ok: boolean } | null = null
35
36async function toolsPath($: EngineInterface): Promise<string | null> {
37 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
38 const path = `${configDir}/gsd-core/bin/gsd-tools.cjs`
39 return (await $.fs.exists(path)) ? path : null
40}
41
42async function proven($: EngineInterface): Promise<GsdResult<string>> {
43 const path = await toolsPath($)
44 if (path === null) return { ok: false, reason: 'gsd-core is not installed for Claude Code (no gsd-core/bin/gsd-tools.cjs under the Claude config dir).' }
45 if (identity !== null && identity.path === path) {
46 return identity.ok ? { ok: true, value: path } : { ok: false, reason: 'the installed gsd-tools did not prove it is @opengsd/gsd-core.' }
47 }
48 const ran = await $.process.run(['node', path, 'runtime-identity', '--raw'], { timeoutMs: TIMEOUT_MS })
49 const ok = ran.exitCode === 0 && ran.stdout.trim().startsWith('{"packageName":"@opengsd/gsd-core"')
50 identity = { path, ok }
51 return ok ? { ok: true, value: path } : { ok: false, reason: 'the installed gsd-tools did not prove it is @opengsd/gsd-core.' }
52}
53
54async function gsdJson<T>($: EngineInterface, args: string[]): Promise<GsdResult<T>> {
55 const tools = await proven($)
56 if (!tools.ok) return tools
57 const cwd = await $.session.cwd()
58 const ran = await $.process.run(['node', tools.value, ...args, '--json-errors'], { cwd, timeoutMs: TIMEOUT_MS })
59 if (ran.exitCode !== 0) {
60 const first = ran.stderr.split('\n').find(l => l.trim() !== '') ?? `exit ${ran.exitCode}`
61 return { ok: false, reason: `gsd-tools ${args[0]}: ${first.slice(0, 200)}` }
62 }
63 let text = ran.stdout.trim()
64 if (text.startsWith('@file:')) {
65 try {
66 text = (await $.fs.read(text.slice('@file:'.length).trim())).trim()
67 } catch {
68 return { ok: false, reason: `gsd-tools ${args[0]}: could not read its @file result.` }
69 }
70 }
71 try {
72 return { ok: true, value: JSON.parse(text) as T }
73 } catch {
74 return { ok: false, reason: `gsd-tools ${args[0]}: answered something that is not JSON.` }
75 }
76}
77
78// ---- palette
79
80const PANE = 'gsd-pilot'
81const CAPTURE_KINDS: Record<string, string> = { todo: '', note: '--note ', seed: '--seed ', backlog: '--backlog ' }
82
83// ---- pane placement: the gsd-status-mod setup (a docked pane opened at
84// session start, `openOnStart` to turn that off), plus what it leaves out.
85//
86// A pane docks beside the transcript only in Claude Code's fullscreen layout
87// (`/tui fullscreen`); on the main screen it sits inline above the prompt. So
88// the pilot opens unasked only where it would be a sidebar, and says why it is
89// waiting instead of failing silently: in an Orca split the terminal is often
90// under the 144 columns an unasked pane needs (110 once /gsd has opened it).
91
92const PANE_SIZE = { columns: 64, rows: 18 }
93const LAYOUT_KEY = 'fullscreen'
94const HINTED_KEY = 'hinted-main-screen'
95
96let openOnStart = true
97let fullscreen: boolean | undefined // as a render or command last reported it; fixed per session
98let autoTried = false
99let closedByHand = false
100
101export const MAIN_SCREEN_HINT =
102 'Claude Code is on its main-screen layout, so the pane sits above the prompt. Run /tui fullscreen to dock it on the right (Orca included).'
103
104export function waitingHint(reason: string): string {
105 return `GSD pilot pane is waiting: ${reason.replace(/\.?\s*$/, '.')} Run /gsd to open it now.`
106}
107
108function noteLayout(v: { isFullscreen?: boolean } | undefined): void {
109 if (typeof v?.isFullscreen === 'boolean') fullscreen = v.isFullscreen
110}
111
112async function keepLayout($: EngineInterface): Promise<void> {
113 if (fullscreen !== undefined && (await $.store.get(LAYOUT_KEY)) !== fullscreen) await $.store.set(LAYOUT_KEY, fullscreen)
114}
115
116// The unasked open, tried once per session as soon as the layout is known:
117// this session's (from a render) or, before the first render, the last one's.
118async function autoOpen($: EngineInterface): Promise<void> {
119 if (autoTried || closedByHand || !openOnStart) return
120 if ((await read($, snapshot)) === null) return
121 const stored = await $.store.get(LAYOUT_KEY)
122 const layout = fullscreen ?? (typeof stored === 'boolean' ? stored : undefined)
123 if (layout === undefined) return
124 // A remembered layout is a guess (the person may have run /tui since): it can
125 // open the pane early, but only this session's own report settles a "no".
126 if (layout || fullscreen !== undefined) autoTried = true
127 if (!layout) {
128 if ((await $.store.get(HINTED_KEY)) !== true) {
129 await $.store.set(HINTED_KEY, true)
130 $.ui.toast(`gsd-pilot: ${MAIN_SCREEN_HINT}`)
131 }
132 return
133 }
134 const opened = await $.ui.open({ id: PANE, title: 'GSD pilot', ...PANE_SIZE })
135 if (!opened.isPlaced) $.ui.toast(waitingHint(opened.reason))
136}
137
138let lastRefresh = 0
139let refreshing: Promise<void> | null = null
140
141async function refresh($: EngineInterface): Promise<void> {
142 if (refreshing) return refreshing
143 refreshing = (async () => {
144 try {
145 const cwd = await $.session.cwd()
146 const [inspect, smart] = await Promise.all([
147 gsdJson<InspectJson>($, ['planning', 'inspect']),
148 gsdJson<SmartJson>($, ['smart-entry', '--json']),
149 ])
150 if (!inspect.ok) {
151 // Not a GSD project, or gsd-core missing: say so once, draw nothing else.
152 await update($, snapshot, () => null)
153 await update($, status, () => ({ kind: 'none', text: inspect.reason }))
154 return
155 }
156 const names = new Set((await $.command.list()).map(c => c.name))
157 const snap = buildSnapshot(inspect.value, smart.ok ? smart.value : {}, names, cwd, await $.clock.now())
158 await update($, snapshot, () => snap)
159 await update($, status, () =>
160 smart.ok ? { kind: 'ok', text: 'ok' } : { kind: 'error', text: `next-move list unavailable: ${smart.reason}` },
161 )
162 } catch (err) {
163 await update($, status, () => ({ kind: 'error', text: String(err).slice(0, 200) }))
164 } finally {
165 lastRefresh = await $.clock.now()
166 refreshing = null
167 }
168 })()
169 return refreshing
170}
171
172function asText(s: PilotSnapshot | null, st: PilotStatus): string {
173 if (s === null) return `gsd-pilot: ${st.text}`
174 const lines = [`GSD ${s.milestone ?? ''} - ${s.situation}${s.activePhase ? ` - ${s.activePhase}` : ''}`.trim()]
175 if (s.verification) lines.push(`Phase ${s.verification.phaseId} verification: ${s.verification.status}`)
176 lines.push('', 'Moves that fit now:')
177 for (const a of s.actions) lines.push(`- ${a.command}${a.isRecommended ? ' (recommended)' : ''}${a.isAvailable ? '' : ' (not installed)'} ${a.label}`)
178 return lines.join('\n')
179}
180
181function statusColor(st: string): string {
182 return st === 'passed' ? 'success' : st === 'gaps_found' || st === 'stale' ? 'warning' : st === 'missing' ? 'subtle' : 'warning'
183}
184
185function relPath(path: string, cwd: string): string {
186 const base = cwd.replace(/\/$/, '') + '/'
187 return path.startsWith(base) ? path.slice(base.length) : path
188}
189
190async function filesFor($: EngineInterface, s: PilotSnapshot, kind: string, id: string): Promise<string[] | string> {
191 const phasesDir = `${s.planningRoot}/phases`
192 let dirs: string[] = []
193 try {
194 dirs = (await $.fs.list(phasesDir)).filter(d => d.kind === 'dir').map(d => d.name)
195 } catch {
196 return `No phases folder at ${phasesDir}.`
197 }
198 if (kind === 'phase') {
199 const want = id.padStart(2, '0')
200 const dir = dirs.find(d => d === want || d.startsWith(`${want}-`))
201 if (!dir) return `No phase ${id} under ${phasesDir}.`
202 const files = (await $.fs.list(`${phasesDir}/${dir}`)).filter(f => f.kind === 'file' && f.name.endsWith('.md')).map(f => f.name)
203 const keep = files.filter(f => /-(CONTEXT|RESEARCH|VERIFICATION|UAT)\.md$/.test(f) || /-PLAN\.md$/.test(f))
204 return (keep.length > 0 ? keep : files).sort().map(f => `${phasesDir}/${dir}/${f}`)
205 }
206 // plan NN-MM: its PLAN and, once written, its SUMMARY.
207 const m = id.match(/^(\d+(?:\.\d+)?)-(\d+)$/)
208 if (!m) return 'A plan id looks like 03-02.'
209 const want = (m[1] ?? '').padStart(2, '0')
210 const dir = dirs.find(d => d === want || d.startsWith(`${want}-`))
211 if (!dir) return `No phase ${m[1]} under ${phasesDir}.`
212 const files = (await $.fs.list(`${phasesDir}/${dir}`)).map(f => f.name)
213 const out = files.filter(f => f === `${id}-PLAN.md` || f === `${id}-SUMMARY.md`).sort()
214 if (out.length === 0) return `No ${id}-PLAN.md in ${dir}.`
215 return out.map(f => `${phasesDir}/${dir}/${f}`)
216}
217
218export const register: Register = (on, options) => {
219 openOnStart = options.openOnStart !== false
220 on('session.start', async ($, e, next) => {
221 await $.command.register({ name: 'gsd', description: 'GSD palette: the moves that fit now, one key away' })
222 await $.command.register({
223 name: 'gsd-grab',
224 description: 'Capture the text you selected as a GSD todo, note, seed or backlog item',
225 argumentHint: '[todo|note|seed|backlog]',
226 })
227 await $.command.register({
228 name: 'gsd-attach',
229 description: "Attach a GSD phase's or plan's files to your next prompt",
230 argumentHint: 'phase <N> | plan <NN-MM>',
231 })
232 // Read first: the pane opens unasked only in a GSD project.
233 if (e.isInteractive) void refresh($).then(() => autoOpen($)).catch(() => undefined)
234 else void refresh($)
235 return next(e)
236 })
237
238 // After each main-loop turn, re-read: the agent may have moved GSD state on.
239 // The first turn also settles the layout a first-ever session did not know.
240 on('turn.complete', async ($, e, next) => {
241 const result = await next(e)
242 if (e.agentId === undefined) {
243 if ((await $.clock.now()) - lastRefresh > 2_000) void refresh($).then(() => autoOpen($)).catch(() => undefined)
244 else void autoOpen($).catch(() => undefined)
245 void keepLayout($).catch(() => undefined)
246 }
247 return result
248 })
249
250 on('command.run', { command: 'gsd' }, async ($, e) => {
251 noteLayout(e.presentation)
252 closedByHand = false
253 await refresh($)
254 const opened = await $.ui.open({ id: PANE, title: 'GSD pilot', ...PANE_SIZE })
255 await keepLayout($).catch(() => undefined)
256 if (opened.isPlaced) return { text: `Opened the GSD pilot.${e.presentation?.isFullscreen === false ? ` ${MAIN_SCREEN_HINT}` : ''}` }
257 return { text: asText(await read($, snapshot), await read($, status)) }
258 })
259
260 // Closed with its close mark (or ctrl+x x): not reopened unasked this session.
261 on('ui.close', { id: PANE }, async ($, e, next) => {
262 const result = await next(e)
263 if (e.origin.kind === 'person') closedByHand = true
264 return result
265 }).catch(($, e, next) => next(e))
266
267 // Nothing drawn here: the band is where the layout is known before any pane is.
268 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
269 noteLayout(e.viewport)
270 return next(e)
271 })
272
273 on('command.run', { command: 'gsd-grab' }, async ($, e) => {
274 const kind = e.args.trim().toLowerCase() || 'todo'
275 const flag = CAPTURE_KINDS[kind]
276 if (flag === undefined) return { text: 'gsd-grab takes todo, note, seed or backlog.' }
277 const picked = await $.ui.selection()
278 if (picked === undefined || picked.text.trim() === '') {
279 return { text: 'Select some text in the transcript first (fullscreen terminal), then run /gsd-grab.' }
280 }
281 const body = picked.text.replace(/\s+/g, ' ').trim().slice(0, 600)
282 await $.prompt.fill({ text: `/gsd-capture ${flag}${body}`, mode: 'replace' })
283 return { text: `Filled /gsd-capture ${flag.trim() || '(todo)'} with your selection. Press Enter to capture it.` }
284 })
285
286 on('command.run', { command: 'gsd-attach' }, async ($, e) => {
287 const [kind, id] = e.args.trim().split(/\s+/)
288 if ((kind !== 'phase' && kind !== 'plan') || !id) return { text: 'Usage: /gsd-attach phase <N> | plan <NN-MM>' }
289 await refresh($)
290 const s = await read($, snapshot)
291 if (s === null) return { text: `gsd-attach: ${(await read($, status)).text}` }
292 const found = await filesFor($, s, kind, id)
293 if (typeof found === 'string') return { text: `gsd-attach: ${found}` }
294 const cwd = await $.session.cwd()
295 await $.prompt.fill({ text: found.map(f => `@${relPath(f, cwd)}`).join(' ') + ' ', mode: 'insert' })
296 return { text: `Attached ${found.length} file(s) from ${kind} ${id} to your prompt.` }
297 })
298
299 // The palette, drawn as gsd-status-mod draws its pane: a centred header, then
300 // one round-bordered panel per topic, its title left and a note on the right.
301 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
302 const { Box, Button, Text } = $.ui.resolve(e)
303 noteLayout(e.viewport)
304 const W = Math.max(40, e.props.bodyColumns)
305 const s = await read($, snapshot)
306 const st = await read($, status)
307 const panel = (key: string, color: string, title: string, right: RenderChildren, children: RenderChildren[]) => (
308 <Box key={key} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
309 <Box justifyContent="space-between">
310 <Text bold wrap="truncate">
311 {title}
312 </Text>
313 {right}
314 </Box>
315 {children}
316 </Box>
317 )
318 const footer = (
319 <Box key="footer">
320 <Button key="refresh" label="refresh" plain hotkey="r" onPress={() => refresh($)} />
321 <Text color="subtle" wrap="truncate">
322 {' buttons fill the prompt; keys after ctrl+x, tab'}
323 </Text>
324 </Box>
325 )
326 if (s === null) {
327 return (
328 <Box flexDirection="column" width={W}>
329 <Box justifyContent="center">
330 <Text bold>GSD pilot</Text>
331 </Box>
332 {panel('none', 'subtle', 'no GSD project here', null, [
333 <Text key="why" color="subtle">
334 {st.text}
335 </Text>,
336 ])}
337 {footer}
338 </Box>
339 )
340 }
341 const fill = (a: PilotAction) => () => $.prompt.fill({ text: a.command, mode: 'replace' })
342 const v = s.verification
343 return (
344 <Box flexDirection="column" width={W}>
345 <Box justifyContent="center">
346 <Text bold wrap="truncate">
347 <Text color="claude">GSD pilot</Text>
348 {s.milestone ? ` · ${s.milestone}` : ''}
349 </Text>
350 </Box>
351 {panel(
352 'state',
353 'claude',
354 s.situation,
355 s.progress ? <Text color="subtle">{s.progress}</Text> : null,
356 [
357 s.activePhase ? (
358 <Text key="active" wrap="truncate-end">
359 {`active ${s.activePhase}`}
360 </Text>
361 ) : null,
362 s.isElsewhere ? (
363 <Text key="where" color="subtle" wrap="truncate-middle">
364 {`reading ${s.planningRoot}`}
365 </Text>
366 ) : null,
367 ],
368 )}
369 {v &&
370 panel(
371 'verify',
372 statusColor(v.status),
373 `phase ${v.phaseId} verification`,
374 <Text color={statusColor(v.status)} inverse>
375 {` ${v.status.toUpperCase()} `}
376 </Text>,
377 [
378 <Text key="next" color="subtle" wrap="truncate-end">
379 {v.nextAction}
380 </Text>,
381 v.command ? (
382 <Button
383 key="verify-go"
384 label={v.command}
385 plain
386 hotkey="v"
387 onPress={() => $.prompt.fill({ text: v.command!, mode: 'replace' })}
388 />
389 ) : null,
390 ],
391 )}
392 {panel(
393 'moves',
394 'claude',
395 'moves that fit now',
396 <Text color="subtle">{s.actions.length > 0 ? `1-${Math.min(9, s.actions.length)}` : ''}</Text>,
397 [
398 s.actions.length === 0 ? (
399 <Text key="none" color="subtle">
400 {st.kind === 'error' ? st.text : 'None offered.'}
401 </Text>
402 ) : null,
403 ...s.actions.slice(0, 9).map((a, i) => (
404 <Box key={`move-${i}`}>
405 <Button
406 key={`go-${i}`}
407 label={a.command}
408 plain
409 hotkey={String(i + 1)}
410 onPress={a.isAvailable ? fill(a) : () => undefined}
411 />
412 <Text color={a.isRecommended ? 'claude' : a.isAvailable ? 'text' : 'subtle'} wrap="truncate-end">
413 {` ${a.isRecommended ? '★ ' : ''}${a.label}${a.isAvailable ? '' : ' (not installed)'}`}
414 </Text>
415 </Box>
416 )),
417 ],
418 )}
419 {footer}
420 </Box>
421 )
422 })
423}
424hooks/snapshot.ts 99 lines1import type { PilotAction, PilotSnapshot, PilotVerification } from '../types'
2
3// Builds what the palette draws from two gsd-tools answers. Pure, so it is
4// tested without a process.
5
6export type InspectJson = {
7 generated_from?: { cwd?: string; planning_root?: string }
8 milestone?: { version?: string | null; name?: string | null }
9 active?: { phase?: { value?: unknown }; status?: { value?: unknown } }
10 progress?: unknown
11 phases?: Array<{
12 dir?: string
13 phase_id?: string
14 complete?: boolean
15 verification?: { status?: string; next_action?: string; route?: string | null }
16 }>
17}
18
19export type SmartJson = {
20 situation?: string
21 actions?: Array<{ label?: string; command?: string; recommended?: boolean }>
22}
23
24// gsd-tools still answers some commands in the retired colon form
25// (`/gsd:progress --next`) while a Claude install registers `gsd-progress`.
26// Use whichever spelling the session actually lists; mark it unavailable when
27// neither is there, rather than fill a command that does not exist.
28export function resolveCommand(raw: string, names: ReadonlySet<string>): { command: string; isAvailable: boolean } {
29 const m = raw.trim().match(/^\/(?:gsd[:-])?([a-z0-9-]+)(.*)$/i)
30 if (m === null) return { command: raw, isAvailable: false }
31 const tail = m[2] ?? ''
32 for (const name of [`gsd-${m[1]}`, `gsd:${m[1]}`]) {
33 if (names.has(name)) return { command: `/${name}${tail}`, isAvailable: true }
34 }
35 return { command: raw, isAvailable: false }
36}
37
38const text = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' && v.trim() !== '—' ? v.trim() : null)
39
40function progressOf(p: unknown): string | null {
41 if (p === null || typeof p !== 'object') return null
42 const o = p as Record<string, unknown>
43 const done = o.completed_plans ?? o.summaries ?? o.total_summaries
44 const total = o.total_plans ?? o.plans
45 if (typeof done === 'number' && typeof total === 'number' && total > 0) return `${done}/${total} plans`
46 return null
47}
48
49export function buildSnapshot(
50 inspect: InspectJson,
51 smart: SmartJson,
52 names: ReadonlySet<string>,
53 cwd: string,
54 now: number,
55): PilotSnapshot {
56 const planningRoot = inspect.generated_from?.planning_root ?? `${cwd}/.planning`
57 const rootDir = planningRoot.replace(/\/\.planning\/?$/, '')
58 const actions: PilotAction[] = (smart.actions ?? [])
59 .filter(a => typeof a.command === 'string')
60 .map(a => {
61 const r = resolveCommand(a.command as string, names)
62 return {
63 label: a.label ?? r.command,
64 command: r.command,
65 isRecommended: a.recommended === true,
66 isAvailable: r.isAvailable,
67 }
68 })
69
70 // The phase the person is in: the first one not complete. Never guessed
71 // from anything else; null when every phase is complete or none exist.
72 const focus = (inspect.phases ?? []).find(p => p.complete !== true && typeof p.phase_id === 'string')
73 let verification: PilotVerification | null = null
74 if (focus && focus.verification && typeof focus.verification.status === 'string') {
75 const route = text(focus.verification.route)
76 const resolved = route ? resolveCommand(`/gsd-${route} ${focus.phase_id}`, names) : null
77 verification = {
78 phaseId: focus.phase_id as string,
79 phaseDir: focus.dir ?? '',
80 status: focus.verification.status,
81 nextAction: focus.verification.next_action ?? '',
82 command: resolved && resolved.isAvailable ? resolved.command : null,
83 }
84 }
85
86 return {
87 at: now,
88 planningRoot,
89 isElsewhere: rootDir !== cwd.replace(/\/$/, ''),
90 milestone: text(inspect.milestone?.version) ?? text(inspect.milestone?.name),
91 situation: smart.situation ?? 'unknown',
92 activePhase: text(inspect.active?.phase?.value),
93 activeStatus: text(inspect.active?.status?.value),
94 progress: progressOf(inspect.progress),
95 actions,
96 verification,
97 }
98}
99types/index.d.ts 39 lines1export type PilotAction = {
2 label: string
3 command: string
4 isRecommended: boolean
5 isAvailable: boolean
6}
7
8export type PilotVerification = {
9 phaseId: string
10 phaseDir: string
11 status: string
12 nextAction: string
13 command: string | null
14}
15
16export type PilotSnapshot = {
17 at: number
18 planningRoot: string
19 isElsewhere: boolean
20 milestone: string | null
21 situation: string
22 activePhase: string | null
23 activeStatus: string | null
24 progress: string | null
25 actions: PilotAction[]
26 verification: PilotVerification | null
27}
28
29export type PilotStatus = { kind: 'ok' | 'none' | 'error'; text: string }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'gsd-pilot': {
34 snapshot: PilotSnapshot | null
35 status: PilotStatus
36 }
37 }
38}
39