Story progress band for /run-project: done vs remaining PRD stories above the prompt while a run is in progress

<strong>HQ</strong> — the team AI operating system
<em>Your AI agents have amnesia. HQ gives them your company's memory.</em>
<img src="docs/images/magic-moment.svg" alt="A terminal: before HQ the agent has no memory of your codebase; after npx create-hq it answers who owns billing with the team, the decision record, and the on-call." width="760"/>
<a href="https://github.com/indigoai-us/hq-core/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-6b7cff.svg" alt="License: MIT"/></a> <a href="https://www.npmjs.com/package/create-hq"><img src="https://img.shields.io/npm/v/create-hq?color=6b7cff&label=create-hq" alt="create-hq on npm"/></a> <a href="https://docs.hq.computer"><img src="https://img.shields.io/badge/docs-hq.computer-6b7cff.svg" alt="Docs"/></a>
HQ is an open-source team AI operating system — a shared, synced context and capability layer over Claude Code, Codex, Cursor, and Grok. It gives your AI coding agents your whole company's memory — knowledge, decisions, people, skills, and policies — so they stop forgetting everything every session.
We call that default failure Agent Amnesia: every session, the agent knows nothing about your stack, your conventions, or the call you made last week. You re-explain. It re-discovers. Nothing compounds. HQ is the cure.
Prefer to follow along? Start with the guided HQ tutorial — seven short videos with matching written steps and screenshots.
npx create-hq
That scaffolds your HQ and wires it into the agents you already use. Prefer the CLI directly?
npm install -g @indigoai-us/hq-cli
npx create-hq → HQ indexes the company: repos, decisions, people, skills.services/billing — Sarah's team, see ADR-014, last touched in PR #482." It knows your whole company.@indigoai-us/hq-pack-* packages.The more org memory accumulates in HQ, the smarter your agents get — and the higher the cost of starting over somewhere else. The memory is both the moat and the marketing. Read the thesis: Context is the moat.
MIT © Indigo AI, Inc.
hooks/register.tsx 422 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Story } from '../types'
5import { bar, formatDuration, isRunProjectSkill, summarize } from './progress'
6import type { ProgressEvent } from './progress'
7import {
8 applyPipeline,
9 changes,
10 parseInvocation,
11 pipelineStoriesPath,
12 runFromState,
13 safePrdPath,
14 statePath,
15} from './run-state'
16import type { Invocation, OrchestratorState, Prd } from './run-state'
17import { capabilityPath, lockedCompanies, stateScope } from './scope'
18
19const PANE = 'story-progress'
20const POLL_MS = 5_000
21
22const runAtom = atom({ plugin: 'run-project', key: 'run' } as const, null)
23const watchAtom = atom({ plugin: 'run-project', key: 'watch' } as const, null)
24const isHidden = atom({ plugin: 'run-project', key: 'isHidden' } as const, false)
25
26const ICON: Record<Story['status'], string> = {
27 done: '✓',
28 failed: '✗',
29 blocked: '⊘',
30 running: '▶',
31 pending: '·',
32}
33
34const COLOR: Record<Story['status'], string | undefined> = {
35 done: 'success',
36 failed: 'error',
37 blocked: 'warning',
38 running: 'claude',
39 pending: undefined,
40}
41
42let timer: { cancel: () => void } | undefined
43
44function statusLine(run: Run | null, now: number) {
45 if (run === null) return undefined
46 const s = summarize(run, now)
47 const bad = s.failed + s.blocked
48
49 return `stories ${s.done}/${s.total}${bad > 0 ? ` · ${bad} ✗` : ''}${s.isComplete ? ' · finished' : ''}`
50}
51
52function announce($: EngineInterface, events: ProgressEvent[], run: Run, now: number) {
53 const s = summarize(run, now)
54 for (const one of events) {
55 const name = one.kind === 'complete' ? '' : `${one.story}${'title' in one && one.title ? ` ${one.title}` : ''}`
56 if (one.kind === 'done') $.ui.toast(`✓ ${name} · ${s.done}/${s.total} done`)
57 if (one.kind === 'failed') $.ui.toast(`✗ ${name} failed`)
58 if (one.kind === 'blocked') $.ui.toast(`⊘ ${name} blocked`)
59 if (one.kind === 'gate' && !one.passed) $.ui.toast(`Regression gate after ${one.story} failed`)
60 if (one.kind === 'complete') {
61 $.ui.toast(`${run.project}: ${s.done} done, ${s.failed} failed, ${s.blocked} blocked`)
62 }
63 }
64}
65
66function stopTimer() {
67 timer?.cancel()
68 timer = undefined
69}
70
71async function readJson<T>($: EngineInterface, path: string): Promise<T> {
72 return JSON.parse(await $.fs.read(path)) as T
73}
74
75const brief = (error: unknown) => String(error).slice(0, 80)
76
77/** state.json as an object, or why it is not one. */
78async function readState($: EngineInterface, path: string): Promise<{ state?: OrchestratorState; error?: string }> {
79 try {
80 const parsed = await readJson<unknown>($, path)
81 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'not a JSON object' }
82 return { state: parsed as OrchestratorState }
83 } catch (error) {
84 return { error: brief(error) }
85 }
86}
87
88/** Identifies one run's state.json across reads. */
89const runKey = (state: OrchestratorState) => `${String(state.started_at)}|${String(state.updated_at)}`
90
91/**
92 * The companies this session is locked to, from its scope capability. Like the
93 * shell resolver, a company whose folder is missing or a symbolic link grants nothing.
94 */
95async function sessionCompanies($: EngineInterface): Promise<string[]> {
96 const sessionId = await $.session.id()
97 const path = capabilityPath(sessionId)
98 if (path === undefined || !(await $.fs.exists(path))) return []
99 try {
100 const locked = lockedCompanies(await readJson<unknown>($, path), sessionId)
101 for (const company of locked) {
102 if (company === 'personal') continue
103 const folder = await $.fs.stat(`companies/${company}`)
104 if (folder.kind !== 'dir' || folder.isLink) return []
105 }
106 return locked
107 } catch {
108 return []
109 }
110}
111
112/** The PRD state.json names; `note` says why it could not be read. */
113async function readPrd($: EngineInterface, prdPath: string | undefined): Promise<{ prd: Prd | null; note?: string }> {
114 if (prdPath === undefined || !(await $.fs.exists(prdPath))) return { prd: null }
115 try {
116 return { prd: await readJson<Prd>($, prdPath) }
117 } catch (error) {
118 return { prd: null, note: `PRD unreadable: ${brief(error)}` }
119 }
120}
121
122/** `--pipeline` story files; one that does not parse is left out. */
123async function readPipeline($: EngineInterface, project: string): Promise<unknown[]> {
124 const dir = pipelineStoriesPath(project)
125 if (!(await $.fs.exists(dir))) return []
126 const stories: unknown[] = []
127 for (const file of await $.fs.list(dir)) {
128 if (file.kind !== 'file' || !file.name.endsWith('.json')) continue
129 try {
130 stories.push(await readJson<unknown>($, `${dir}/${file.name}`))
131 } catch {
132 // A file the conductor is mid-write on; the next read picks it up.
133 }
134 }
135 return stories
136}
137
138/** Reads the watched project's state.json (and its PRD) into the band. */
139async function refresh($: EngineInterface) {
140 const watch = await read($, watchAtom)
141 if (watch === null) {
142 stopTimer()
143 return
144 }
145 const path = statePath(watch.project)
146 // state.json names the run's company, so nothing is shown without it.
147 if (!(await $.fs.exists(path))) {
148 await update($, watchAtom, () => ({ ...watch, note: `waiting for ${path}` }))
149 return
150 }
151
152 const loaded = await readState($, path)
153 if (loaded.state === undefined) {
154 // Keep the last good view; the band shows why it stopped moving.
155 await update($, watchAtom, () => ({ ...watch, note: `state.json unreadable: ${loaded.error}` }))
156 return
157 }
158 const state = loaded.state
159 if (watch.priorRun !== undefined && state.status === 'completed' && runKey(state) === watch.priorRun) {
160 await update($, watchAtom, () => ({ ...watch, note: 'last run completed; waiting for this run to start' }))
161 return
162 }
163
164 const prdPath = safePrdPath(state.prd_path)
165 const company = typeof state.company === 'string' ? state.company : undefined
166 const scope = stateScope(company, prdPath, await sessionCompanies($))
167 if (!scope.isAllowed) {
168 await update($, runAtom, () => null)
169 await update($, watchAtom, () => ({ ...watch, ...(scope.note ? { note: scope.note } : {}) }))
170 $.ui.status(undefined)
171 return
172 }
173
174 const pipeline = watch.isPipeline ? await readPipeline($, watch.project) : []
175 const { prd, note } = await readPrd($, prdPath)
176 const fromState = runFromState(watch.project, state, prd)
177 const run = watch.isPipeline ? applyPipeline(fromState, pipeline) : fromState
178 const before = await read($, runAtom)
179 const now = await $.clock.now()
180 await update($, runAtom, () => run)
181 await update($, watchAtom, () => ({
182 project: watch.project,
183 ...(watch.isPipeline ? { isPipeline: true } : {}),
184 ...(note ? { note } : {}),
185 }))
186 $.ui.status(statusLine(run, now))
187 announce($, changes(before, run), run, now)
188 if (run.isFinished) stopTimer()
189}
190
191/** A refresh from the timer: a failure goes on the band instead of ending the polling. */
192async function poll($: EngineInterface) {
193 try {
194 await refresh($)
195 } catch (error) {
196 const watch = await read($, watchAtom)
197 if (watch !== null) await update($, watchAtom, () => ({ ...watch, note: `refresh failed: ${brief(error)}` }))
198 }
199}
200
201/**
202 * Starts reading a run. `isNewRun` (the skill just expanded): a completed
203 * state.json already on disk belongs to an earlier run, so keep polling until
204 * this one rewrites it.
205 */
206async function startWatching($: EngineInterface, invocation: Invocation, isNewRun: boolean) {
207 stopTimer()
208 const path = statePath(invocation.project)
209 let priorRun: string | undefined
210 if (isNewRun && (await $.fs.exists(path))) {
211 const { state } = await readState($, path)
212 if (state?.status === 'completed') priorRun = runKey(state)
213 }
214 await update($, runAtom, () => null)
215 $.ui.status(undefined)
216 await update($, watchAtom, () => ({
217 project: invocation.project,
218 ...(invocation.isPipeline ? { isPipeline: true } : {}),
219 ...(priorRun === undefined ? {} : { priorRun }),
220 }))
221 await update($, isHidden, () => false)
222 await poll($)
223 if ((await read($, runAtom))?.isFinished !== true) {
224 timer = $.clock.every(POLL_MS, () => void poll($))
225 }
226}
227
228/** `/story-progress`, which exists only once /run-project has run in the session. */
229async function registerCommand($: EngineInterface) {
230 await $.command.register({
231 name: 'story-progress',
232 description: 'The /run-project progress band: show | hide | pane | stop | watch <project>',
233 })
234}
235
236export const register: Register = on => {
237 on('session.start', async ($, e, next) => {
238 // Nothing happens until /run-project runs. A reload during a run resets
239 // module variables, so a watch the session still holds is picked back up.
240 const watch = await read($, watchAtom)
241 if (watch !== null) {
242 await registerCommand($)
243 stopTimer()
244 timer = $.clock.every(POLL_MS, () => void poll($))
245 }
246
247 return next(e)
248 })
249
250 on('command.run', { command: 'story-progress' }, async ($, e) => {
251 const [verb = '', ...rest] = e.args
252 .trim()
253 .split(/\s+/)
254 .filter(Boolean)
255 .map(word => word.replace(/[.,;:!?]+$/, ''))
256
257 if (verb === 'watch') {
258 const invocation = parseInvocation(`**User's input:** ${rest.join(' ')}`)
259 if (invocation === undefined) return { text: 'Usage: /story-progress watch <project> [--pipeline]' }
260 await startWatching($, invocation, false)
261 return { text: `Watching ${statePath(invocation.project)}.` }
262 }
263 if (verb === 'show' || verb === '') {
264 await update($, isHidden, () => false)
265 return { text: 'Story progress band shown.' }
266 }
267 if (verb === 'hide') {
268 await update($, isHidden, () => true)
269 return { text: 'Story progress band hidden. /story-progress show brings it back.' }
270 }
271 if (verb === 'pane') {
272 await $.ui.open({ id: PANE, title: 'Stories' })
273 return { text: 'Story progress pane opened.' }
274 }
275 if (verb === 'stop') {
276 stopTimer()
277 await update($, runAtom, () => null)
278 await update($, watchAtom, () => null)
279 $.ui.status(undefined)
280 return { text: 'Story progress cleared.' }
281 }
282
283 return { text: `Unknown option "${verb}". Use show, hide, pane, stop or watch <project>.` }
284 })
285
286 on('skill.prompt', async ($, e, next) => {
287 const result = await next(e)
288 const invocation = isRunProjectSkill(e.skill) ? parseInvocation(e.text) : undefined
289 if (invocation !== undefined) {
290 await registerCommand($)
291 await startWatching($, invocation, true)
292 }
293
294 return result
295 })
296
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 const run = await read($, runAtom)
299 const watch = await read($, watchAtom)
300 if (e.props.hasSurvey || (await read($, isHidden)) || (run === null && watch === null)) {
301 return next(e)
302 }
303
304 const { Box, Button, Text } = $.ui.resolve(e)
305 const hide = <Button key="hide" label="Hide" dimColor onPress={() => update($, isHidden, () => true)} />
306
307 if (run === null) {
308 return (
309 <Box gap={1}>
310 <Text color="claude" bold>
311 run-project
312 </Text>
313 <Text dimColor>{watch?.project}</Text>
314 <Text dimColor>{watch?.note ?? 'reading state.json'}</Text>
315 {hide}
316 </Box>
317 )
318 }
319
320 const s = summarize(run, await $.clock.now())
321 const width = Math.max(8, Math.min(24, e.props.bodyColumns - 70))
322 const lastGate = run.gates[run.gates.length - 1]
323
324 return (
325 <Box flexDirection="column">
326 <Box gap={1}>
327 <Text color="claude" bold>
328 run-project
329 </Text>
330 <Text dimColor>{run.project}</Text>
331 <Text color="success">{bar(s.done, s.total, width)}</Text>
332 <Text bold>
333 {s.done}/{s.total} done
334 </Text>
335 {s.failed + s.blocked > 0 && (
336 <Text color="error">
337 {s.failed} failed{s.blocked > 0 ? `, ${s.blocked} blocked` : ''}
338 </Text>
339 )}
340 <Text>{s.remaining} left</Text>
341 </Box>
342 <Box gap={1}>
343 {s.running ? (
344 <Text wrap="truncate-end">
345 ▶ {s.running.id}
346 {s.running.title ? ` ${s.running.title}` : ''}
347 {s.runningForMs === undefined ? '' : ` (${formatDuration(s.runningForMs)})`}
348 </Text>
349 ) : (
350 <Text color={s.isComplete ? 'success' : s.remaining === 0 && s.total > 0 ? 'warning' : undefined}>
351 {s.isComplete
352 ? 'Run finished'
353 : s.remaining === 0 && s.total > 0
354 ? 'Stopped on failed or blocked stories'
355 : 'Between stories'}
356 </Text>
357 )}
358 {!s.isComplete && s.etaMs !== undefined && <Text dimColor>ETA ~{formatDuration(s.etaMs)}</Text>}
359 {lastGate && (
360 <Text color={lastGate.passed ? 'success' : 'error'}>
361 gate {lastGate.passed ? '✓' : '✗'} {lastGate.afterStory}
362 </Text>
363 )}
364 {watch?.note && <Text color="warning">{watch.note}</Text>}
365 <Button
366 key="details"
367 label="Details"
368 dimColor
369 onPress={() => void $.ui.open({ id: PANE, title: 'Stories' })}
370 />
371 {hide}
372 </Box>
373 </Box>
374 )
375 })
376
377 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
378 const { Box, Text } = $.ui.resolve(e)
379 const run = await read($, runAtom)
380 if (run === null) {
381 return <Text dimColor>No run in progress. /run-project starts one.</Text>
382 }
383 const now = await $.clock.now()
384 const s = summarize(run, now)
385
386 return (
387 <Box flexDirection="column">
388 <Text bold>
389 {run.project} · {s.done}/{s.total} done · {s.remaining} left
390 </Text>
391 <Text dimColor>
392 avg {s.avgMs === undefined ? 'n/a' : formatDuration(s.avgMs)} per story · elapsed{' '}
393 {formatDuration(Math.max(0, now - run.startedAt))}
394 </Text>
395 <Text> </Text>
396 {run.stories.map(one => (
397 <Box key={one.id} gap={1}>
398 <Text color={COLOR[one.status]}>{ICON[one.status]}</Text>
399 <Text dimColor={one.status === 'pending'}>{one.id}</Text>
400 <Text dimColor={one.status === 'pending'} wrap="truncate-end">
401 {one.title}
402 </Text>
403 <Text dimColor>
404 {one.worker ?? ''}
405 {one.startedAt !== undefined && (one.finishedAt !== undefined || one.status === 'running')
406 ? ` · ${formatDuration((one.finishedAt ?? now) - one.startedAt)}`
407 : ''}
408 </Text>
409 </Box>
410 ))}
411 <Text> </Text>
412 <Text dimColor>
413 Gates:{' '}
414 {run.gates.length === 0
415 ? 'none yet'
416 : run.gates.map(g => `${g.passed ? '✓' : '✗'} ${g.afterStory}`).join(' ')}
417 </Text>
418 </Box>
419 )
420 })
421}
422hooks/progress.ts 69 lines1import type { Run, Story } from '../types'
2
3export type Summary = {
4 total: number
5 done: number
6 failed: number
7 blocked: number
8 remaining: number
9 running: Story | undefined
10 runningForMs: number | undefined
11 avgMs: number | undefined
12 etaMs: number | undefined
13 isComplete: boolean
14}
15
16export const isFinished = (one: Story) =>
17 one.status === 'done' || one.status === 'failed' || one.status === 'blocked'
18
19export function summarize(run: Run, now: number): Summary {
20 const count = (status: Story['status']) => run.stories.filter(one => one.status === status).length
21 const timed = run.stories.filter(
22 one => isFinished(one) && one.startedAt !== undefined && one.finishedAt !== undefined,
23 )
24 const avgMs =
25 timed.length === 0
26 ? undefined
27 : timed.reduce((sum, one) => sum + (one.finishedAt! - one.startedAt!), 0) / timed.length
28 const remaining = count('pending') + count('running')
29 const running = run.stories.find(one => one.status === 'running')
30 const etaMs = avgMs === undefined || remaining === 0 ? undefined : avgMs * remaining
31
32 return {
33 total: run.stories.length,
34 done: count('done'),
35 failed: count('failed'),
36 blocked: count('blocked'),
37 remaining,
38 running,
39 runningForMs: running?.startedAt === undefined ? undefined : Math.max(0, now - running.startedAt),
40 avgMs,
41 etaMs,
42 // Only the orchestrator says a run is over: with every story finished but
43 // some failed or blocked, a run stays open for a retry or an owner's answer.
44 isComplete: run.isFinished,
45 }
46}
47
48export type ProgressEvent =
49 | { kind: 'done' | 'failed' | 'blocked'; story: string; title: string }
50 | { kind: 'gate'; story: string; passed: boolean }
51 | { kind: 'complete' }
52
53export function bar(done: number, total: number, width: number): string {
54 const filled = total === 0 ? 0 : Math.round((done / total) * width)
55
56 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
57}
58
59export function formatDuration(ms: number): string {
60 const s = Math.round(ms / 1000)
61 if (s < 60) return `${s}s`
62 const m = Math.floor(s / 60)
63 if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`
64
65 return `${Math.floor(m / 60)}h ${m % 60}m`
66}
67
68export const isRunProjectSkill = (skill: string) => /(^|:|-)run-project$/.test(skill)
69hooks/run-state.ts 228 lines1import type { Run, Story } from '../types'
2import { isFinished } from './progress'
3import type { ProgressEvent } from './progress'
4
5/**
6 * The fields of workspace/orchestrator/{project}/state.json this reads
7 * (schema: core/settings/orchestrator.yaml). Every field is checked before use:
8 * other processes write the file and it may hold anything.
9 */
10export type OrchestratorState = {
11 status?: unknown
12 company?: unknown
13 started_at?: unknown
14 updated_at?: unknown
15 prd_path?: unknown
16 progress?: unknown
17 current_tasks?: unknown
18 completed_tasks?: unknown
19 failed_tasks?: unknown
20 blocked_tasks?: unknown
21 regression_gates?: unknown
22}
23
24/** The fields of the project's prd.json this reads. */
25export type Prd = {
26 userStories?: unknown
27}
28
29/** What a /run-project invocation runs. */
30export type Invocation = { project: string; isPipeline: boolean }
31
32type Fields = Record<string, unknown>
33
34/** The object entries of an array field; anything else reads as empty. */
35export const entries = (value: unknown): Fields[] =>
36 Array.isArray(value) ? value.filter((one): one is Fields => one !== null && typeof one === 'object') : []
37
38const text = (value: unknown) => (typeof value === 'string' ? value : undefined)
39
40const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
41
42/** Flags under which /run-project reports and stops instead of running stories. */
43const NON_RUN_FLAGS = new Set(['--status', '--dry-run', '--help', '-h'])
44
45/** Options written `--name value` (`--name=value` is one word already). */
46const VALUE_FLAGS = new Set(['--timeout', '--account'])
47
48export const statePath = (project: string) => `workspace/orchestrator/${project}/state.json`
49
50export const pipelineStoriesPath = (project: string) => `workspace/orchestrator/${project}/pipeline/state/stories`
51
52/**
53 * The project a /run-project invocation runs, read from the skill's expanded
54 * text (`**User's input:** {project} [flags]`); undefined when it runs none.
55 */
56export function parseInvocation(prompt: string): Invocation | undefined {
57 const line = /\*\*User's input:\*\*[ \t]*(.*)/.exec(prompt)?.[1]
58 if (line === undefined) return undefined
59 const words = line.trim().split(/\s+/).filter(Boolean)
60 if (words.some(word => NON_RUN_FLAGS.has(word))) return undefined
61 const positional = words.filter((word, i) => !word.startsWith('-') && !VALUE_FLAGS.has(words[i - 1] ?? ''))
62 const project = positional[0]
63
64 return project !== undefined && SAFE_NAME.test(project)
65 ? { project, isPipeline: words.includes('--pipeline') }
66 : undefined
67}
68
69/** A PRD path state.json names, kept when it stays inside the HQ root. */
70export function safePrdPath(path: unknown): string | undefined {
71 if (typeof path !== 'string' || path === '' || path.startsWith('/') || path.split('/').includes('..')) {
72 return undefined
73 }
74
75 return path
76}
77
78const time = (iso: unknown) => {
79 const ms = typeof iso === 'string' ? Date.parse(iso) : NaN
80 return Number.isNaN(ms) ? undefined : ms
81}
82
83/** A failed_tasks entry that recorded a block (`blocked: TIMEOUT`, `blocked: true`). */
84const isBlockedFailure = (one: Fields) => one.blocked === true || /^blocked\b/i.test(text(one.error) ?? '')
85
86/** Each entry's id with the time its `at` field names. */
87function byId(list: unknown, at: string) {
88 const found = new Map<string, number | undefined>()
89 for (const one of entries(list)) {
90 const id = text(one.id)
91 if (id) found.set(id, time(one[at]))
92 }
93 return found
94}
95
96export function runFromState(project: string, state: OrchestratorState, prd: Prd | null): Run {
97 const startedAt = time(state.started_at) ?? 0
98 const completed = byId(state.completed_tasks, 'completed_at')
99 const current = byId(state.current_tasks, 'started_at')
100 const blocked = byId(state.blocked_tasks, 'timestamp')
101 const failed = new Map<string, number | undefined>()
102 for (const one of entries(state.failed_tasks)) {
103 const id = text(one.id)
104 if (!id) continue
105 if (isBlockedFailure(one)) blocked.set(id, time(one.timestamp))
106 else failed.set(id, time(one.timestamp))
107 }
108
109 const fromPrd = entries(prd?.userStories).filter(one => typeof one.id === 'string')
110 const ids =
111 fromPrd.length > 0
112 ? fromPrd.map(one => one.id as string)
113 : [...new Set([...completed.keys(), ...failed.keys(), ...blocked.keys(), ...current.keys()])]
114 const meta = new Map(fromPrd.map(one => [one.id as string, one]))
115
116 const stories: Story[] = ids.map(id => {
117 const one = meta.get(id)
118 const worker = text(one?.worker)
119 const base = { id, title: text(one?.title) ?? '', ...(worker ? { worker } : {}) }
120 const at = (finishedAt: number | undefined) => (finishedAt === undefined ? {} : { finishedAt })
121 if (one?.passes === true || completed.has(id)) return { ...base, status: 'done', ...at(completed.get(id)) }
122 if (current.has(id)) {
123 const runningSince = current.get(id)
124 return { ...base, status: 'running', ...(runningSince === undefined ? {} : { startedAt: runningSince }) }
125 }
126 if (blocked.has(id)) return { ...base, status: 'blocked', ...at(blocked.get(id)) }
127 if (failed.has(id)) return { ...base, status: 'failed', ...at(failed.get(id)) }
128 if (one?.status === 'blocked') return { ...base, status: 'blocked' }
129
130 return { ...base, status: 'pending' }
131 })
132
133 // Without a PRD, state.json's total says how many stories are still to come.
134 const progress = state.progress !== null && typeof state.progress === 'object' ? (state.progress as Fields) : {}
135 const total = typeof progress.total === 'number' ? progress.total : 0
136 for (let n = stories.length; fromPrd.length === 0 && n < total; n++) {
137 stories.push({ id: `#${n + 1}`, title: '', status: 'pending' })
138 }
139
140 timeBackToBack(stories, startedAt)
141
142 return {
143 project,
144 isFinished: state.status === 'completed',
145 startedAt,
146 stories,
147 gates: entries(state.regression_gates)
148 .filter(g => typeof g.after_story === 'string')
149 .map(g => ({ afterStory: g.after_story as string, passed: g.passed === true })),
150 }
151}
152
153/**
154 * Lanes record when a story finished, not when it began: time finished stories
155 * (done, failed or blocked) back to back from the run's start, in the order
156 * they finished.
157 */
158function timeBackToBack(stories: Story[], startedAt: number) {
159 let previous = startedAt
160 for (const one of stories
161 .filter(s => isFinished(s) && s.finishedAt !== undefined)
162 .sort((a, b) => a.finishedAt! - b.finishedAt!)) {
163 one.startedAt = Math.min(previous, one.finishedAt!)
164 previous = one.finishedAt!
165 }
166}
167
168/** pipeline-conductor.sh story states, as the band counts them. */
169const PIPELINE_STATUS: Record<string, Story['status'] | 'skipped'> = {
170 verified: 'done',
171 accepted_partial: 'done',
172 in_flight: 'running',
173 awaiting_recheck: 'running',
174 blocked_needs_owner: 'blocked',
175 parked: 'blocked',
176 parked_dependency: 'blocked',
177 failed_report: 'failed',
178 skipped: 'skipped',
179}
180
181/**
182 * Lays `--pipeline` story files (pipeline/state/stories/{id}.json) over the
183 * run: the conductor owns story state in pipeline mode and does not set
184 * `passes`. Stories state.json does not list are added; skipped ones dropped.
185 */
186export function applyPipeline(run: Run, files: unknown[]): Run {
187 const stories = run.stories.map(one => ({ ...one }))
188 const skipped = new Set<string>()
189 for (const file of entries(files)) {
190 const id = text(file.id)
191 if (!id) continue
192 const status = PIPELINE_STATUS[text(file.state) ?? ''] ?? 'pending'
193 if (status === 'skipped') {
194 skipped.add(id)
195 continue
196 }
197 let one = stories.find(s => s.id === id)
198 if (one === undefined) {
199 one = { id, title: text(file.title) ?? '', status }
200 stories.push(one)
201 }
202 one.status = status
203 if (status === 'running' || status === 'pending') delete one.finishedAt
204 if (status === 'running' && typeof file.routed_at === 'number') one.startedAt = file.routed_at * 1000
205 }
206
207 return { ...run, stories: stories.filter(one => !skipped.has(one.id)) }
208}
209
210/** What changed between two reads of a run, as toasts announce it. */
211export function changes(before: Run | null, after: Run): ProgressEvent[] {
212 if (before === null) return []
213 const was = new Map(before.stories.map(one => [one.id, one.status]))
214 const events: ProgressEvent[] = []
215 for (const one of after.stories) {
216 if (!isFinished(one) || was.get(one.id) === one.status) continue
217 if (one.status === 'done' || one.status === 'failed' || one.status === 'blocked') {
218 events.push({ kind: one.status, story: one.id, title: one.title })
219 }
220 }
221 for (const gate of after.gates.slice(before.gates.length)) {
222 events.push({ kind: 'gate', story: gate.afterStory, passed: gate.passed })
223 }
224 if (after.isFinished && !before.isFinished) events.push({ kind: 'complete' })
225
226 return events
227}
228hooks/scope.ts 70 lines1/**
2 * The session's company lock, read the way core/scripts/lib/session-scope-capability.sh
3 * reads it, so a run is only shown for a company this session may see.
4 */
5
6const SLUG = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
7const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
8
9export const capabilityPath = (sessionId: string) =>
10 SESSION.test(sessionId) ? `workspace/sessions/${sessionId}/scope-capability.json` : undefined
11
12/**
13 * The companies a scope-capability.json grants this session; [] when malformed.
14 * Only the primary company is granted: the shell resolver also checks the
15 * multi-company kill switch, which a mod cannot read, before it grants the rest
16 * of company_slugs. The set is still validated, so a tampered file grants nothing.
17 */
18export function lockedCompanies(cap: unknown, sessionId: string): string[] {
19 if (cap === null || typeof cap !== 'object') return []
20 const { session_id, agent_id, company_slug, company_slugs } = cap as Record<string, unknown>
21 if (session_id !== sessionId || (agent_id ?? '') !== '') return []
22 if (typeof company_slug !== 'string' || !SLUG.test(company_slug)) return []
23 if (company_slugs === undefined) return [company_slug]
24
25 const set = company_slugs
26 const isValid =
27 Array.isArray(set) &&
28 set.length > 0 &&
29 set.every(one => typeof one === 'string' && SLUG.test(one)) &&
30 set[0] === company_slug &&
31 new Set(set).size === set.length &&
32 (company_slug === 'personal' ? set.length === 1 : !set.includes('personal'))
33
34 return isValid ? [company_slug] : []
35}
36
37/** The company a PRD path is under: `companies/<slug>/...`, or `personal/...`. */
38function prdCompany(prdPath: string): string | undefined {
39 const parts = prdPath.split('/')
40 if (parts[0] === 'personal') return 'personal'
41 return parts[0] === 'companies' && parts[1] !== undefined && parts[1] !== '' ? parts[1] : undefined
42}
43
44/**
45 * Whether a run may be shown under the lock, and why not when it may not. The
46 * run's company is state.json's `company`, else the company its PRD is under;
47 * both, when present, must be locked.
48 */
49export function stateScope(
50 stateCompany: string | undefined,
51 prdPath: string | undefined,
52 locked: string[],
53): { isAllowed: boolean; note?: string } {
54 if (locked.length === 0) return { isAllowed: false, note: 'run not shown: this session has no company lock' }
55 const fromPrd = prdPath === undefined ? undefined : prdCompany(prdPath)
56 if (prdPath !== undefined && fromPrd === undefined) {
57 return { isAllowed: false, note: 'run not shown: its PRD is outside companies/' }
58 }
59 if (stateCompany === undefined && fromPrd === undefined) {
60 return { isAllowed: false, note: 'run not shown: state.json names no company' }
61 }
62 for (const one of [stateCompany, fromPrd]) {
63 if (one !== undefined && !locked.includes(one)) {
64 return { isAllowed: false, note: `run not shown: ${one} is outside this session's companies` }
65 }
66 }
67
68 return { isAllowed: true }
69}
70types/index.d.ts 42 lines1export type StoryStatus = 'pending' | 'running' | 'done' | 'failed' | 'blocked'
2
3export type Story = {
4 id: string
5 title: string
6 worker?: string
7 status: StoryStatus
8 startedAt?: number
9 finishedAt?: number
10}
11
12export type Gate = { afterStory: string; passed: boolean }
13
14export type Run = {
15 project: string
16 /** The orchestrator marked the run completed (state.json `status`). */
17 isFinished: boolean
18 startedAt: number
19 stories: Story[]
20 gates: Gate[]
21}
22
23/** The project /run-project is running, and what reading its state last said. */
24export type Watch = {
25 project: string
26 /** `/run-project --pipeline`: story state is in pipeline/state/stories/. */
27 isPipeline?: boolean
28 /**
29 * The `started_at|updated_at` of a completed state.json left by an earlier
30 * run, seen when this invocation began; polling goes on until it changes.
31 */
32 priorRun?: string
33 /** What the last read could not show, and why. */
34 note?: string
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'run-project': { run: Run | null; watch: Watch | null; isHidden: boolean }
40 }
41}
42