SLOPSHOPPER

aidlc-phase

Show every AI-DLC stage and where the work is in a pane beside the chat, or the phase and stage on a line separate from your own statusLine

newpanecommandstatusprocess
v?no licenseupdated 2026-10-2070-10/aidlc-phase
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aidlc-phase
│ ┃ AI-DLC ✕ › fix the failing auth test and add an audit log call │ ┃ No intent in progress │ ┃ Start work with /aidlc to see progress here ⏺ 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 │ │ › /aidlc-phase │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · AI-DLC
No intent in progress Start work with /aidlc to see progress here
README

aidlc-phase

A Claude Code mod that shows where an AI-DLC workflow is. By default it lists all 33 AI-DLC stages in a pane beside the chat: which stages this Intent runs, which it skips, how far each one is, and the stage under way. It can show one line below the prompt instead. Either way you keep your own statusLine; the project's statusLine does not have to be replaced by AI-DLC's.

The pane listing all 33 AI-DLC stages, with the stage under way highlighted

The one-line display:

⚠ aidlc-phase: [AIDLC] my-feature · INCEPTION [▓▓▓░░░░░░░] 3/9 > User Stories

The mod is hooks/register.tsx. It reads the AI-DLC files in hooks/progress.ts and draws the pane in hooks/pane.tsx; the values the pane draws from are declared in types/index.d.ts. Each has its tests beside it (*.test.ts, *.test.tsx, with shared test data in hooks/fixtures.ts). The repository is also the marketplace (.claude-plugin/).

When it runs

  • Only when the working folder or one of its parents has an aidlc/ folder (an AI-DLC project). Anywhere else it runs nothing and shows nothing
  • Function hooks must be enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)
  • The Intent shown is the one AI-DLC's own status line shows; the pane looks up its record in the space's intents.json

The pane

  • It opens by itself when a session starts in the fullscreen layout. Claude Code places a pane it was not asked for once the terminal is 144 columns wide (110 once you have opened it yourself); until then the one-line display shows instead, and it goes as soon as the pane is drawn
  • On the main screen (not fullscreen) it does not open by itself and the one-line display shows. /aidlc-phase opens it there too, above the prompt
  • /aidlc-phase opens it at any width
  • Once you close it, neither the pane nor the line shows for the rest of the session, until /aidlc-phase opens it again
  • It is updated when the session starts and at the end of each reply
  • When the height does not fit every stage, finished phases fold into one row each, then the skipped stages of each phase into one row
  • When the newest state cannot be read, the last list stays with a note above it; with nothing read before, the pane says how to check (/aidlc --doctor)

Known limits:

  • A change of terminal height alone does not redraw the pane, so the folding follows the height as of the last width change
  • When a pane opened by /aidlc-phase stops being placed, the one-line display comes back at the end of the next reply

Settings

display picks how it shows: pane (the default) or statusline (one line below the prompt, as before the pane existed). Change it in /config.

With statusline, when no workflow is in progress ([AIDLC] ready), the line is hidden.

Install

Run these in the folder of a project that uses AI-DLC:

claude plugin marketplace add 70-10/aidlc-phase --scope local
claude plugin install aidlc-phase@aidlc-phase --scope local

--scope local writes to .claude/settings.local.json of the repository. Even when run in a subfolder, it writes to the git root.

Auto-update is off by default for this marketplace. To receive updates automatically, turn on auto-update for it in the /plugin screen.

