SLOPSHOPPER

gsd-pilot

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

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gsd-pilot
│ ┃ GSD pilot ✕ › fix the failing auth test and add an audit log call │ ┃ GSD pilot │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ no GSD project here ⎿ Read 6 lines │ ┃ │ gsd-core is not installed for Claude Code ⏺ Update(src/auth.ts) │ ┃ │ gsd-core/bin/gsd-tools.cjs under the Claud ⎿ Added 2 lines, removed 1 line │ ┃ │ dir). ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ r: refresh buttons fill the prompt; keys af │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gsd │ ⎿ gsd-pilot: Opened the GSD pilot. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GSD pilot
GSD pilot ╭──────────────────────────────────────────────────────╮ │ no GSD project here │ │ gsd-core is not installed for Claude Code (no │ │ gsd-core/bin/gsd-tools.cjs under the Claude config │ │ dir). │ ╰──────────────────────────────────────────────────────╯ r: refresh buttons fill the prompt; keys after ctrl+x, tab
README

gsd-pilot

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.

What you get

  • /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.
  • The milestone, the situation gsd-core reports, plan progress, and which .planning it is reading (said when that is not your working folder, for example from a linked worktree).
  • A verification card for the first phase not yet complete: its status (MISSING, STALE, GAPS_FOUND, PASSED...), gsd-core's next action, and the command for it (v).
  • Moves that fit now: gsd-core's own 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.
  • Keys reach the pane after ctrl+x then tab; clicks reach it in the fullscreen terminal.
  • /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.

Where the facts come from

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.

Rules it keeps

  • Runs only the gsd-core installed under your Claude config dir ($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.
  • Fills the prompt; never submits a command, never runs a GSD command itself.
  • Writes no files, makes no network calls.
  • Computes no GSD verdict of its own; if gsd-core reports no phase, it shows none.
  • gsd-tools still answers some moves in the retired colon form (/gsd:progress); the palette uses whichever spelling this session's command list actually has.

Develop

claude plugin validate . claude plugin test . claude --plugin-dir .

Source 3 files
hooks/register.tsx 424 lines
1import { 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}
424
hooks/snapshot.ts 99 lines
1import 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}
99
types/index.d.ts 39 lines
1export 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