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

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 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/).
aidlc/ folder (an AI-DLC project). Anywhere else it runs nothing and shows nothingCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)intents.json/aidlc-phase opens it there too, above the prompt/aidlc-phase opens it at any width/aidlc-phase opens it again/aidlc --doctor)Known limits:
/aidlc-phase stops being placed, the one-line display comes back at the end of the next replydisplay 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.
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.
claude plugin validate .
claude plugin test .hooks/register.tsx 259 lines1import { 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}
259hooks/pane.tsx 85 lines1import 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}
85hooks/progress.ts 301 lines1import 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}
301types/index.d.ts 43 lines1export 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