Test

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 259 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PaneView, Progress, UnitProgress } from '../types'
5import { drawPane } from './pane'
6import {
7  approvedUnits,
8  buildProgress,
9  intentFromLine,
10  isUnitMajorConstruction,
11  ProgressReadError,
12  readStageGraph,
13  recordDirFor,
14  shownStep,
15  spaceFromCursor,
16  unitNames,
17  unitProgressOf,
18} from './progress'
19import type { DirectiveMarker } from './progress'
20
21// AI-DLC's own renderer is run instead of reading aidlc-state.md here: which
22// intent a session shows (its binding, the active-intent cursor, archived
23// records, Unit progress) is the engine's rule and changes with each release.
24const COMMAND_SOURCES = [
25  '.claude/tools/data/harness.json',
26  '.claude/settings.local.json',
27  '.claude/settings.json',
28]
29const LEGACY_HOOK = '.claude/hooks/aidlc-statusline.ts'
30const STAGE_GRAPH = '.claude/tools/data/stage-graph.json'
31
32const PANE = 'aidlc-phase'
33const PANE_TITLE = 'AI-DLC'
34
35const view = atom({ plugin: 'aidlc-phase', key: 'view' } as const, { kind: 'loading' } as PaneView)
36const isAutoOpened = atom({ plugin: 'aidlc-phase', key: 'isAutoOpened' } as const, false)
37const isClosedByPerson = atom({ plugin: 'aidlc-phase', key: 'isClosedByPerson' } as const, false)
38
39function isAidlcRenderer(command: string): boolean {
40  return command.includes('engine statusline') || command.includes('aidlc-statusline.ts')
41}
42
43function parent(dir: string): string | null {
44  const cut = dir.replace(/\/+$/, '').lastIndexOf('/')
45  return cut <= 0 ? null : dir.slice(0, cut)
46}
47
48async function projectDir($: EngineInterface, cwd: string): Promise<string | null> {
49  for (let dir: string | null = cwd; dir !== null; dir = parent(dir)) {
50    if (await $.fs.exists(`${dir}/aidlc`)) return dir
51  }
52  return null
53}
54
55async function statusLineCommand($: EngineInterface, project: string): Promise<string | null> {
56  for (const source of COMMAND_SOURCES) {
57    try {
58      const command: unknown = JSON.parse(String(await $.fs.read(`${project}/${source}`)))?.statusLine?.command
59      if (typeof command === 'string' && isAidlcRenderer(command)) return command
60    } catch {
61      // A missing or unreadable file only means the next place may have it.
62    }
63  }
64  return (await $.fs.exists(`${project}/${LEGACY_HOOK}`))
65    ? `bun "$CLAUDE_PROJECT_DIR/${LEGACY_HOOK}"`
66    : null
67}
68
69type StatusLine =
70  | { kind: 'outside' }
71  | { kind: 'ready' }
72  | { kind: 'failed'; reason: string }
73  | { kind: 'shown'; project: string; text: string }
74
75async function readStatusLine($: EngineInterface): Promise<StatusLine> {
76  const project = await projectDir($, await $.session.cwd())
77  if (project === null) return { kind: 'outside' }
78  const command = await statusLineCommand($, project)
79  if (command === null) return { kind: 'outside' }
80  // No model or context figures go in, so the right side carries cost at most;
81  // the person's own status line already shows those.
82  const { exitCode, stdout } = await $.process.run(['sh', '-c', command], {
83    cwd: project,
84    env: { CLAUDE_PROJECT_DIR: project },
85    stdin: JSON.stringify({ session_id: await $.session.id(), workspace: { project_dir: project } }),
86    timeoutMs: 10_000,
87  })
88  if (exitCode !== 0) return { kind: 'failed', reason: `${command} exited ${exitCode}` }
89  const left = (stdout.split('\n')[0] ?? '').replace(/\x1b\[[0-9;]*m/g, '').split(' | ')[0]?.trim() ?? ''
90  return left === '' || left.startsWith('[AIDLC] ready') ? { kind: 'ready' } : { kind: 'shown', project, text: left }
91}
92
93async function phaseLine($: EngineInterface): Promise<string | undefined> {
94  const line = await readStatusLine($)
95  return line.kind === 'shown' ? line.text : undefined
96}
97
98let latest = 0
99
100async function refresh($: EngineInterface): Promise<void> {
101  const mine = ++latest
102  let text: string | undefined
103  try {
104    text = await phaseLine($)
105  } catch {
106    text = undefined
107  }
108  // Runs are not awaited by turn.complete, so an older one can finish last.
109  if (mine === latest) $.ui.status(text)
110}
111
112async function readOptional($: EngineInterface, path: string): Promise<string | null> {
113  return (await $.fs.exists(path)) ? String(await $.fs.read(path)) : null
114}
115
116async function readMarker($: EngineInterface, record: string, stateFile: string): Promise<DirectiveMarker | null> {
117  const path = `${record}/.aidlc-engine/active-directive.json`
118  if (!(await $.fs.exists(path))) return null
119  const [marker, state] = await Promise.all([$.fs.stat(path), $.fs.stat(stateFile)])
120  return { text: String(await $.fs.read(path)), isFresh: marker.mtimeMs >= state.mtimeMs }
121}
122
123async function readUnits($: EngineInterface, record: string, active: string): Promise<UnitProgress | null> {
124  try {
125    const dag = await readOptional($, `${record}/inception/units-generation/unit-of-work-dependency.md`)
126    if (dag === null) return null
127    const units = unitNames(dag)
128    if (units.length === 0) return null
129    const audit = `${record}/audit`
130    const files = (await $.fs.list(audit)).filter(entry => entry.name.endsWith('.md'))
131    const audits = await Promise.all(files.map(async entry => String(await $.fs.read(`${audit}/${entry.name}`))))
132    return unitProgressOf(units, approvedUnits(audits, units), active)
133  } catch (error) {
134    // Unit progress is extra: the stages still show without it, as on AI-DLC's own line.
135    $.ui.log(`aidlc-phase: Unit progress not read: ${messageOf(error)}`, { to: 'debug' })
136    return null
137  }
138}
139
140async function loadProgress($: EngineInterface, project: string, text: string): Promise<Progress> {
141  const intent = intentFromLine(text)
142  if (intent === null) throw new ProgressReadError(`the status line names no intent: ${text}`)
143  const space = intent.space ?? spaceFromCursor(await readOptional($, `${project}/aidlc/active-space`))
144  const intents = `${project}/aidlc/spaces/${space}/intents`
145  const record = `${intents}/${recordDirFor(String(await $.fs.read(`${intents}/intents.json`)), intent.slug)}`
146  const stateFile = `${record}/aidlc-state.md`
147  const state = String(await $.fs.read(stateFile))
148  const graph = readStageGraph(String(await $.fs.read(`${project}/${STAGE_GRAPH}`)))
149  const shown = shownStep(state, await readMarker($, record, stateFile))
150  const units = isUnitMajorConstruction(state) ? await readUnits($, record, shown.unit) : null
151  return buildProgress(intent.slug, state, graph, shown, units)
152}
153
154function messageOf(error: unknown): string {
155  return error instanceof Error ? error.message : String(error)
156}
157
158let latestText: string | undefined
159
160async function showStatusWithoutPane($: EngineInterface): Promise<void> {
161  const [isClosed, panes] = await Promise.all([read($, isClosedByPerson), $.ui.panes()])
162  const isPlaced = panes.some(pane => pane.id === PANE && pane.isPlaced)
163  $.ui.status(isPlaced || isClosed ? undefined : latestText)
164}
165
166async function refreshPane($: EngineInterface): Promise<void> {
167  const mine = ++latest
168  let found: PaneView
169  let text: string | undefined
170  try {
171    const line = await readStatusLine($)
172    text = line.kind === 'shown' ? line.text : undefined
173    found = line.kind === 'shown'
174      ? { kind: 'progress', progress: await loadProgress($, line.project, line.text), isStale: false }
175      : line.kind === 'failed'
176        ? { kind: 'error', reason: line.reason }
177        : { kind: 'none' }
178  } catch (error) {
179    found = { kind: 'error', reason: messageOf(error) }
180  }
181  // Runs are not awaited by turn.complete, so an older one can finish last.
182  if (mine !== latest) return
183  if (found.kind === 'error') $.ui.log(`aidlc-phase: progress not read: ${found.reason}`, { to: 'debug' })
184  latestText = text
185  try {
186    await update($, view, previous =>
187      found.kind === 'error' && previous.kind === 'progress' ? { ...previous, isStale: true } : found,
188    )
189    await showStatusWithoutPane($)
190  } catch (error) {
191    $.ui.log(`aidlc-phase: pane not updated: ${messageOf(error)}`, { to: 'debug' })
192  }
193}
194
195async function startPane($: EngineInterface): Promise<void> {
196  await $.command.register({ name: 'aidlc-phase', description: 'Show AI-DLC progress in a pane beside the chat' })
197  const isInProject = (await projectDir($, await $.session.cwd())) !== null
198  if (isInProject && !(await read($, isClosedByPerson))) {
199    await update($, isAutoOpened, () => true)
200    // Awaiting would hold the session's start on the pane being drawn.
201    void $.ui.open({ id: PANE, title: PANE_TITLE }).catch(error => {
202      $.ui.log(`aidlc-phase: pane not opened: ${messageOf(error)}`, { to: 'debug' })
203    })
204  }
205  await refreshPane($)
206}
207
208export const register: Register = (on, options) => {
209  const isPane = options.display !== 'statusline'
210
211  on('session.start', async ($, e, next) => {
212    const started = await next(e)
213    if (isPane) await startPane($)
214    else await refresh($)
215    return started
216  })
217
218  on('turn.complete', async ($, e, next) => {
219    const done = await next(e)
220    // Awaiting would hold the end of every turn on starting the renderer.
221    void (isPane ? refreshPane($) : refresh($))
222    return done
223  })
224
225  on('command.run', { command: 'aidlc-phase' }, async $ => {
226    await update($, isClosedByPerson, () => false)
227    await update($, isAutoOpened, () => false)
228    await $.ui.open({ id: PANE, title: PANE_TITLE })
229    void refreshPane($)
230    return {}
231  })
232
233  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
234    const elements = $.ui.resolve(e)
235    // Whether the layout docks a pane is known only once one is drawn, so an
236    // unasked pane on the main screen is opened first and closed here.
237    if (e.viewport?.isFullscreen === false && (await read($, isAutoOpened))) {
238      void $.ui.close({ id: PANE }).catch(error => {
239        $.ui.log(`aidlc-phase: pane not closed: ${messageOf(error)}`, { to: 'debug' })
240      })
241      $.ui.status(latestText)
242      const { Box } = elements
243      return <Box />
244    }
245    $.ui.status(undefined)
246    const size = { bodyRows: e.props.scroll.bodyRows, bodyColumns: e.props.bodyColumns }
247    return drawPane(elements, await read($, view), size)
248  })
249
250  on('ui.close', { id: PANE }, async ($, e, next) => {
251    const closed = await next(e)
252    if (e.origin.kind === 'person') {
253      await update($, isClosedByPerson, () => true)
254      $.ui.status(undefined)
255    }
256    return closed
257  })
258}
259
hooks/pane.tsx 85 lines
1import type { ElementTable } from 'claude-code'
2
3import type { PaneView, StageTone } from '../types'
4import {
5  EMPTY_LINES,
6  ERROR_LINES,
7  LOADING_LINE,
8  MARK_SYMBOL,
9  UNIT_SYMBOL,
10  layoutPane,
11} from './progress'
12import type { PaneLine } from './progress'
13
14export const TONE_COLORS: Record<StageTone, string> = {
15  always: 'suggestion',
16  conditional: 'warning',
17  none: 'inactive',
18}
19
20export type PaneSize = { bodyRows: number; bodyColumns: number }
21
22function drawLine({ Text }: ElementTable, line: PaneLine, size: PaneSize) {
23  switch (line.kind) {
24    case 'notice':
25      return <Text bold wrap="truncate-end">{line.text}</Text>
26    case 'summary':
27      return <Text wrap="truncate-end">{line.text}</Text>
28    case 'blank':
29      return <Text> </Text>
30    case 'phase':
31      return <Text bold>{line.text}</Text>
32    case 'phase-done':
33      return <Text wrap="truncate-end">✓ {line.text}</Text>
34    case 'stage':
35      return (
36        <Text inverse={line.isCurrent} bold={line.isCurrent} wrap="truncate-end">
37          {MARK_SYMBOL[line.stage.mark]} <Text color={TONE_COLORS[line.stage.tone]}>{line.stage.number} {line.stage.name}</Text>
38          {line.unitLabel === '' ? '' : `  ${line.unitLabel}`}
39        </Text>
40      )
41    case 'unit':
42      return <Text wrap="truncate-end">{`    ${UNIT_SYMBOL[line.unit.mark]} ${line.unit.name}`}</Text>
43    case 'skipped':
44      return <Text wrap="truncate-end">– <Text color={TONE_COLORS.none}>{String(line.count)} skipped</Text></Text>
45    case 'rule':
46      return <Text dimColor>{'─'.repeat(Math.max(1, size.bodyColumns))}</Text>
47    case 'legend':
48      return <Text wrap="truncate-end">{line.text}</Text>
49    case 'legend-tones':
50      return (
51        <Text wrap="truncate-end">
52          Color: <Text color={TONE_COLORS.always}>■ always</Text> <Text color={TONE_COLORS.conditional}>■ conditional</Text>{' '}
53          <Text color={TONE_COLORS.none}>■ skipped</Text>
54        </Text>
55      )
56  }
57}
58
59function drawMessage({ Box, Text }: ElementTable, lines: readonly string[], detail = '') {
60  return (
61    <Box flexDirection="column">
62      {lines.map(text => <Text>{text}</Text>)}
63      {detail === '' ? [] : [<Text dimColor>{detail}</Text>]}
64    </Box>
65  )
66}
67
68export function drawPane(elements: ElementTable, view: PaneView, size: PaneSize) {
69  const { Box } = elements
70  switch (view.kind) {
71    case 'loading':
72      return drawMessage(elements, [LOADING_LINE])
73    case 'none':
74      return drawMessage(elements, EMPTY_LINES)
75    case 'error':
76      return drawMessage(elements, ERROR_LINES, view.reason)
77    case 'progress':
78      return (
79        <Box flexDirection="column">
80          {layoutPane(view.progress, size.bodyRows, view.isStale).map(line => drawLine(elements, line, size))}
81        </Box>
82      )
83  }
84}
85
hooks/progress.ts 301 lines
1import type { Progress, StageMark, StageRow, StageTone, UnitMark, UnitProgress, UnitRow } from '../types'
2
3export class ProgressReadError extends Error {
4  constructor(message: string) {
5    super(message)
6    this.name = 'ProgressReadError'
7  }
8}
9
10export type StageInfo = { slug: string; number: string; name: string; phase: string; execution: 'ALWAYS' | 'CONDITIONAL' }
11
12export type IntentRef = { space: string | null; slug: string }
13
14const SLUG = /^[a-z0-9][a-z0-9-]*$/
15const UNIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
16const SPACE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
17const RECORD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
18
19export function intentFromLine(left: string): IntentRef | null {
20  const body = left.replace(/^\[AIDLC\]\s*/, '')
21  const segments = body.split(' · ').map(part => part.trim())
22  if (segments.length >= 3 && segments[1]) {
23    return SPACE_NAME.test(segments[0] ?? '') ? { space: segments[0]!, slug: segments[1] } : null
24  }
25  if (segments.length === 2 && segments[0]) return { space: null, slug: segments[0] }
26  return null
27}
28
29export function spaceFromCursor(text: string | null): string {
30  const name = (text ?? '').trim()
31  return SPACE_NAME.test(name) ? name : 'default'
32}
33
34type IntentEntry = { slug?: unknown; dirName?: unknown; status?: unknown }
35
36export function recordDirFor(intentsJson: string, slug: string): string {
37  let rows: unknown
38  try {
39    rows = JSON.parse(intentsJson)
40  } catch {
41    throw new ProgressReadError('intents.json is not JSON')
42  }
43  if (!Array.isArray(rows)) throw new ProgressReadError('intents.json is not a list')
44  const named = (rows as IntentEntry[]).filter(
45    row => row?.slug === slug && typeof row.dirName === 'string' && RECORD_NAME.test(row.dirName),
46  )
47  if (named.length === 1) return named[0]!.dirName as string
48  const inFlight = named.filter(row => row.status === 'in-flight')
49  if (inFlight.length === 1) return inFlight[0]!.dirName as string
50  throw new ProgressReadError(
51    named.length === 0 ? `no intent named ${slug}` : `${named.length} intents named ${slug}, ${inFlight.length} in flight`,
52  )
53}
54
55export function readStageGraph(json: string): StageInfo[] {
56  let rows: unknown
57  try {
58    rows = JSON.parse(json)
59  } catch {
60    throw new ProgressReadError('stage-graph.json is not JSON')
61  }
62  if (!Array.isArray(rows)) throw new ProgressReadError('stage-graph.json is not a list')
63  const stages: StageInfo[] = []
64  for (const row of rows as Array<Record<string, unknown>>) {
65    // Stages a plugin adds carry `plugin`; only AI-DLC's own are listed.
66    if (row === null || typeof row !== 'object' || row.plugin !== undefined) continue
67    const { slug, number, name, phase, execution } = row
68    if (
69      typeof slug !== 'string' || typeof number !== 'string' || typeof name !== 'string' ||
70      typeof phase !== 'string' || (execution !== 'ALWAYS' && execution !== 'CONDITIONAL')
71    ) {
72      throw new ProgressReadError(`stage-graph.json has a stage it cannot read: ${String(slug)}`)
73    }
74    stages.push({ slug, number, name, phase, execution })
75  }
76  if (stages.length === 0) throw new ProgressReadError('stage-graph.json lists no stage')
77  return stages
78}
79
80export function extractField(state: string, label: string): string {
81  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
82  const match = new RegExp(`^-\\s*\\*\\*${escaped}\\*\\*:[^\\S\\n]*([^\\n]*)`, 'm').exec(state)
83  return match ? match[1]!.replace(/\r$/, '').trim() : ''
84}
85
86function stageProgressSection(state: string): string[] {
87  const lines = state.split(/\r?\n/)
88  const start = lines.findIndex(line => /^##\s+Stage Progress\s*$/.test(line))
89  if (start < 0) throw new ProgressReadError('aidlc-state.md has no Stage Progress')
90  const rest = lines.slice(start + 1)
91  const end = rest.findIndex(line => /^##\s/.test(line))
92  return end < 0 ? rest : rest.slice(0, end)
93}
94
95const EXECUTE_MARKS: Record<string, StageMark> = { ' ': 'todo', '-': 'running', '?': 'awaiting', R: 'revising', x: 'done' }
96
97function markOf(box: string, kind: string): StageMark | null {
98  if (box === 'S') return 'skipped'
99  if (kind === 'SKIP') return box === ' ' ? 'skipped' : null
100  return EXECUTE_MARKS[box] ?? null
101}
102
103export function readStageProgress(state: string, graph: readonly StageInfo[]): StageRow[] {
104  const known = new Map(graph.map(stage => [stage.slug, stage]))
105  const marks = new Map<string, StageMark>()
106  for (const line of stageProgressSection(state)) {
107    const row = /^- \[(.)\] (\S+) — (\S+)\s*$/.exec(line)
108    if (!row) continue
109    const [, box, slug, kind] = row as unknown as [string, string, string, string]
110    if (!known.has(slug)) continue
111    const mark = kind === 'EXECUTE' || kind === 'SKIP' ? markOf(box, kind) : null
112    if (mark === null) throw new ProgressReadError(`aidlc-state.md row it cannot read: ${line.trim()}`)
113    marks.set(slug, mark)
114  }
115  return graph.map(stage => {
116    const mark = marks.get(stage.slug)
117    if (mark === undefined) throw new ProgressReadError(`aidlc-state.md has no row for ${stage.slug}`)
118    const tone: StageTone = mark === 'skipped' ? 'none' : stage.execution === 'ALWAYS' ? 'always' : 'conditional'
119    return { slug: stage.slug, number: stage.number, name: stage.name, phase: stage.phase, mark, tone }
120  })
121}
122
123export function countPlanned(stages: readonly StageRow[]): { done: number; planned: number } {
124  const planned = stages.filter(stage => stage.mark !== 'skipped')
125  return { done: planned.filter(stage => stage.mark === 'done').length, planned: planned.length }
126}
127
128export type DirectiveMarker ={ text: string; isFresh: boolean }
129
130// The same step AI-DLC's own status line shows (its shownStep): the Unit Stage
131// field, else the active-directive file when nothing has written the state since.
132export function shownStep(state: string, marker: DirectiveMarker | null): { stage: string; unit: string } {
133  const stage = extractField(state, 'Current Stage')
134  const unitStage = extractField(state, 'Unit Stage')
135  if (SLUG.test(unitStage)) return { stage: unitStage, unit: '' }
136  if (marker === null || !marker.isFresh) return { stage, unit: '' }
137  try {
138    const parsed = JSON.parse(marker.text) as { stage?: unknown; unit?: unknown; delivery?: unknown }
139    if (typeof parsed.stage !== 'string' || !SLUG.test(parsed.stage) || parsed.delivery === 'superseded') {
140      return { stage, unit: '' }
141    }
142    const unit = typeof parsed.unit === 'string' && parsed.unit.length <= 64 && UNIT_NAME.test(parsed.unit) ? parsed.unit : ''
143    return { stage: parsed.stage, unit }
144  } catch {
145    // A marker being rewritten reads as no marker, as AI-DLC's own line does.
146    return { stage, unit: '' }
147  }
148}
149
150export function unitNames(dag: string): string[] {
151  const block = /## Machine-Readable Edge Block[\s\S]*?```ya?ml\r?\n([\s\S]*?)```/.exec(dag)?.[1] ?? ''
152  return [...block.matchAll(/^\s*-\s+name:\s*([A-Za-z0-9][A-Za-z0-9._-]{0,63})\s*$/gm)].map(match => match[1]!)
153}
154
155export function approvedUnits(audits: readonly string[], units: readonly string[]): string[] {
156  const rows: Array<{ at: string; isApproved: boolean; unit: string }> = []
157  for (const audit of audits) {
158    for (const entry of audit.split(/\n## /)) {
159      if (!/^\*\*Checkpoint\*\*: construction-unit$/m.test(entry)) continue
160      const event = /^\*\*Event\*\*:\s*(\S+)/m.exec(entry)?.[1] ?? ''
161      if (event !== 'GATE_APPROVED' && event !== 'GATE_REJECTED') continue
162      const unit = /^\*\*Unit\*\*:\s*(.+)$/m.exec(entry)?.[1]?.trim() ?? ''
163      const at = /^\*\*Timestamp\*\*:\s*(.+)$/m.exec(entry)?.[1]?.trim() ?? ''
164      rows.push({ at, isApproved: event === 'GATE_APPROVED', unit })
165    }
166  }
167  const done = new Set<string>()
168  for (const row of rows.sort((a, b) => a.at.localeCompare(b.at))) {
169    if (!units.includes(row.unit)) continue
170    if (row.isApproved) done.add(row.unit)
171    else done.delete(row.unit)
172  }
173  return units.filter(unit => done.has(unit))
174}
175
176export function unitProgressOf(units: readonly string[], approved: readonly string[], active: string): UnitProgress | null {
177  if (units.length === 0 || approved.length >= units.length) return null
178  const rows = units.map(name => {
179    const mark: UnitMark = approved.includes(name) ? 'done' : name === active ? 'running' : 'todo'
180    return { name, mark }
181  })
182  return { current: approved.length + 1, total: units.length, units: rows }
183}
184
185export function isUnitMajorConstruction(state: string): boolean {
186  return extractField(state, 'Construction Iteration') === 'unit-major' && extractField(state, 'Lifecycle Phase') === 'CONSTRUCTION'
187}
188
189export function buildProgress(
190  intent: string,
191  state: string,
192  graph: readonly StageInfo[],
193  shown: { stage: string },
194  units: UnitProgress | null,
195): Progress {
196  const stages = readStageProgress(state, graph)
197  const slugs = new Set(stages.map(stage => stage.slug))
198  const fallback = extractField(state, 'Current Stage')
199  const current = slugs.has(shown.stage) ? shown.stage : slugs.has(fallback) ? fallback : null
200  const { done, planned } = countPlanned(stages)
201  const status = extractField(state, 'Status')
202  const isComplete = status === 'Completed' || status === 'Complete' || (planned > 0 && done === planned)
203  return { intent, stages, current, isComplete, units: current === null ? null : units }
204}
205
206export const MARK_SYMBOL: Record<StageMark, string> = {
207  done: '✓', running: '▶', awaiting: '?', revising: '↺', todo: '·', skipped: '–',
208}
209
210export const UNIT_SYMBOL: Record<UnitMark, string> = { done: '✓', running: '▶', todo: '·' }
211
212export const STALE_NOTICE = 'Could not read the latest state'
213export const EMPTY_LINES = ['No intent in progress', 'Start work with /aidlc to see progress here']
214export const ERROR_LINES = ['Could not read progress', 'Run /aidlc --doctor to find the cause']
215export const LOADING_LINE = 'Loading…'
216export const MARK_LEGEND = ['✓done  ▶running  ?approval  ↺revising', '·todo  –skipped']
217
218export type PaneLine =
219  | { kind: 'notice'; text: string }
220  | { kind: 'summary'; text: string }
221  | { kind: 'blank' }
222  | { kind: 'phase'; text: string }
223  | { kind: 'phase-done'; text: string }
224  | { kind: 'stage'; stage: StageRow; isCurrent: boolean; unitLabel: string }
225  | { kind: 'unit'; unit: UnitRow }
226  | { kind: 'skipped'; count: number }
227  | { kind: 'rule' }
228  | { kind: 'legend'; text: string }
229  | { kind: 'legend-tones' }
230
231export function summaryLines(progress: Progress): string[] {
232  const { done, planned } = countPlanned(progress.stages)
233  const current = progress.stages.find(stage => stage.slug === progress.current)
234  const now = progress.isComplete
235    ? 'Now all done'
236    : current === undefined
237      ? 'Now —'
238      : `Now ${MARK_SYMBOL[current.mark]} ${current.number} ${current.name}${current.mark === 'awaiting' ? ' (approval)' : ''}`
239  return [progress.intent, `Done ${done}/${planned} · ${progress.stages.length} stages total`, now]
240}
241
242type PhaseBlock = { phase: string; stages: StageRow[]; isDoneFolded: boolean; isSkipFolded: boolean }
243
244function phaseBlocks(stages: readonly StageRow[]): PhaseBlock[] {
245  const blocks: PhaseBlock[] = []
246  for (const stage of stages) {
247    const last = blocks.at(-1)
248    if (last?.phase === stage.phase) last.stages.push(stage)
249    else blocks.push({ phase: stage.phase, stages: [stage], isDoneFolded: false, isSkipFolded: false })
250  }
251  return blocks
252}
253
254function isPhaseDone(block: PhaseBlock): boolean {
255  const planned = block.stages.filter(stage => stage.mark !== 'skipped')
256  return planned.length > 0 && planned.every(stage => stage.mark === 'done')
257}
258
259function blockLines(block: PhaseBlock, progress: Progress): PaneLine[] {
260  const label = block.phase.toUpperCase()
261  const skipped = block.stages.filter(stage => stage.mark === 'skipped').length
262  if (block.isDoneFolded) {
263    const planned = block.stages.length - skipped
264    return [{ kind: 'phase-done', text: `${label} ${planned}/${planned} done${skipped > 0 ? ` · ${skipped} skipped` : ''}` }]
265  }
266  const lines: PaneLine[] = [{ kind: 'phase', text: label }]
267  for (const stage of block.stages) {
268    if (block.isSkipFolded && stage.mark === 'skipped') continue
269    const isCurrent = stage.slug === progress.current
270    const units = isCurrent ? progress.units : null
271    lines.push({ kind: 'stage', stage, isCurrent, unitLabel: units ? `Unit ${units.current}/${units.total}` : '' })
272    for (const unit of units?.units ?? []) lines.push({ kind: 'unit', unit })
273  }
274  if (block.isSkipFolded && skipped > 0) lines.push({ kind: 'skipped', count: skipped })
275  return lines
276}
277
278export function layoutPane(progress: Progress, bodyRows: number, isStale: boolean): PaneLine[] {
279  const head: PaneLine[] = [
280    ...(isStale ? [{ kind: 'notice', text: STALE_NOTICE } as const] : []),
281    ...summaryLines(progress).map(text => ({ kind: 'summary', text }) as const),
282    { kind: 'blank' },
283  ]
284  const tail: PaneLine[] = [
285    { kind: 'rule' },
286    ...MARK_LEGEND.map(text => ({ kind: 'legend', text }) as const),
287    { kind: 'legend-tones' },
288  ]
289  const blocks = phaseBlocks(progress.stages)
290  const height = () => head.length + tail.length + blocks.reduce((sum, block) => sum + blockLines(block, progress).length, 0)
291  const folds = [
292    ...blocks.filter(isPhaseDone).map(block => () => { block.isDoneFolded = true }),
293    ...blocks.map(block => () => { block.isSkipFolded = true }),
294  ]
295  for (const fold of folds) {
296    if (height() <= bodyRows) break
297    fold()
298  }
299  return [...head, ...blocks.flatMap(block => blockLines(block, progress)), ...tail]
300}
301
types/index.d.ts 43 lines
1export type StageMark = 'done' | 'running' | 'awaiting' | 'revising' | 'todo' | 'skipped'
2
3export type StageTone = 'always' | 'conditional' | 'none'
4
5export type StageRow = {
6  slug: string
7  number: string
8  name: string
9  phase: string
10  mark: StageMark
11  tone: StageTone
12}
13
14export type UnitMark = 'done' | 'running' | 'todo'
15
16export type UnitRow = { name: string; mark: UnitMark }
17
18export type UnitProgress = { current: number; total: number; units: UnitRow[] }
19
20export type Progress = {
21  intent: string
22  stages: StageRow[]
23  current: string | null
24  isComplete: boolean
25  units: UnitProgress | null
26}
27
28export type PaneView =
29  | { kind: 'loading' }
30  | { kind: 'none' }
31  | { kind: 'error'; reason: string }
32  | { kind: 'progress'; progress: Progress; isStale: boolean }
33
34declare module 'claude-code' {
35  interface PluginState {
36    'aidlc-phase': {
37      view: PaneView
38      isAutoOpened: boolean
39      isClosedByPerson: boolean
40    }
41  }
42}
43