// Status line: Display aidlc workflow position in the terminal status area // Registered via statusLine setting in settings.json // Invoked via: aidlc engine statusline import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs"; import { createRequire } from "node:module"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_SPACE = "default"; const KNOWN_HARNESS_DIRS = [ ".claude", ".kiro", ".codex", ".cursor", ".aidlc", ] as const; type IntentRow = { uuid?: unknown; slug?: unknown; status?: unknown; dirName?: unknown; }; type StatuslineIntent = { slug: string; dirName: string | null; }; function resolveStatuslineProjectDir( importMetaUrl: string, workspaceProjectDir?: string, ): string { for (const value of [ process.env.AIDLC_PROJECT_DIR, workspaceProjectDir, process.env.CLAUDE_PROJECT_DIR, ]) { if (value) { return isAbsolute(value) ? value : resolve(process.cwd(), value); } } const scriptDir = dirname(fileURLToPath(importMetaUrl)); if (basename(scriptDir) === "hooks") { const harnessRoot = dirname(scriptDir); if (basename(harnessRoot).startsWith(".")) return dirname(harnessRoot); } const cwd = process.cwd(); for (const harness of KNOWN_HARNESS_DIRS) { if (existsSync(join(cwd, harness))) return cwd; } return cwd; } function workspaceRoot(projectDir: string): string { return join(projectDir, "aidlc"); } function activeSpace(projectDir: string): string { try { const value = readFileSync( join(workspaceRoot(projectDir), "active-space"), "utf-8", ).trim(); if (value) return value; } catch { // The default space is valid on a fresh shell. } return DEFAULT_SPACE; } function spacesRoot(projectDir: string): string { return join(workspaceRoot(projectDir), "spaces"); } function intentsDir(projectDir: string, space: string): string { return join(spacesRoot(projectDir), space, "intents"); } function listIntentDirs(projectDir: string, space: string): string[] { try { return readdirSync(intentsDir(projectDir, space)) .filter((name) => existsSync(join(intentsDir(projectDir, space), name, "aidlc-state.md")) ) .sort(); } catch { return []; } } function activeIntent( projectDir: string, space = activeSpace(projectDir), ): string | null { const root = intentsDir(projectDir, space); try { const value = readFileSync(join(root, "active-intent"), "utf-8").trim(); if (value && existsSync(join(root, value, "aidlc-state.md"))) return value; } catch { // Fall through to the lone-record rule. } const dirs = listIntentDirs(projectDir, space); return dirs.length === 1 ? dirs[0] : null; } // The statusline is a read-only display on the hot path, so it carries LOCAL // lite copies of the lib's session-selection helpers instead of importing // aidlc-lib.ts (startup cost). Semantics mirror validSessionId / // readSessionBinding / resolveWorkflowSelection / stateFilePathForSelection: // a per-session binding (written by the session hooks) pins the displayed // space/intent; anything malformed or stale degrades to the shared cursors. type StatuslineSelection = { space: string; intent: string | null }; function validSessionId(sessionId: string | undefined): string | null { const raw = sessionId ?? ""; const safe = raw .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 180); if (!safe || safe === "." || safe === "..") return null; return safe === raw ? raw : null; } function readSessionBinding( projectDir: string, sessionId: string, ): StatuslineSelection | null { try { const parsed: unknown = JSON.parse( readFileSync( join( workspaceRoot(projectDir), ".aidlc-sessions", `${sessionId}.binding.json`, ), "utf-8", ), ); if (parsed === null || typeof parsed !== "object") return null; const record = parsed as Record; const space = record.space; const intent = record.intent; if (typeof space !== "string" || !/^[a-z][a-z0-9-]*$/.test(space)) { return null; } if ( intent !== null && (typeof intent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(intent)) ) { return null; } if (typeof record.boundAt !== "string" || record.boundAt.length === 0) { return null; } if ( intent !== null && !existsSync(join(intentsDir(projectDir, space), intent, "aidlc-state.md")) ) { return null; } return { space, intent: intent as string | null }; } catch { return null; } } function resolveWorkflowSelection( projectDir: string, sessionId?: string, ): StatuslineSelection { const binding = sessionId ? readSessionBinding(projectDir, sessionId) : null; if (binding) return binding; const space = activeSpace(projectDir); return { space, intent: activeIntent(projectDir, space) }; } function stateFilePathForSelection( projectDir: string, selection: StatuslineSelection, ): string { const root = selection.intent === null ? intentsDir(projectDir, selection.space) : join(intentsDir(projectDir, selection.space), selection.intent); return join(root, "aidlc-state.md"); } function listSpaces(projectDir: string): string[] { const names = new Set([DEFAULT_SPACE]); try { for (const name of readdirSync(spacesRoot(projectDir))) { if (statSync(join(spacesRoot(projectDir), name)).isDirectory()) { names.add(name); } } } catch { // Fresh workspace: default only. } return [...names].sort(); } function recordDirMatches(row: IntentRow, dirName: string): boolean { if (typeof row.dirName === "string") return row.dirName === dirName; if (typeof row.slug !== "string" || typeof row.uuid !== "string") return false; const id = row.uuid.replace(/-/g, "").slice(-16); return dirName === `${row.slug}-${id}`; } function displaySlugFromDirName(dirName: string): string { const dated = /^\d{6}-(.+)$/.exec(dirName); return dated ? dated[1] : dirName.replace(/-[0-9a-f]+$/, ""); } function listIntents( projectDir: string, space = activeSpace(projectDir), ): StatuslineIntent[] { const dirs = listIntentDirs(projectDir, space); let rows: IntentRow[] = []; try { const parsed = JSON.parse( readFileSync(join(intentsDir(projectDir, space), "intents.json"), "utf-8"), ); if (Array.isArray(parsed)) rows = parsed; } catch { // Orphan directories still appear below. } const claimed = new Set(); const intents = rows.map((row): StatuslineIntent => { const dirName = dirs.find((dir) => recordDirMatches(row, dir)) ?? null; if (dirName) claimed.add(dirName); return { slug: typeof row.slug === "string" ? row.slug : dirName ? displaySlugFromDirName(dirName) : "", dirName, }; }); for (const dirName of dirs) { if (!claimed.has(dirName)) { intents.push({ slug: displaySlugFromDirName(dirName), dirName }); } } return intents; } function agentsDir(projectDir: string): string | null { if (process.env.AIDLC_AGENTS_DIR) return process.env.AIDLC_AGENTS_DIR; const scriptDir = dirname(fileURLToPath(import.meta.url)); if (basename(scriptDir) === "hooks") { const shipped = join(dirname(scriptDir), "agents"); if (existsSync(shipped)) return shipped; } const declared = process.env.AIDLC_HARNESS_DIR; if (declared && existsSync(join(projectDir, declared, "agents"))) { return join(projectDir, declared, "agents"); } for (const harness of KNOWN_HARNESS_DIRS) { const candidate = join(projectDir, harness, "agents"); if (existsSync(candidate)) return candidate; } return null; } function frontmatterScalar(body: string, key: string): string { const frontmatter = body.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ""; return new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter)?.[1].trim() ?? ""; } function loadAgentDisplayMap(projectDir: string): Record { const dir = agentsDir(projectDir); if (!dir) return {}; const map: Record = {}; try { for (const file of readdirSync(dir).filter((name) => name.endsWith(".md"))) { const body = readFileSync(join(dir, file), "utf-8"); const name = frontmatterScalar(body, "name"); const display = frontmatterScalar(body, "display_name"); if (!name || !display) continue; if (Object.hasOwn(map, name)) return {}; map[name] = display; } } catch { return {}; } return map; } type Input = { session_id?: string; workspace?: { project_dir?: string }; model?: { id?: string }; context_window?: { used_percentage?: number }; // The live transcript path the host pipes in. Accepted for completeness - // costSegment reads the rolled-up ledger, not the transcript, so this is not // required for the cost render. transcript_path?: string; }; function abbreviateModel(modelId: string): string { if (!modelId) return ""; let short = modelId; let prefix = ""; // Bedrock inference-profile prefixes: regional (us./eu./apac.) or global. const bedrockPrefix = short.match(/^(?:us|eu|apac|global)\.anthropic\./); if (bedrockPrefix) { prefix = "BR:"; short = short.slice(bedrockPrefix[0].length); } // Strip claude- prefix, -vN version, -YYYYMMDD date, :N suffix short = short .replace(/^claude-/, "") .replace(/-v\d+/, "") .replace(/-\d{8}/, "") .replace(/:\d+$/, ""); return prefix + short; } function contextColor(pct: number): string { if (pct >= 75) return "\x1b[31m"; // red if (pct >= 50) return "\x1b[33m"; // yellow return "\x1b[32m"; // green } const RESET = "\x1b[0m"; const STAGE_DISPLAY: Record = { "workspace-scaffold": "Workspace Scaffold", "workspace-detection": "Workspace Detection", "state-init": "State Init", "intent-capture": "Intent Capture", "market-research": "Market Research", feasibility: "Feasibility", "scope-definition": "Scope Definition", "team-formation": "Team Formation", "rough-mockups": "Rough Mockups", "approval-handoff": "Approval & Handoff", "reverse-engineering": "Reverse Engineering", "practices-discovery": "Practices Discovery", "requirements-analysis": "Requirements Analysis", "user-stories": "User Stories", "refined-mockups": "Refined Mockups", "domain-design": "Domain Design", "contract-design": "Contract Design", "units-generation": "Units Generation", "delivery-planning": "Delivery Planning", "functional-design": "Functional Design", "nfr-requirements": "NFR Requirements", "nfr-design": "NFR Design", "infrastructure-design": "Infrastructure Design", "code-generation": "Code Generation", "build-and-test": "Build and Test", "ci-pipeline": "CI Pipeline", "deployment-pipeline": "Deployment Pipeline", "environment-provisioning": "Env Provisioning", "deployment-execution": "Deployment Execution", "observability-setup": "Observability Setup", "incident-response": "Incident Response", "performance-validation": "Performance Validation", "feedback-optimization": "Feedback & Optimization", }; // Agent display names derive from `.claude/agents/*.md` frontmatter via // loadAgents(). The `orchestrator` pseudo-entry is seeded explicitly — // state files can carry `Active Agent: orchestrator` during orchestrator- // driven transitions, but there's no corresponding agent file. function agentDisplayMap(projectDir: string): Record { return { orchestrator: "Orchestrator", ...loadAgentDisplayMap(projectDir), }; } function extractField(text: string, label: string): string { // Match the Markdown list field pattern used throughout aidlc-state.md: // - **Lifecycle Phase**: IDEATION // Anchoring on "^-\s*\*\*LABEL\*\*:" prevents prose lines that happen to contain // the label (e.g. "> The Lifecycle Phase: OPERATION was added in v2.") from // hijacking the displayed value. const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`^-\\s*\\*\\*${escaped}\\*\\*:[^\\S\\n]*([^\\n]*)`, "m"); const m = text.match(re); return m ? m[1].replace(/\r$/, "").trim() : ""; } function phaseProgress(text: string, phase: string): { done: number; total: number } { if (!phase) return { done: 0, total: 0 }; // Normalize: take the first whitespace-delimited token and uppercase it, so // values like "INCEPTION (finalizing)" or mixed-case headings still match. const phaseToken = phase.trim().split(/\s+/)[0].toUpperCase(); if (!phaseToken) return { done: 0, total: 0 }; const lines = text.split(/\r?\n/); let inPhase = false; let total = 0; let done = 0; for (const line of lines) { if (line.startsWith("### ") && line.toUpperCase().includes(`${phaseToken} PHASE`)) { inPhase = true; continue; } if (line.startsWith("### ")) { inPhase = false; } if (!inPhase) continue; if (!line.startsWith("- [")) continue; if (line.includes("SKIP") || line.includes("[S]")) continue; total++; if (line.startsWith("- [x]")) done++; } return { done, total }; } function progressBar(completed: number, total: number): string { if (!total || total <= 0) return ""; let filled = Math.floor((completed * 10) / total); if (filled > 10) filled = 10; const empty = 10 - filled; return `[${"\u2593".repeat(filled)}${"\u2591".repeat(empty)}]`; } // covers: function:fmtTokens // Compact token count: 1234 -> "1.2k", 3_400_000 -> "3.4M". Sub-1000 values // render as their integer. Negative / non-finite guarded to "0". One decimal // place at k/M scale, trailing ".0" trimmed ("2k" not "2.0k"). export function fmtTokens(n: number): string { if (!Number.isFinite(n) || n <= 0) return "0"; const trim = (s: string): string => s.replace(/\.0$/, ""); if (n >= 1e6) return `${trim((n / 1e6).toFixed(1))}M`; if (n >= 1e3) return `${trim((n / 1e3).toFixed(1))}k`; return String(Math.round(n)); } // covers: function:costSegment // The current workflow/session cost segment: `up down $`. Reads // the rolled-up ledger (FAST - the fold hooks already parsed the transcript; we // never re-parse it here). When the total USD is // null/zero/unknown (e.g. only unknown models), render tokens only - NEVER a // fabricated "$0". Renders ONLY when the ledger exists and carries data: an // install without the Claude fold hook (Kiro/Codex/opencode, or an upstream // session that never folded) sees no ledger and gets "", so the statusline is // byte-unchanged from before this feature. // // The ledger advances on each fold, so this segment reflects usage through the // last folded turn - it can lag the in-flight turn by up to one fold. That is // acceptable for a statusline. Returns "" on ANY failure - the statusline must // never error out of a cost read. `transcriptPath` selects the current session's // workflow-scoped aggregate; the transcript itself is never parsed here. export function costSegment( projectDir: string, transcriptPath?: string, sessionId?: string, ): string { try { if (!projectDir) return ""; const ledger = join( projectDir, "aidlc", ".aidlc-sessions", "usage-ledger.json", ); if (!existsSync(ledger)) return ""; const require = createRequire(import.meta.url); const { sessionUsageAggregate } = require( "../tools/aidlc-usage.ts", ) as typeof import("../tools/aidlc-usage.ts"); const t = sessionUsageAggregate( projectDir, transcriptPath, undefined, sessionId, )?.totals; if (!t) return ""; const input = t.tokens?.input ?? 0; const output = t.tokens?.output ?? 0; if (input <= 0 && output <= 0 && !(t.usd > 0)) return ""; let seg = `↑${fmtTokens(input)} ↓${fmtTokens(output)}`; if (typeof t.usd === "number" && Number.isFinite(t.usd) && t.usd > 0) { seg += ` $${t.usd.toFixed(2)}`; } return seg; } catch { return ""; } } function buildRightSide( modelShort: string, ctxInt: number | null, cost: string, ): { plain: string; formatted: string } { const parts: string[] = []; const fmtParts: string[] = []; if (modelShort) { parts.push(modelShort); fmtParts.push(modelShort); } if (ctxInt !== null) { parts.push(`ctx:${ctxInt}%`); const color = contextColor(ctxInt); fmtParts.push(`${color}ctx:${ctxInt}%${RESET}`); } if (cost) { parts.push(cost); fmtParts.push(cost); } return { plain: parts.join(" "), formatted: fmtParts.join(" ") }; } // The " · · " orientation prefix (vision §3 / §11.2): the // statusline always tells the user which world they're in. Two invisibility // rules keep it out of the single-team user's face: // - the " ·" segment renders ONLY when more than one space exists // (listSpaces() always reports at least the always-present "default", so a // single-team user — exactly one space — never sees the word "space"); // - the intent slug renders whenever a per-intent record is active. On the // flat-legacy / pre-auto-create layout activeIntent() returns null, so the // prefix is empty and the line reads exactly as it did before the workspace // move (a flat project is unchanged). // The intent SLUG comes from the registry (rename-stable) when the active // record has a registry row; otherwise it falls back to the record dir name // minus its `-id8` disambiguator (an orphan / hand-created record). function orientationPrefix(projectDir: string, sessionId?: string): string { const selection = resolveWorkflowSelection(projectDir, sessionId); const space = selection.space; const activeDir = selection.intent; if (activeDir === null) return ""; // flat-legacy / no record → no prefix const intents = listIntents(projectDir, space); const match = intents.find((i) => i.dirName === activeDir); const slug = match?.slug || displaySlugFromDirName(activeDir); const segments: string[] = []; if (listSpaces(projectDir).length > 1) segments.push(space); segments.push(slug); return `${segments.join(" · ")} · `; } function printLine(left: string, right: { plain: string; formatted: string }): void { if (!right.formatted) { process.stdout.write(`${left}\n`); return; } const cols = process.stdout.columns ?? 0; if (cols > 0) { let pad = cols - left.length - right.plain.length; if (pad < 2) pad = 2; process.stdout.write(`${left}${" ".repeat(pad)}${right.formatted}\n`); } else { process.stdout.write(`${left} | ${right.formatted}\n`); } } async function main(stdinText: string): Promise { // Skip stdin read when stdin is a TTY — Claude Code always pipes JSON, // never runs the statusline with a terminal attached. Without this guard // a direct run / test / debug-mode pipeline would block on terminal input. let input: Input = {}; try { input = stdinText ? JSON.parse(stdinText) : {}; } catch { // ignore malformed stdin; fall through to derived project dir } const projectDir = resolveStatuslineProjectDir( import.meta.url, input.workspace?.project_dir, ); const sessionId = validSessionId(input.session_id) ?? undefined; const modelShort = abbreviateModel(input.model?.id ?? ""); const ctxRaw = input.model?.id ? input.context_window?.used_percentage : undefined; const ctxInt = typeof ctxRaw === "number" ? Math.round(ctxRaw) : null; const cost = costSegment(projectDir, input.transcript_path, sessionId); const right = buildRightSide(modelShort, ctxInt, cost); const selection = projectDir ? resolveWorkflowSelection(projectDir, sessionId) : null; const stateFile = projectDir && selection ? stateFilePathForSelection(projectDir, selection) : ""; if (!stateFile || !existsSync(stateFile)) { printLine("[AIDLC] ready", right); return; } const state = readFileSync(stateFile, "utf-8"); const phase = extractField(state, "Lifecycle Phase"); const stage = extractField(state, "Current Stage"); const agent = extractField(state, "Active Agent"); const statusMatch = state.match(/^-\s*\*\*Status\*\*:\s*(.+)$/m); const status = statusMatch ? statusMatch[1].replace(/\r$/, "").trim() : ""; const stageDisplay = STAGE_DISPLAY[stage] ?? stage; const agentDisplay = agentDisplayMap(projectDir)[agent] ?? agent; const { done, total } = phaseProgress(state, phase); const bar = total > 0 ? progressBar(done, total) : ""; const phaseProg = total > 0 ? `${done}/${total}` : ""; if (!phase) { printLine("[AIDLC] ready", right); return; } // Orientation prefix — only computed once a record is active (the state file // resolved above), so the empty-state "[AIDLC] ready" lines never carry it. const prefix = orientationPrefix(projectDir, sessionId); if (status === "Completed" || status === "Complete") { // At workflow completion, show a full bar even if Lifecycle Phase no longer // resolves to a real heading (e.g. a future caller writes a "COMPLETE" // sentinel or leaves the phase stale). Keep the natural bar when phaseProgress // could resolve it so tests that seed a real terminal phase still see 10/10. const completeBar = bar || `[${"▓".repeat(10)}]`; printLine(`[AIDLC] ${prefix}COMPLETE ${completeBar}`, right); return; } let output = `[AIDLC] ${prefix}${phase}`; if (bar) output += ` ${bar}`; if (phaseProg) output += ` ${phaseProg}`; if (stageDisplay) output += ` > ${stageDisplay}`; if (agentDisplay) output += ` -- ${agentDisplay}`; printLine(output, right); } export async function run(input: string): Promise { await main(process.stdin.isTTY ? "" : input); return 0; } if (import.meta.main) { process.exit(await run(await Bun.stdin.text())); }