Shows the stage, breadcrumb, a status-colored sprint bar, timing and the current story above the prompt; its crumbs and count open the epic, sprint and stories

A single-player browser game inspired by Syndicate. As the Operations Director of Nexus Global, inspect the World Network, fund research, accept contracts, and deploy one to four operatives into real-time isometric missions.
React 19 and Vite drive the interface; react-three-fiber and three.js drive the mission scene; Zustand holds campaign and HUD state. Runtime visual assets are generated in code.
package-lock.json. Use an even-numbered Node release compatible with the locked toolchain.From the repository root:
npm ci
npm run dev -- --strictPort
Open the URL printed by Vite, normally http://localhost:4200. vite.config.ts uses PORT if set; --strictPort makes an occupied port an explicit error instead of silently choosing another. Stop the server with Ctrl+C when finished.
Start a New Operation, select an unlocked contract on the World Network, then proceed through Brief → Assembly → mission. The mission pause menu lists the current bindings; the complete control reference is in the GDD.
WebGPURenderer, whose initialization automatically falls back to WebGL2 when needed. The console prints [scene] renderer backend: ... and the resolved quality tier after initialization. A menu loading successfully does not verify mission rendering.Run npm run dev:assets -- --strictPort to open /tools/asset-viewer.html with the procedural Agent already loaded. The viewer and mission use the same builder in src/scene/unitModel.ts. Orbit, zoom, and pan with the mouse; use the inspector for camera presets, operative accents, weapon and selection markers, wireframe, turntable, grid, and exposure. Geometry statistics describe the model without its selection markers. The viewer does not initialize a campaign or mission.
The page is also included in npm run build and can be opened at the same path with npm run preview -- --port 4200 --strictPort. Stop the server after use.
npm run lint
npm run test
npm run build
These are the required automated checks. build typechecks TypeScript and creates the production bundle in dist/; tests live beside their modules.
To inspect that production bundle locally:
npm run preview -- --port 4200 --strictPort
Stop the development server first if it occupies the same port. Preview is a local check, not a deployment service. For rendering, screen flow, input, audio, or persistence changes, also run the click-through at 1280×720 and record actual coverage using the QA record format. Automated checks alone do not establish a browser pass.
Stop every dev/preview server you start. On macOS, confirm the QA port is clear with:
lsof -nP -iTCP:4200 -sTCP:LISTEN
No listener output means the port is clear. If you used another port, check that one too; do not stop unrelated processes.
| Need | Read | Authority / scope |
|---|---|---|
| Understand the game and its rules | Game design | Living specification, including acceptance criteria and explicitly pending targets |
| Use the right names | Domain glossary | Canonical terminology, not a replacement for the rules |
| Understand settled decisions | ADRs | Rationale and consequences; reopen decisions explicitly |
| Change code safely | Engineering guidance | Module boundaries, state lifetimes, rendering constraints, verification and contribution conventions |
| Exercise the player flow | Click-through | Manual procedure; not evidence that a check has passed |
| Record or inspect verification | QA records | Reproducible record format and historical evidence index |
| Work on district rendering | City architecture | Shared geometry/material kit, visibility behavior and scoped verification |
| Work on sound assets | Audio sources and preparation | Credits, checksums, rebuilding and playback checks |
| Work with issues | Issue tracker | GitHub workflow and issue relationships |
The styled GDD is a dated snapshot, not the living specification. Historical QA describes the revision and coverage recorded there, not a current whole-game pass. Code establishes implemented behavior; when it disagrees with the living specification, resolve the discrepancy rather than copying stale rules forward.
hooks/register.tsx 583 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { Band, FileRef, PaneView, Progress, Sprint, Story } from '../types'
5
6// The band draws .claude/statusline.sh's stage and breadcrumb, less its first
7// two segments (context tokens, model), so the script stays the one source of
8// those. Sprint progress, timing and the current story come from
9// sprint-status.yaml; the next step of a finished sprint from the session state.
10const SCRIPT = '.claude/statusline.sh'
11const SPRINT_YAML = 'production/sprint-status.yaml'
12const SESSION_STATE = 'production/session-state/active.md'
13const EPICS = 'production/epics'
14const DROPPED_SEGMENTS = 2
15const REFRESH_MS = 60_000
16const DAY_MS = 86_400_000
17const BAR_CELLS = 10
18// Below WIDE the epic crumb and the timing go; below NARROW the rigor, the
19// bar and the next step go too.
20const WIDE = 100
21const NARROW = 70
22// A story the band names when the Task slot is empty, most urgent first.
23const CURRENT_STATUSES = ['in-progress', 'review']
24const PANE = 'status-band'
25const SEP = ' › '
26
27const band = atom({ plugin: 'status-band', key: 'band' } as const, null)
28const pane = atom({ plugin: 'status-band', key: 'pane' } as const, { kind: 'stories' } as PaneView)
29
30export type SprintStory = { id: string; name: string; file: string; status: string }
31
32export type Status = { stage: string; rigor: string | null; crumbs: string[] }
33
34export const parseStatus = (output: string): Status | null => {
35 const segments = output.trim().split(' | ').slice(DROPPED_SEGMENTS)
36 const [head = '', ...rest] = segments
37 const [stage = '', ...rigor] = head.split(' · ')
38
39 if (stage.trim() === '') {
40 return null
41 }
42
43 const crumbs = rest.join(' | ').split(' > ').map(c => c.trim()).filter(c => c !== '')
44
45 return { stage: stage.trim(), rigor: rigor.length > 0 ? rigor.join(' · ') : null, crumbs }
46}
47
48const unquote = (value: string): string => value.trim().replace(/^"(.*)"$/, '$1')
49
50// sprint-status.yaml is written by /sprint-plan in one fixed shape, so a line
51// parse of the story keys is enough and keeps the mod free of a yaml library.
52export const parseSprint = (yaml: string): SprintStory[] => {
53 const stories: SprintStory[] = []
54
55 for (const line of yaml.split('\n')) {
56 const match = /^\s*(-\s+)?(id|name|file|status):\s*(.*)$/.exec(line)
57
58 if (match === null) {
59 continue
60 }
61
62 const [, dash, key, value = ''] = match
63 const last = stories.at(-1)
64
65 if (dash !== undefined && key === 'id') {
66 stories.push({ id: unquote(value), name: '', file: '', status: '' })
67 } else if (dash === undefined && last !== undefined && key !== 'id') {
68 last[key as 'name' | 'file' | 'status'] = unquote(value)
69 }
70 }
71
72 return stories
73}
74
75// The sprint's own keys sit unindented at the top of the same file.
76export const parseSprintHeader = (yaml: string): { goal: string | null; start: string | null; end: string | null } => {
77 const key = (name: string) => {
78 const match = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(yaml)
79 const value = match === null ? '' : unquote(match[1] ?? '')
80
81 return value === '' ? null : value
82 }
83
84 return { goal: key('goal'), start: key('start'), end: key('end') }
85}
86
87export const progressOf = (stories: SprintStory[]): Progress | null => {
88 if (stories.length === 0) {
89 return null
90 }
91
92 const count = (status: string) => stories.filter(s => s.status === status).length
93
94 return {
95 done: count('done'),
96 review: count('review'),
97 inProgress: count('in-progress'),
98 blocked: count('blocked'),
99 total: stories.length,
100 }
101}
102
103export const isComplete = (progress: Band['progress']): boolean =>
104 typeof progress === 'object' && progress !== null && progress.done === progress.total
105
106// Whole days since the epoch, in the session's own time zone.
107export const dayNumber = (ms: number): number =>
108 Math.floor((ms - new Date(ms).getTimezoneOffset() * 60_000) / DAY_MS)
109
110const dayOf = (iso: string | null): number | null => {
111 const match = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
112
113 return match === null ? null : Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / DAY_MS
114}
115
116export const timingOf = (start: string | null, end: string | null, today: number): Sprint['timing'] => {
117 const first = dayOf(start)
118 const last = dayOf(end)
119
120 if (first !== null && today < first) return { kind: 'starts', days: first - today }
121 if (last !== null && today <= last) return { kind: 'left', days: last - today }
122 if (last !== null) return { kind: 'over', days: today - last }
123
124 return null
125}
126
127export const timingText = (timing: NonNullable<Sprint['timing']>): string => {
128 if (timing.kind === 'starts') return `starts in ${timing.days}d`
129 if (timing.kind === 'over') return `${timing.days}d over`
130
131 return timing.days === 0 ? 'last day' : `${timing.days}d left`
132}
133
134// The first slash command of the **Next step:** line, with its one argument.
135export const nextStepOf = (state: string): string | null => {
136 const line = /^\*\*Next step:\*\*\s*(.*)$/m.exec(state)?.[1] ?? ''
137 const match = /(?:^|\s)(\/[a-z][\w-]*)(?:\s+(?!then\b|and\b|or\b)([a-z][\w-]*))?/.exec(line)
138
139 if (match === null) {
140 return null
141 }
142
143 return match[2] === undefined ? (match[1] ?? null) : `${match[1]} ${match[2]}`
144}
145
146const hrefOf = (path: string) => encodeURI(`file://${path}`)
147
148const markdownFile = (path: string, title: string): FileRef => ({ path, title, format: 'markdown' })
149
150// The Task slot (a third crumb) wins when it names a sprint story: that crumb
151// becomes the link. With no Task, the first in-progress or review story is.
152export const currentStory = (
153 stories: SprintStory[],
154 crumbs: string[],
155 root: string,
156): { crumbs: string[]; story: Story | null } => {
157 const byId = new Map(stories.map(s => [s.id, s]))
158 const idOf = (crumb: string) => crumb.split(' ')[0] ?? ''
159 const storyOf = (label: string, task: SprintStory): Story => {
160 const path = `${root}/${task.file}`
161
162 return { label, href: hrefOf(path), file: markdownFile(path, task.id) }
163 }
164 const label = crumbs.find(c => byId.has(idOf(c)))
165 const task = label === undefined ? undefined : byId.get(idOf(label))
166
167 if (label !== undefined && task !== undefined) {
168 return { crumbs: crumbs.filter(c => c !== label), story: storyOf(label, task) }
169 }
170
171 if (crumbs.length >= 3) {
172 return { crumbs, story: null }
173 }
174
175 for (const status of CURRENT_STATUSES) {
176 const found = stories.find(s => s.status === status)
177
178 if (found !== undefined) {
179 return { crumbs, story: storyOf(`${found.id} ${found.name}`, found) }
180 }
181 }
182
183 return { crumbs, story: null }
184}
185
186export const sprintOf = (yaml: string, stories: SprintStory[], root: string, today: number): Sprint => {
187 const { goal, start, end } = parseSprintHeader(yaml)
188
189 return {
190 file: { path: `${root}/${SPRINT_YAML}`, title: 'sprint-status.yaml', format: 'yaml' },
191 goal,
192 timing: timingOf(start, end, today),
193 rows: stories.map(s => ({
194 id: s.id,
195 name: s.name,
196 status: s.status,
197 file: markdownFile(`${root}/${s.file}`, s.id),
198 })),
199 }
200}
201
202export const slugOf = (crumb: string): string =>
203 crumb.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
204
205const isSprintCrumb = (crumb: string) => /^sprint\b/i.test(crumb)
206
207export type Layout = Band & { bar: boolean; timing: boolean }
208
209export const layout = (value: Band, columns: number): Layout => ({
210 ...value,
211 rigor: columns < NARROW ? null : value.rigor,
212 crumbs: columns < WIDE && value.crumbs.length > 1 ? value.crumbs.slice(1) : value.crumbs,
213 nextStep: columns < NARROW ? null : value.nextStep,
214 bar: columns >= NARROW,
215 timing: columns >= WIDE,
216})
217
218// Never a full bar short of done, never an empty one past zero.
219export const filledCells = ({ done, total }: Progress): number => {
220 const cells = Math.round((done / total) * BAR_CELLS)
221
222 if (done === total) return BAR_CELLS
223 if (done === 0) return 0
224
225 return Math.min(BAR_CELLS - 1, Math.max(1, cells))
226}
227
228export const SEGMENT_KINDS = ['done', 'review', 'inProgress', 'blocked'] as const
229
230export type SegmentKind = (typeof SEGMENT_KINDS)[number]
231
232// Cells per status for the terminal bar: done keeps filledCells' rule, every
233// other status with a story gets at least one cell, and an empty cell stays
234// while any story has not started.
235export const segments = (progress: Progress): Record<SegmentKind, number> => {
236 const cells: Record<SegmentKind, number> = { done: filledCells(progress), review: 0, inProgress: 0, blocked: 0 }
237 const others = ['inProgress', 'review', 'blocked'] as const
238
239 for (const kind of others) {
240 if (progress[kind] > 0) {
241 cells[kind] = Math.max(1, Math.round((progress[kind] / progress.total) * BAR_CELLS))
242 }
243 }
244
245 const notStarted = progress.total - SEGMENT_KINDS.reduce((sum, kind) => sum + progress[kind], 0)
246 const room = notStarted > 0 ? BAR_CELLS - 1 : BAR_CELLS
247 const sum = () => SEGMENT_KINDS.reduce((total, kind) => total + cells[kind], 0)
248
249 if (notStarted === 0 && progress.done < progress.total) {
250 while (sum() < BAR_CELLS) {
251 const largest = others.reduce((a, b) => (progress[b] > progress[a] ? b : a))
252 cells[largest] += 1
253 }
254 }
255
256 while (sum() > room) {
257 const largest = SEGMENT_KINDS.filter(kind => cells[kind] > 1).sort((a, b) => cells[b] - cells[a])[0]
258
259 if (largest === undefined) break
260 cells[largest] -= 1
261 }
262
263 return cells
264}
265
266const THEME_COLORS: Record<SegmentKind, string> = {
267 done: 'success',
268 review: 'warning',
269 inProgress: 'suggestion',
270 blocked: 'error',
271}
272
273// The desktop bar: raw colors, since an Svg is drawn outside the theme.
274const SVG_COLORS: Record<SegmentKind, string> = {
275 done: '#4eba65',
276 review: '#e5a53b',
277 inProgress: '#7c9cf5',
278 blocked: '#e5484d',
279}
280
281const escapeXml = (text: string) =>
282 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
283
284// `title` is the hover tooltip: an interactive Svg draws its <title> natively.
285export const barSvg = (progress: Progress, title: string): string => {
286 const width = 100
287 const height = 6
288 let x = 0
289 const rects = SEGMENT_KINDS.map(kind => {
290 const w = (progress[kind] / progress.total) * width
291 const rect = w > 0 ? `<rect x="${x.toFixed(2)}" width="${w.toFixed(2)}" height="${height}" fill="${SVG_COLORS[kind]}"/>` : ''
292 x += w
293
294 return rect
295 }).join('')
296
297 return (
298 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
299 `<title>${escapeXml(title)}</title>` +
300 `<clipPath id="r"><rect width="${width}" height="${height}" rx="3"/></clipPath>` +
301 `<g clip-path="url(#r)"><rect width="${width}" height="${height}" fill="#8888884d"/>${rects}</g></svg>`
302 )
303}
304
305export const progressAlt = (progress: Progress): string => {
306 const extra = [
307 progress.review > 0 ? `${progress.review} in review` : '',
308 progress.inProgress > 0 ? `${progress.inProgress} in progress` : '',
309 progress.blocked > 0 ? `${progress.blocked} blocked` : '',
310 ].filter(part => part !== '')
311
312 return [`${progress.done} of ${progress.total} stories done`, ...extra].join(', ')
313}
314
315const STATUS_MARKS: Record<string, { mark: string; color: string }> = {
316 done: { mark: '✓', color: 'success' },
317 review: { mark: '◐', color: 'warning' },
318 'in-progress': { mark: '▶', color: 'suggestion' },
319 blocked: { mark: '✕', color: 'error' },
320}
321
322// undefined: the file is absent; null: it is there but could not be read.
323const readText = async ($: EngineInterface, path: string): Promise<string | null | undefined> => {
324 try {
325 if (!(await $.fs.exists(path))) {
326 return undefined
327 }
328
329 const text = await $.fs.read(path)
330
331 return typeof text === 'string' ? text : null
332 } catch {
333 return null
334 }
335}
336
337// A run that cannot start or outlives its timeout rejects; it counts as a
338// failed run, so the band hands the draw back instead of the hook throwing.
339const runScript = async ($: EngineInterface): Promise<Status | null> => {
340 try {
341 const { exitCode, stdout } = await $.process.run(['bash', SCRIPT], {
342 stdin: JSON.stringify({ model: { display_name: '' } }),
343 timeoutMs: 10_000,
344 })
345
346 return exitCode === 0 ? parseStatus(stdout) : null
347 } catch {
348 return null
349 }
350}
351
352// The first crumb of two or more is the epic when its folder has an EPIC.md.
353const epicOf = async ($: EngineInterface, root: string, crumbs: string[]): Promise<Band['epic']> => {
354 const crumb = crumbs.length >= 2 ? crumbs[0] : undefined
355
356 if (crumb === undefined) {
357 return null
358 }
359
360 const path = `${root}/${EPICS}/${slugOf(crumb)}/EPIC.md`
361 const exists = await $.fs.exists(path).catch(() => false)
362
363 return exists ? { crumb, file: markdownFile(path, crumb) } : null
364}
365
366const refresh = async ($: EngineInterface, root: string) => {
367 const status = await runScript($)
368 let next: Band | null = null
369
370 if (status !== null) {
371 const yaml = await readText($, `${root}/${SPRINT_YAML}`)
372 const stories = typeof yaml === 'string' ? parseSprint(yaml) : []
373 const { crumbs, story } = currentStory(stories, status.crumbs, root)
374 const progress = yaml === null ? 'unreadable' : progressOf(stories)
375 const today = dayNumber(await $.clock.now())
376 const sprint = typeof yaml === 'string' && stories.length > 0 ? sprintOf(yaml, stories, root, today) : null
377 const state = isComplete(progress) ? await readText($, `${root}/${SESSION_STATE}`) : undefined
378
379 next = {
380 ...status,
381 crumbs,
382 epic: await epicOf($, root, crumbs),
383 progress,
384 sprint,
385 story,
386 nextStep: typeof state === 'string' ? nextStepOf(state) : null,
387 }
388 }
389
390 const before = await read($, band)
391
392 if (JSON.stringify(before) !== JSON.stringify(next)) {
393 await update($, band, () => next)
394 }
395}
396
397const openFile = async ($: EngineInterface, file: FileRef) => {
398 const text = await readText($, file.path)
399
400 const view: PaneView = { kind: 'file', file, text: typeof text === 'string' ? text : null }
401
402 await update($, pane, () => view)
403 await $.ui.open({ id: PANE, title: file.title })
404}
405
406const openStories = async ($: EngineInterface) => {
407 const view: PaneView = { kind: 'stories' }
408
409 await update($, pane, () => view)
410 await $.ui.open({ id: PANE, title: 'Sprint stories' })
411}
412
413export const register: Register = on => {
414 let root = '.'
415
416 on('session.start', async ($, e, next) => {
417 root = e.cwd
418 // The interval first, so a first refresh that fails still leaves it running.
419 $.clock.every(REFRESH_MS, () => refresh($, root))
420 await refresh($, root)
421
422 return next(e)
423 })
424
425 on('turn.complete', async ($, e, next) => {
426 await refresh($, root)
427
428 return next(e)
429 })
430
431 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
432 const value = await read($, band)
433
434 if (e.props.hasSurvey || value === null) {
435 return next(e)
436 }
437
438 const { Box, Button, Link, Text } = $.ui.resolve(e)
439 const isTerminal = e.surface === 'terminal'
440 const shown = layout(value, e.props.bodyColumns)
441 const parts: RenderNode[] = []
442 const sep = (key: string) => <Text key={key} dimColor>{SEP}</Text>
443 // The terminal opens a file: link in the person's editor; the desktop opens
444 // no file: link, so there the crumb is a Button that shows the file in a pane.
445 const opener = (key: string, label: string, file: FileRef) =>
446 isTerminal
447 ? <Text key={key}><Link href={hrefOf(file.path)} label={label} /></Text>
448 : <Button key={key} plain label={label} onPress={() => void openFile($, file)} />
449
450 parts.push(<Text key="stage" bold>{shown.stage}</Text>)
451
452 if (shown.rigor !== null) {
453 parts.push(<Text key="rigor" dimColor>·{shown.rigor}</Text>)
454 }
455
456 shown.crumbs.forEach((crumb, i) => {
457 parts.push(sep(`crumb-sep-${i}`))
458
459 if (shown.epic !== null && crumb === shown.epic.crumb) {
460 parts.push(opener('epic', crumb, shown.epic.file))
461 } else if (shown.sprint !== null && isSprintCrumb(crumb)) {
462 parts.push(opener('sprint', crumb, shown.sprint.file))
463 } else {
464 parts.push(<Text key={`crumb-${i}`}>{crumb}</Text>)
465 }
466 })
467
468 if (shown.progress === 'unreadable') {
469 parts.push(<Text key="progress" dimColor>{' sprint ?'}</Text>)
470 } else if (shown.progress !== null) {
471 const progress = shown.progress
472 const count = `${progress.done}/${progress.total}`
473
474 parts.push(<Text key="progress-gap">{' '}</Text>)
475
476 if (isComplete(progress)) {
477 parts.push(
478 <Button key="progress" plain onPress={() => void openStories($)}>
479 <Text color="success">✓ complete</Text>
480 <Text dimColor>{` ${count}`}</Text>
481 </Button>,
482 )
483 } else if (shown.bar && isTerminal) {
484 const cells = segments(progress)
485 const empty = BAR_CELLS - SEGMENT_KINDS.reduce((sum, kind) => sum + cells[kind], 0)
486
487 parts.push(
488 <Button key="progress" plain onPress={() => void openStories($)}>
489 {SEGMENT_KINDS.filter(kind => cells[kind] > 0).map(kind => (
490 <Text color={THEME_COLORS[kind]}>{'▰'.repeat(cells[kind])}</Text>
491 ))}
492 {empty > 0 && <Text dimColor>{'▱'.repeat(empty)}</Text>}
493 {` ${count}`}
494 </Button>,
495 )
496 } else {
497 if (shown.bar && e.surface !== 'terminal') {
498 const { Svg } = $.ui.resolve(e)
499
500 const alt = progressAlt(progress)
501 const goal = shown.sprint?.goal
502 const title = goal === null || goal === undefined ? alt : `Goal: ${goal}\n${alt}`
503
504 parts.push(<Svg key="bar" source={barSvg(progress, title)} alt={alt} width={100} height={6} isInteractive />)
505 parts.push(<Text key="bar-gap">{' '}</Text>)
506 }
507
508 parts.push(<Button key="progress" plain label={count} onPress={() => void openStories($)} />)
509 }
510
511 if (progress.blocked > 0) {
512 parts.push(<Text key="blocked" color="error">{` ${progress.blocked} blocked`}</Text>)
513 }
514
515 if (!isComplete(progress) && shown.timing && shown.sprint?.timing) {
516 const timing = shown.sprint.timing
517
518 parts.push(
519 timing.kind === 'over'
520 ? <Text key="timing" color="warning">{` ${timingText(timing)}`}</Text>
521 : <Text key="timing" dimColor>{` ${timingText(timing)}`}</Text>,
522 )
523 }
524
525 if (isComplete(progress) && shown.nextStep !== null) {
526 parts.push(sep('next-sep'))
527 parts.push(<Text key="next" dimColor>next </Text>)
528 parts.push(<Text key="next-step">{shown.nextStep}</Text>)
529 }
530 }
531
532 if (shown.story !== null) {
533 parts.push(sep('story-sep'))
534 parts.push(opener('story', shown.story.label, shown.story.file))
535 }
536
537 return <Box flexDirection="row" alignItems="center">{parts}</Box>
538 })
539
540 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541 const { Box, Button, Code, Markdown, Text } = $.ui.resolve(e)
542 const view = await read($, pane)
543 const sprint = (await read($, band))?.sprint ?? null
544
545 if (view.kind === 'file') {
546 return (
547 <Box flexDirection="column">
548 {sprint !== null && (
549 <Button key="back" plain dimColor label="‹ Sprint stories" onPress={() => void openStories($)} />
550 )}
551 {view.text === null
552 ? <Text dimColor>{`Could not read ${view.file.path}`}</Text>
553 : view.file.format === 'yaml'
554 ? <Code source={view.text} language="yaml" />
555 : <Markdown text={view.text} />}
556 </Box>
557 )
558 }
559
560 if (sprint === null) {
561 return <Text dimColor>No sprint file.</Text>
562 }
563
564 return (
565 <Box flexDirection="column">
566 {sprint.goal !== null && <Text dimColor wrap="wrap">{sprint.goal}</Text>}
567 {sprint.rows.map(row => {
568 const mark = STATUS_MARKS[row.status] ?? { mark: '·', color: 'inactive' }
569
570 return (
571 <Button key={`story-${row.id}`} plain onPress={() => void openFile($, row.file)}>
572 <Text color={mark.color}>{`${mark.mark} `}</Text>
573 {`${row.id} ${row.name} `}
574 <Text dimColor>{row.status}</Text>
575 </Button>
576 )
577 })}
578 <Button key="sprint-file" plain dimColor label="Open sprint-status.yaml" onPress={() => void openFile($, sprint.file)} />
579 </Box>
580 )
581 })
582}
583types/index.d.ts 46 lines1// A file the band opens: absolute path, the pane's title, how the pane draws it.
2export type FileRef = { path: string; title: string; format: 'markdown' | 'yaml' }
3
4export type Story = { label: string; href: string; file: FileRef }
5
6export type Progress = {
7 done: number
8 review: number
9 inProgress: number
10 blocked: number
11 total: number
12}
13
14export type SprintRow = { id: string; name: string; status: string; file: FileRef }
15
16export type Sprint = {
17 file: FileRef
18 goal: string | null
19 // Whole days, from the yaml's start and end against today.
20 timing: { kind: 'starts'; days: number } | { kind: 'left'; days: number } | { kind: 'over'; days: number } | null
21 rows: SprintRow[]
22}
23
24export type Band = {
25 stage: string
26 rigor: string | null
27 crumbs: string[]
28 // The crumb that names the epic, with its EPIC.md when there is one.
29 epic: { crumb: string; file: FileRef } | null
30 // null: no sprint file; 'unreadable': the file is there but failed to read.
31 progress: Progress | 'unreadable' | null
32 sprint: Sprint | null
33 story: Story | null
34 // The first slash command of the session state's **Next step:** line.
35 nextStep: string | null
36}
37
38// What the band's pane shows: the sprint's stories, or one file.
39export type PaneView = { kind: 'stories' } | { kind: 'file'; file: FileRef; text: string | null }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'status-band': { band: Band | null; pane: PaneView }
44 }
45}
46