SLOPSHOPPER

run-project

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

newpanebandcommandtoaststatus
★ 89v0.1.0MITupdated 2026-10-16indigoai-us/hq-core/.claude/skills/run-project
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-project
│ ┃ story-progress ✕ › fix the failing auth test and add an audit log call │ ┃ No run in progress. /run-project starts one. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · story-progress
No run in progress. /run-project starts one.
README

<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>


What is HQ?

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.

Quick start

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

The 30-second version

  1. Agent, day one → asks "who owns billing in our codebase?" and has no idea.
  2. npx create-hq → HQ indexes the company: repos, decisions, people, skills.
  3. Same question, now → "services/billing — Sarah's team, see ADR-014, last touched in PR #482." It knows your whole company.

What's inside

  • Shared context layer — one place for your company's knowledge, decisions, and people, read by your agents automatically and synced across the team.
  • Skills & workers — reusable capabilities and specialized agents you build once and share, instead of re-deriving them every session.
  • Policies — durable rules that travel with the company so every agent obeys them.
  • Packs — rich add-ons ship as @indigoai-us/hq-pack-* packages.

Why it compounds

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.

Learn more

License

MIT © Indigo AI, Inc.

Source 5 files
hooks/register.tsx 422 lines
1import { 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}
422
hooks/progress.ts 69 lines
1import 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)
69
hooks/run-state.ts 228 lines
1import 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}
228
hooks/scope.ts 70 lines
1/**
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}
70
types/index.d.ts 42 lines
1export 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