A mod that shows AIDLC beside the chat: a line above the prompt with the runs in flight and anything waiting on you, a board pane for runs and the sprint…

An AI Development Life Cycle for Claude Code: you point it at a work item — or just describe what you want — and it takes the change from requirements to an open pull request, verifying against your project's real gate along the way. A human still reviews and merges.
It is built entirely from Claude Code primitives (agents, skills, hooks, permissions, MCP servers and a mod) and ships as plugins: one you need, and five you add when your project calls for them. There is nothing to run and no service to host, and it works on your Claude plan with no API key.
describe it, or name a work item
↓
fetch → validate requirements → plan → implement (fanned out across disjoint files)
→ review + security (in parallel) → QA → fix cycles → push → open PR → update the tracker
↓
you review and merge
Not everything gets that. Ask for a typo fix and you get a typo fix — edited, gated, committed, no ticket and no PR. AIDLC picks the lightest process tier that fits the consequence of getting it wrong, says which one it picked, and honors "just do it" as an instruction rather than arguing.
It fits when:
pytest gate, a GitFlow develop branch, squash merges, a PROJ-123-thing branch pattern — and you want those honored, not replaced.It does not fit when:
Common use cases
| You want to… | Do this |
|---|---|
| Turn a plain-language request into a shipped PR | /aidlc:run "add avatar upload, 5 MB max" |
| Ask whether an idea fits your architecture | /aidlc:do "should billing live in the API repo?" — grounds in your ADRs, backlog and stack, then answers. No item created |
| Fix something small right now | /aidlc:do "fix the typo in the header" — edited, gated, committed, no ticket |
| Adopt AIDLC on a codebase that already exists | /aidlc:init → /aidlc:adopt → /aidlc:adopt-apply |
| Turn a client's requirements document into a populated board | /aidlc:bootstrap ./requirements.docx |
| Plan a document of new features for a product you already ship | /aidlc:bootstrap ./phase-2-features.docx — same command; it detects the existing code, grounds in it, and attaches to the epics already on the board |
Work out why /aidlc:* commands vanished, or why a run blocks on permissions | /aidlc:doctor — read-only; reports the fault and the fix |
| Work the top of your backlog | /aidlc:next |
| Work several items at once | /aidlc:sprint 3 |
| Handle PR review comments | /aidlc:review-feedback PROJ-123 |
| Re-order work after priorities move | /aidlc:replan "checkout before search" |
| Build a UI to a high visual bar | /aidlc-ux:design /dashboard |
| Build screens that already exist in Figma | /aidlc-ux:figma <url> → /aidlc-ux:design |
| Record why the code is the way it is | ADRs, written by the architect or /aidlc:adopt-adr |
| Cut a release | /aidlc:release |
| See what's running and what's waiting on you, without asking | install aidlc-cockpit, then glance above the prompt or open /aidlc-board |
| Know when a reviewer commented or a check went red on your PR | the cockpit watches the pipeline's PRs and tells you the command to run |
| Send a PM or client where things stand | /aidlc:status --publish — a page on claude.ai, private until you share it |
| Hand the product over at the end of a project | /aidlc:study — the business study, shareable as a Claude Doc the client can comment on and export to Word or PDF |
| Know what each work item cost | /aidlc:insights, with the cockpit installed |
Catch an old project up after /plugin marketplace update | /aidlc:upgrade |
| Reverse a run that should not have happened | /aidlc:undo PROJ-123 — closes the PR or opens a revert PR, puts the board back; never rewrites history |
| Leave cleanly | /aidlc:remove |
claude --version; update with claude update). Older versions run, but a guard hook that cannot start lets the call through there instead of blocking it. /aidlc:doctor checks this.--publish) needs a session signed in with a claude.ai account.gh for GitHub, or az with the azure-devops extension for Azure Repos and Boards. Jira works through the Atlassian MCP server (aidlc-tracker-jira).Per developer, once. The shortest path is one line, which adds the marketplace and installs the core:
/plugin install aidlc@aidlc --marketplace Sourabh1095/AIDLC
Then add what your project needs:
/plugin marketplace add Sourabh1095/AIDLC # already done if you used the line above
/plugin install aidlc@aidlc # required — the pipeline itself
/plugin install aidlc-tracker-jira@aidlc # required IF your tracker is Jira
/plugin install aidlc-tracker-ado@aidlc # optional — ADO also works via the az CLI
/plugin install aidlc-stack-web@aidlc # optional — TypeScript/Next.js/NestJS/Postgres/Mongo
/plugin install aidlc-ux@aidlc # optional — the design pod (only fires on UI items)
/plugin install aidlc-cockpit@aidlc # optional — AIDLC beside the chat (a mod, Claude Code 2.1.287+)
| Plugin | What it adds | Install it when |
|---|---|---|
aidlc | The orchestrator, 9 pipeline agents, the tracker adapters, run state, the guard hooks. Stack-agnostic — it never assumes a language or package manager. Ships one MCP server (Context7) and no tracker server. | Always |
aidlc-tracker-jira | The Atlassian MCP server. wi-jira has no CLI fallback, so this is not optional on a Jira project. | workItems.source: jira |
aidlc-tracker-ado | The Azure DevOps MCP server. Optional — the adapter's az boards/az rest tier covers every operation and is what headless runs already use. | You want the richer interactive tier on ADO |
aidlc-stack-web | TypeScript standards, enterprise project structure, Next.js/NestJS/Postgres/Mongo conventions, migrations, Docker, API design, the Node CI half, the Prettier format hook. | Your project is TypeScript/Node |
aidlc-ux | 7 design agents, the Figma and Playwright MCP servers, and a design pipeline that gates on either a jury score or fidelity to a Figma design. | Your project has a UI worth judging |
aidlc-cockpit | A mod: a line above the prompt with the runs in flight and anything waiting on you, a board pane for runs and the sprint, /aidlc-board with no model turn, a desktop notification when a sprint run needs you, your pull requests watched (review comments, red checks, merges, each with the command to run next), and what each work item cost by phase (feeds /aidlc:insights). Calls no model, gates nothing. | You want to see the pipeline without asking for it, or know what an item costs |
/plugin marketplace update aidlc # pulls the new plugin versions
/reload-plugins
/aidlc:upgrade # in each project: catches its config and permission rules up
/aidlc:doctor # confirms the project can run
The plugin update changes the plugin, not your projects. /aidlc:upgrade shows every change before making it, and stages permission changes for you to apply. A project that installed AIDLC at project scope keeps that version until you run claude plugin update aidlc@aidlc --scope project in it.
Developing this repo itself: claude --plugin-dir <your-clone>/plugins/aidlc-core (add more --plugin-dir flags for the other plugins).
/aidlc:init # short Q&A → CLAUDE.md, config, permissions, rules, backlog/
/aidlc:run "users can upload an avatar, 5 MB max, png or jpeg"
run takes free text: it files the work item first, then builds it. Or start from a requirements document with /aidlc:bootstrap ./spec.docx, which infers the architecture from the requirements and populates the whole board in one reviewed pass.
Full walkthrough: docs/example-walkthrough.md — empty folder to a working full-stack app, every command included.
/aidlc:init # choose "there's existing code — scan it"; leaves stack/topology pending
/aidlc:adopt # READ-ONLY: derives topology, stack, real gate commands, git conventions
/aidlc:adopt-apply # shows the full diff, writes config only once you approve
The scan reads the code rather than asking you to recall it, and every fact it derives carries path:line evidence. Then optionally /aidlc:adopt-adr (records decisions your code already embodies, rationale left blank for a human — a scan proves what was decided, never why) and /aidlc:adopt-backlog (files the debt it found as real items).
The unit of adoption is the workspace, not the repo. One folder with repos inside it, or a multi-root VS Code .code-workspace whose folders live on different drives — one scan classifies every root and profiles each on its own terms. A Django service, a Go proxy and a pnpm monorepo in one workspace end up with three different gates, three commit conventions and three default branches.
Full walkthrough: docs/brownfield-walkthrough.md — a four-year-old GitFlow service, from first scan through a merged PR to a drift report six weeks later.
/aidlc:next # pick the top ready item and run it
/aidlc:status # what's in flight, what's blocked, what's next
/aidlc-board # with aidlc-cockpit: runs, sprint and pull requests in a pane, no tokens
Full playbook: docs/user-guide.md.
| Command | Purpose |
|---|---|
/aidlc:do <anything> | The general front door. An opinion question, an investigation, a small fix, or work described in plain language — it grounds itself in your project (config, ADRs, backlog, in-flight runs) and routes to the lightest thing that fits. Consults end with an answer and no item |
/aidlc:init | Scaffold AIDLC into a project |
| Command | In | Out | |
|---|---|---|---|
| `/aidlc:bootstrap <doc \ | brief>` | a requirements document, or a batch of new features | greenfield: a whole populated backlog + inferred architecture · brownfield: the batch grounded in your code and attached under the epics you already have |
/aidlc:intake <text> | one requirement, in plain language | well-formed items, deduped against the board | |
/aidlc:adopt | the existing code | an evidence-backed profile, read-only; a drift report on a re-scan | |
/aidlc:adopt-apply | an approved profile | config, CLAUDE.md, your real gate + git conventions + runtime constraints — behind a shown diff | |
/aidlc:adopt-adr | an approved profile | retroactive ADRs, rationale left for a human | |
/aidlc:adopt-backlog | an approved profile | the debt the scan found, as items — capped, deduped, each shown first |
| Command | Purpose | |
|---|---|---|
| `/aidlc:run <ID \ | text>` | Run one work item end to end. Resumable. Free text files it first. --plan-only stops after the plan so you can review it; pipeline.planApproval makes that checkpoint routine |
/aidlc:next | Pick the highest-priority ready item and run it. On a team, scoped to what's assigned to you | |
/aidlc:sprint N | Run N independent items in parallel as background sessions, with a live board that says which run needs you and how to answer (mono: worktrees · poly: per-repo) | |
/aidlc:review-feedback <ID> | Pull unresolved PR threads, fix them through the normal cycle, push, reply on each. A comment you disagree with gets answered on the thread — and nothing you didn't fix gets resolved | |
/aidlc:status | Dashboard: active runs, blockers, backlog snapshot, drift. --publish makes it a page on claude.ai for people who don't use Claude Code, updated in place each time | |
/aidlc:insights | Is the pipeline getting better? Time to done and per phase, fix cycles, findings by source (duplication, agent vs human review, QA, security), month by month, and — with aidlc-cockpit — what items cost and in which phase — read-only | |
/aidlc:study | The business study, written from your project brain: the product, every user type and what it can do, every module, the end-to-end flows, data, integrations, decisions, delivery history, intended vs built. Assumptions are kept apart as questions, and anything not covered is said. Shares as a Claude Doc your client can comment on and export to Word or PDF (--publish) | |
| `/aidlc:learn [module \ | --stale]` | Grow your project brain (.aidlc/brain/, private to you): map the product's modules, read one deeply — purpose, user types, business rules, data, flows, gaps — or refresh the pages the code has moved past, including changes teammates made without AIDLC |
/aidlc:doctor | Is this workspace able to run at all? Config, settings, plugin enablement, permission rules, repo paths, hooks, tracker auth, CI gating — read-only, each finding with its fix. Run it first when /aidlc:* commands are missing or a run blocks on permissions |
| Command | Purpose |
|---|---|
/aidlc:groom | Backlog refinement: fix acceptance criteria, size, flag blockers, propose splits |
/aidlc:replan [how] | Priorities moved? Say it in your own words — checkout before search, or all backend first, then UI. Re-sequences not-yet-started work into waves; in-flight work is never touched and nothing is written to the tracker |
/aidlc:repo add <name> | Declare + bootstrap a repo in a multi-repo workspace |
/aidlc:release | Semver from commits, changelog, tag, notes. Publishing is approval-gated |
aidlc-ux)| Command | Purpose | ||
|---|---|---|---|
| `/aidlc-ux:design <route \ | path \ | "redesign X">` | Run the design pod on a screen or the whole app. No designs → narrative → inspiration → design system → build + motion → a strict jury loop until the rendered UI scores ≥9/10. Screens already in Figma → build to them and gate on fidelity instead |
| `/aidlc-ux:figma <url> \ | sync \ | status` | Link a Figma file (screens, or a design system), map frames to routes, extract the spec and variables, and re-sync later to find what drifted |
aidlc-cockpit)| Command | Purpose |
|---|---|
/aidlc-board | A pane with every run, the sprint and the pipeline's pull requests, what needs you first. A sprint run waiting on you has Copy attach command; a PR that needs you has Put in prompt, which fills in its command for you to send. Answers with no model turn. The line above the prompt shows the same at a glance |
| Command | Purpose |
|---|---|
/aidlc:scaffold-skill <name> | Create a project-local skill when a capability is genuinely missing |
/aidlc:scaffold-agent <name> | Same, for an agent — behind a higher justification bar |
/aidlc:promote <name> | PR a proven project-local extension into the shared plugin |
/aidlc:upgrade | Catch a project up with the installed plugin: config shape migrations, permission-rule migrations, rules drift. Every change shown first; settings.json is staged, never written |
/aidlc:sync | Reconcile local extensions after plugin updates |
/aidlc:dogfood | Log friction with the plugin itself to a local feedback inbox |
/aidlc:undo <ID> | Reverse one run: close its PR and delete its branch, or open a revert PR if it merged; put the item, its Tasks and its parent back. Shows the plan first; never force-pushes or deletes an item |
/aidlc:remove | Remove AIDLC from the project: deletes its own files, reverts only what it merged, keeps everything you authored |
Run files are the memory. Every item being worked has .aidlc/runs/<ID>.md holding its phase, plan, assumptions, findings and log. It is committed to the item's branch, so the PR carries the full audit trail and any session can resume mid-pipeline — close your laptop at implement, reopen tomorrow, /aidlc:run PROJ-123 continues from there.
Sessions start knowing what happened. .aidlc/journal.md records one line per event — runs that landed, small fixes, what a consult concluded, when the order was re-planned — and a SessionStart hook reads it back with your blocked runs, the active wave and the last board snapshot. Run files are each item's memory; this is the workspace's. Claude Code's own auto memory stays in the project too — .aidlc/memory/, not ~/.claude/projects/ — so what it learns about the project is kept with the project.
It builds its own understanding of the product. .aidlc/brain/ holds a page per module — what it is for, who uses it, its business rules, data, connections and open questions — plus the user types, the journeys across modules and the domain's vocabulary. Every line says where it came from (the code, a requirement, a person, or an assumption still to confirm). The brain is yours alone and stays out of git, and every page records the commit it was checked against, so a module a teammate changed without AIDLC shows as behind before anything relies on it. It grows as you work: each pipeline run teaches it the modules it changed, /aidlc:do adds what an explanation taught it, and intake and grooming add what was intended. /aidlc:learn reads a module in depth on demand, and "I'm taking over payments" through /aidlc:do gives you the handoff brief. When the project ends, /aidlc:study writes the business study from it.
Process is proportional to consequence. Four tiers — answer, direct (a gated commit, no ticket), tracked (branch + run file), full (the pipeline) — with a project-wide floor you set. Five escalation triggers override it, each naming something you cannot fix by noticing later: auth or tenant-isolation paths, a destructive migration, a declared API contract, code an in-flight run owns, and an explicit pipeline request.
Reuse first, checked. Before planning, AIDLC lists what your code already has — components, hooks, helpers, clients, fixtures — and every plan starts by saying what it will reuse, extend or newly share. After implementing, a deterministic check flags code the branch duplicated, whether from existing code or from itself, and a copy goes back through a fix cycle. Parallel implementers get the shared piece built first, so six screens don't get six pagination hooks.
Your gate, not an assumed one. Verification runs the commands your project actually declares — pytest, mvn -B verify, cargo test, npm scripts — resolved per repo and per package. A gate your project doesn't have is reported as a coverage hole, never silently replaced with a default.
One repo or many. The runnable leaf is always one repo → one branch → one PR. A cross-repo feature becomes a parent whose children each target one repo, and a frontend/backend pair is built concurrently against a contract that lands first, with an integration join proving the seam.
Solo by default, team when you say so. team.mode: shared scopes picks to your assigned items, makes grooming propose rather than overwrite a colleague's acceptance criteria, floors ceremony at tracked, and checks the shared control plane is current. The pipeline reads the assignee and never writes it — who does the work is a staffing decision. Everything is gated on that one flag, so a solo project is untouched.
Parallel work that can still ask. /aidlc:sprint runs each item as a Claude Code background session, isolated in its own worktree or repo. When one needs a person (a plan approval, a permission, a security confirm), the board says which run and how to answer. It carries on once you do, and it keeps going if you close the sprint session.
You can see it without asking. The optional cockpit is a Claude Code mod. It draws the runs in flight, the sprint, and the pipeline's pull requests above your prompt, and notifies you when something needs you. It also measures what each work item costs, by phase. It only reads what the pipeline's own scripts report, never calls a model, and AIDLC works the same without it.
Guardrails are hooks, not hope. A pre-flight hook blocks force-pushes and pushes from protected branches, destructive database commands, production-targeted cluster commands, credential reads and .env exfiltration. Env-file access is default-deny with a per-workspace opt-in. New dependencies are gated for a supply-chain check before install, across every supported ecosystem. The pipeline cannot edit its own permissions or hook scripts, and a guard that cannot run blocks the call rather than letting it through unchecked.
Design rationale: docs/architecture.md. Why each permission rule exists: docs/permissions-rationale.md.
Everything lives in .claude/aidlc.config.json, written by /aidlc:init or /aidlc:adopt-apply. The settings worth knowing on day one:
| Key | What it decides |
|---|---|
workItems.source | markdown (default) · jira · ado |
workspace.layout | mono (default) · poly — several repos under one control plane |
git.mode | remote (default: push + PR) · local (no remote yet: a confirmed local merge instead) |
pipeline.ceremony | The process floor: direct (default) · tracked · full |
pipeline.effort | Reasoning effort per agent dispatch — budget (economy · balanced · quality), a max cap, per-agent overrides. Each agent has a role default and /aidlc:run adjusts it for the item's size and risk: the token dial beside the fixed model tiers |
pipeline.verification | How often reviewer/QA/security run — per item, per epic, on demand, or off. The default is economical; your CI gate is the per-item floor |
pipeline.gates.verify | Your project's real gate commands, per repo and package |
team.mode | solo (default) · shared |
ux.* | Jury threshold, render URL, Figma links |
Full schema: docs/aidlc.config.schema.json.
| Path | What it is |
|---|---|
.claude-plugin/marketplace.json | The marketplace manifest |
plugins/aidlc-core/ | The aidlc plugin: orchestrator skills, 9 agents, hooks, tracker adapters, MCP config |
plugins/aidlc-stack-web/ | Web stack pack: skills, the tooling/structure/CI templates, the format hook |
plugins/aidlc-ux/ | UX pod: 7 design agents, design skills, artifact templates, Figma + Playwright MCP |
plugins/aidlc-cockpit/ | The cockpit mod: hooks/register.tsx draws, hooks/view.mjs decides what to show (tested with node) |
| `plugins/aidlc-
hooks/register.tsx 414 lines1// aidlc-cockpit — AIDLC beside the chat.
2//
3// It gates no tool call, rewrites no prompt and approves nothing. What it shows comes from core's
4// scripts (run-files.mjs, sprint.mjs, costs.mjs), found next to this plugin, so it can never disagree
5// with /aidlc:status, /aidlc:sprint or /aidlc:insights. What to show is decided in view.mjs; this file
6// gathers and draws.
7//
8// The one thing it writes is the cost ledger: what each work item cost, by phase, from Claude Code's own
9// session cost (view.mjs → *the cost ledger*). One file per item per session, under the main checkout's
10// git-ignored `.aidlc/logs/costs/`.
11//
12// Cost: no model calls, ever. A board refresh runs at most three short `node` processes, every 30
13// seconds and only while a surface is drawing, so an unattended background sprint run polls nothing.
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { CockpitBoardRow, CockpitPr, CockpitRun, CockpitSnapshot, CockpitSprint } from '../types'
18import type { Charge, Ledger, LedgerFile } from './view.mjs'
19import {
20 advance,
21 bandModel,
22 boardText,
23 cacheDirOf,
24 commandItem,
25 coreCandidates,
26 coreTooOld,
27 ledgerName,
28 mergeLedger,
29 newestSprintFile,
30 phaseOf,
31 prLine,
32 prState,
33 prTransitions,
34 runFileTarget,
35 runLine,
36 toSlash,
37 transitions,
38} from './view.mjs'
39
40const PANE = 'aidlc-board'
41const COMMAND = 'aidlc-board'
42const POLL_MS = 30_000
43const FLUSH_MS = 120_000
44const MIN_GAP_MS = 5_000
45// Pull requests change on a person's time, not a turn's, and each look is a call to GitHub or Azure.
46const PR_MS = 300_000
47// The oldest core this cockpit reads correctly: pr-state.mjs (0.67), costs.mjs (0.65), run-files --json (0.64).
48const MIN_CORE = '0.67.0'
49
50// Change the shape tag when the snapshot's fields change: a reload keeps $.state, and the tag makes
51// the new code decline what the old code stored instead of misreading it.
52const snapshot = atom({ plugin: 'aidlc-cockpit', key: 'snapshot' } as const, null, { shape: 'snapshot-3' })
53const hidden = atom({ plugin: 'aidlc-cockpit', key: 'hidden' } as const, false)
54
55const clockTime = (ms: number) => {
56 const d = new Date(ms)
57 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
58}
59
60// Module variables start over on a reload. For the ledger that means a reload in the middle of a phase
61// charges what was spent before it to nothing; the next flush or phase change picks it up again.
62let core: string | null | undefined // undefined: not looked for yet; null: not found
63let lastRows: CockpitBoardRow[] | null = null
64let busy = false
65let lastAt = 0
66let ledger: Ledger = { item: null, phase: null, baseline: null }
67let costRoot: string | undefined
68let sessionId: string | undefined
69let coreVersion: string | null | undefined
70let prs: CockpitPr[] | null = null
71let prsAt = 0
72
73// Claude Code traces every call a mod makes on `$`, so `$` is only ever passed to functions declared
74// at the top of this file.
75async function findCore($: EngineInterface): Promise<string | null> {
76 let versions: string[] = []
77 try {
78 versions = (await $.fs.list(cacheDirOf($.plugin.root))).filter(e => e.kind === 'dir').map(e => e.name)
79 } catch {
80 // no plugin cache next to us: a checkout or a folder marketplace
81 }
82 for (const dir of coreCandidates($.plugin.root, versions))
83 if (await $.fs.exists(`${dir}/skills/sprint/sprint.mjs`)) return dir
84 return null
85}
86
87async function readCoreVersion($: EngineInterface, dir: string): Promise<string | null> {
88 try {
89 const v = (JSON.parse(await $.fs.read(`${dir}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
90 return typeof v === 'string' ? v : null
91 } catch {
92 return null
93 }
94}
95
96async function nodeJson($: EngineInterface, args: string[], cwd: string): Promise<unknown> {
97 try {
98 const r = await $.process.run(['node', ...args], { cwd, timeoutMs: 30_000 })
99 return r.exitCode === 0 ? JSON.parse(r.stdout) : null
100 } catch {
101 return null
102 }
103}
104
105// The main checkout of a folder. A mono sprint run works in a worktree that is removed when the sprint
106// wraps up; its ledger must not go with it.
107async function mainCheckout($: EngineInterface, cwd: string): Promise<string> {
108 try {
109 const r = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, timeoutMs: 10_000 })
110 const dir = toSlash(r.stdout.trim())
111 if (r.exitCode === 0 && /\/\.git$/.test(dir)) return dir.slice(0, -'/.git'.length)
112 } catch {
113 // not a git repository: keep the ledger where the session is
114 }
115 return cwd
116}
117
118// What the session has cost so far, as /cost totals it (subagents included), or null where unknown.
119async function spent($: EngineInterface): Promise<number | null> {
120 try {
121 const usd = (await $.session.usage()).cost?.usd
122 return typeof usd === 'number' && Number.isFinite(usd) ? usd : null
123 } catch {
124 return null
125 }
126}
127
128async function writeCharges($: EngineInterface, charges: readonly Charge[]): Promise<void> {
129 if (!charges.length) return
130 costRoot ??= await mainCheckout($, toSlash(await $.session.cwd()))
131 sessionId ??= await $.session.id()
132 const now = new Date(await $.clock.now()).toISOString()
133 for (const item of new Set(charges.map(c => c.item))) {
134 const path = `${costRoot}/.aidlc/logs/costs/${ledgerName(item, sessionId)}`
135 let existing: LedgerFile | null = null
136 try {
137 existing = JSON.parse(await $.fs.read(path)) as LedgerFile
138 } catch {
139 // the first charge for this item in this session
140 }
141 const file = mergeLedger(existing, { item, session: sessionId.slice(0, 8), now }, charges.filter(c => c.item === item))
142 await $.fs.write(path, `${JSON.stringify(file, null, 2)}\n`)
143 }
144}
145
146// Move the ledger to the item and phase now in force (null: none), charging what was spent since.
147async function track($: EngineInterface, next: { item: string; phase: string } | null): Promise<void> {
148 const usd = await spent($)
149 if (usd == null) return
150 const step = advance(ledger, next, usd)
151 ledger = step.ledger
152 await writeCharges($, step.charges)
153}
154
155// Charge what the current phase has spent so far, and carry on. Bounds what a crash can lose.
156async function flush($: EngineInterface): Promise<void> {
157 if (ledger.item && ledger.phase) await track($, { item: ledger.item, phase: ledger.phase })
158}
159
160async function readPhase($: EngineInterface, path: string): Promise<string | null> {
161 const cwd = toSlash(await $.session.cwd())
162 const p = toSlash(path)
163 try {
164 return phaseOf(await $.fs.read(/^([A-Za-z]:)?\//.test(p) ? p : `${cwd}/${p}`))
165 } catch {
166 return null
167 }
168}
169
170// A run file was written: if its phase moved, the phase that ended is charged.
171async function noteRunFile($: EngineInterface, target: { item: string; path: string }): Promise<void> {
172 const phase = await readPhase($, target.path)
173 if (!phase || (ledger.item === target.item && ledger.phase === phase)) return
174 await track($, { item: target.item, phase })
175}
176
177// `/aidlc:run <ID>` typed by name: charge from its first turn, in the phase its run file is already at.
178async function noteRunCommand($: EngineInterface, item: string): Promise<void> {
179 const phase = (await readPhase($, `.aidlc/runs/${item}.md`)) ?? 'start'
180 await track($, { item, phase })
181}
182
183async function refresh($: EngineInterface, force = false): Promise<void> {
184 const now = await $.clock.now()
185 if (busy || (!force && now - lastAt < MIN_GAP_MS)) return
186 busy = true
187 lastAt = now
188 try {
189 const cwd = toSlash(await $.session.cwd())
190 if (!(await $.fs.exists(`${cwd}/.claude/aidlc.config.json`))) {
191 await update($, snapshot, () => null)
192 return
193 }
194 if (core === undefined) core = await findCore($)
195 if (!core) {
196 await update($, snapshot, () => ({ runs: [], sprint: null, at: now, problem: 'the aidlc plugin was not found next to aidlc-cockpit — install aidlc@aidlc' }))
197 return
198 }
199 // An older core is missing scripts this cockpit reads, which would show as an empty board.
200 if (coreVersion === undefined) coreVersion = await readCoreVersion($, core)
201 if (coreTooOld(coreVersion, MIN_CORE)) {
202 const problem = `this cockpit needs aidlc ${MIN_CORE} or later (found ${coreVersion ?? 'an unknown version'}) — /plugin marketplace update aidlc`
203 await update($, snapshot, () => ({ runs: [], sprint: null, at: now, problem }))
204 return
205 }
206 const runs = ((await nodeJson($, [`${core}/hooks/scripts/lib/run-files.mjs`, cwd, '--json'], cwd)) as CockpitRun[] | null) ?? []
207 let sprint: CockpitSprint | null = null
208 let listing: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
209 try {
210 listing = await $.fs.list(`${cwd}/.aidlc`)
211 } catch {
212 // no .aidlc yet
213 }
214 const file = newestSprintFile(listing)
215 if (file) sprint = (await nodeJson($, [`${core}/skills/sprint/sprint.mjs`, 'board', '--sprint-file', `${cwd}/.aidlc/${file}`, '--json'], cwd)) as CockpitSprint | null
216
217 // What each item has cost: the ledger as core reads it, plus what this session has spent on its
218 // item since the last charge.
219 costRoot ??= await mainCheckout($, cwd)
220 const costs: Record<string, number> = {}
221 if (await $.fs.exists(`${costRoot}/.aidlc/logs/costs`)) {
222 const read = (await nodeJson($, [`${core}/skills/insights/costs.mjs`, costRoot, '--json'], cwd)) as { byItem?: Record<string, { usd: number }> } | null
223 for (const [id, it] of Object.entries(read?.byItem ?? {})) costs[id] = it.usd
224 }
225 if (ledger.item && ledger.baseline != null) {
226 const usd = await spent($)
227 if (usd != null && usd > ledger.baseline) costs[ledger.item] = (costs[ledger.item] ?? 0) + (usd - ledger.baseline)
228 }
229
230 for (const t of transitions(lastRows, sprint?.rows ?? null)) {
231 void $.ui.notify(t.text, { title: 'AIDLC sprint' })
232 $.ui.toast(t.text)
233 }
234 lastRows = sprint?.rows ?? null
235
236 // The pipeline's pull requests: a slower cadence, since each look asks GitHub or Azure.
237 if (force || now - prsAt >= PR_MS) {
238 prsAt = now
239 const fresh = (await nodeJson($, [`${core}/skills/status/pr-state.mjs`, cwd, '--json'], cwd)) as CockpitPr[] | null
240 if (fresh) {
241 for (const t of prTransitions(prs, fresh)) {
242 void $.ui.notify(t.text, { title: 'AIDLC pull request' })
243 $.ui.toast(t.text)
244 }
245 prs = fresh
246 }
247 }
248 const snap: CockpitSnapshot = { runs, sprint, costs, prs: prs ?? [], at: now, problem: null }
249 await update($, snapshot, () => snap)
250 } finally {
251 busy = false
252 }
253}
254
255// The timer's tick: look again only while something is drawing.
256async function poll($: EngineInterface): Promise<void> {
257 if ((await $.session.surfaces()).length) await refresh($)
258}
259
260async function settle($: EngineInterface): Promise<void> {
261 await flush($)
262 await refresh($)
263}
264
265export const register: Register = on => {
266 on('session.start', async ($, e, next) => {
267 // The ledger is kept in every session, a background or `claude -p` sprint run included.
268 $.clock.every(FLUSH_MS, () => void flush($))
269 // The board only where something draws.
270 if (e.isInteractive) {
271 await $.command.register({
272 name: 'aidlc-board',
273 description: 'AIDLC runs and sprint board, with no model turn',
274 immediate: true,
275 })
276 $.clock.after(1, () => void refresh($, true))
277 $.clock.every(POLL_MS, () => void poll($))
278 }
279 return next(e)
280 })
281
282 on('session.end', async ($, e, next) => {
283 try {
284 await flush($)
285 } catch {
286 // a ledger is never worth delaying an exit over
287 }
288 return next(e)
289 })
290
291 // `/aidlc:run <ID>`: the run is charged from its first turn.
292 on('prompt.submit', async ($, e, next) => {
293 const item = commandItem(e.command)
294 if (item) {
295 try {
296 await noteRunCommand($, item)
297 } catch {
298 // never stand between a person and their prompt
299 }
300 }
301 return next(e)
302 }).catch(($, e, next) => next(e))
303
304 // The pipeline writes the run file at every phase change; the call itself always goes ahead untouched.
305 on('tool.call', async ($, e, next) => {
306 const target = runFileTarget(String(e.tool), e as unknown as { command?: unknown; file_path?: unknown })
307 const ran = await next(e)
308 if (target) {
309 try {
310 await noteRunFile($, target)
311 } catch {
312 // the ledger is best-effort; the tool call already happened
313 }
314 }
315 return ran
316 }).catch(($, e, next) => next(e))
317
318 // Run files change during turns: settle the ledger and look again when the main turn ends.
319 on('turn.complete', async ($, e, next) => {
320 const result = await next(e)
321 if (!e.agentId) $.clock.after(250, () => void settle($))
322 return result
323 })
324
325 on('command.run', { command: 'aidlc-board' }, async $ => {
326 await update($, hidden, () => false)
327 await refresh($, true)
328 await $.ui.open({ id: PANE, title: 'AIDLC' })
329 return { text: boardText(await read($, snapshot)) }
330 })
331
332 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
333 if (e.props.hasSurvey || (await read($, hidden))) return next(e)
334 const band = bandModel(await read($, snapshot))
335 if (!band) return next(e)
336 const { Box, Button, Text } = $.ui.resolve(e)
337 return (
338 <Box flexDirection="column">
339 {band.lines.map(line => (
340 <Text color={line.tone === 'warning' ? 'warning' : 'subtle'} wrap="truncate-end">
341 {line.text}
342 </Text>
343 ))}
344 <Box flexDirection="row" gap="sm">
345 <Button key="aidlc-board" label="Board" onPress={() => void $.ui.open({ id: PANE, title: 'AIDLC' })} />
346 <Button key="aidlc-hide" label="Hide" onPress={() => void update($, hidden, () => true)} />
347 </Box>
348 </Box>
349 )
350 })
351
352 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
353 const { Box, Button, Text } = $.ui.resolve(e)
354 const snap = await read($, snapshot)
355 if (!snap) return <Text dimColor>Not an AIDLC workspace: no .claude/aidlc.config.json in this folder.</Text>
356 if (snap.problem) return <Text color="warning">{snap.problem}</Text>
357 const rows = snap.sprint?.rows ?? []
358 return (
359 <Box flexDirection="column" gap="sm">
360 <Box flexDirection="column">
361 <Text weight="bold">{snap.runs.length ? `Runs (${snap.runs.length})` : 'No runs in flight'}</Text>
362 {snap.runs.map(run => {
363 const line = runLine(run, snap.costs?.[run.item])
364 return <Text color={line.tone === 'warning' ? 'warning' : 'text'}>{line.text}</Text>
365 })}
366 </Box>
367 {rows.length > 0 && (
368 <Box flexDirection="column">
369 <Text weight="bold">{`Sprint (${rows.length})`}</Text>
370 {rows.map(row => (
371 <Box flexDirection="row" gap="sm">
372 <Text color={row.needsYou && !row.terminal ? 'warning' : row.terminal ? 'success' : 'text'}>
373 {`${row.item} · ${row.phase ?? '—'} · ${row.waitingFor ? `${row.status} (${row.waitingFor})` : row.status}`}
374 </Text>
375 {row.session && row.needsYou && !row.terminal && (
376 <Button
377 key={`attach-${row.item}`}
378 label="Copy attach command"
379 onPress={press => void $.ui.copy({ text: `claude attach ${row.session}`, surface: press.surface })}
380 />
381 )}
382 </Box>
383 ))}
384 </Box>
385 )}
386 {(snap.prs ?? []).length > 0 && (
387 <Box flexDirection="column">
388 <Text weight="bold">{`Pull requests (${(snap.prs ?? []).length})`}</Text>
389 {(snap.prs ?? []).map(p => {
390 // The command to run next, put in the prompt for the person to send (never sent for them).
391 const command = p.next?.split(' → ').slice(-1)[0]
392 return (
393 <Box flexDirection="row" gap="sm">
394 <Text color={p.next ? 'warning' : p.error ? 'subtle' : 'text'}>{p.next ? prLine(p) : `${p.item} PR #${p.number}: ${p.error ?? prState(p)}`}</Text>
395 {command?.startsWith('/') && (
396 <Button
397 key={`pr-${p.item}`}
398 label="Put in prompt"
399 onPress={() =>
400 void $.prompt.fill({ text: command, mode: 'replace' }).catch(() => $.ui.toast(`Type ${command} to run it`))
401 }
402 />
403 )}
404 </Box>
405 )
406 })}
407 </Box>
408 )}
409 <Text dimColor>{`Updated ${clockTime(snap.at)} · costs are API-equivalent (/cost) · /${COMMAND} to refresh`}</Text>
410 </Box>
411 )
412 })
413}
414hooks/view.mjs 243 lines1// The aidlc-cockpit mod's logic: plain data in, plain data out. The mod (register.tsx) gathers the
2// data through Claude Code and draws; everything that decides WHAT to show lives here, so node can
3// test it (view.test.mjs) on every OS in CI, where no Claude Code runs.
4//
5// The data itself is never computed here. Runs come from core's `run-files.mjs --json` and the sprint
6// board from core's `sprint.mjs board --json`, so the cockpit cannot disagree with /aidlc:status or
7// /aidlc:sprint about what a run is doing.
8
9const MAX_RUN_LINES = 3;
10const MAX_NEED_LINES = 2;
11const MAX_PR_LINES = 2;
12
13export const toSlash = (p) => String(p).replace(/\\/g, "/").replace(/\/+$/, "");
14const parent = (p) => {
15 const parts = toSlash(p).split("/");
16 parts.pop();
17 return parts.join("/");
18};
19
20function compareVersions(a, b) {
21 const pa = String(a).split(".").map((n) => parseInt(n, 10));
22 const pb = String(b).split(".").map((n) => parseInt(n, 10));
23 for (let i = 0; i < 3; i++) {
24 const d = (Number.isNaN(pa[i]) ? -1 : pa[i] ?? 0) - (Number.isNaN(pb[i]) ? -1 : pb[i] ?? 0);
25 if (d) return d;
26 }
27 return 0;
28}
29
30// Where the aidlc core plugin can be, given this plugin's root, best guess first:
31// <root>/../aidlc-core a checkout, a --plugin-dir, or a folder marketplace
32// <root>/../../aidlc/<version> Claude Code's plugin cache (cache/<marketplace>/<plugin>/<version>),
33// newest version first
34// `cacheVersions` is the listing of <root>/../../aidlc (directory names), or [] when it does not exist.
35export function coreCandidates(root, cacheVersions = []) {
36 const r = toSlash(root);
37 const out = [`${parent(r)}/aidlc-core`];
38 const cache = `${parent(parent(r))}/aidlc`;
39 for (const v of [...cacheVersions].sort((a, b) => compareVersions(b, a))) out.push(`${cache}/${v}`);
40 return out;
41}
42export const cacheDirOf = (root) => `${parent(parent(toSlash(root)))}/aidlc`;
43
44// The newest sprint file in a listing of .aidlc/ (`{ name, kind, mtimeMs }` entries), or null.
45export function newestSprintFile(entries) {
46 const files = (entries ?? []).filter((e) => e.kind === "file" && /^sprint-.+\.json$/.test(e.name));
47 files.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0));
48 return files[0]?.name ?? null;
49}
50
51export function prLabel(pr) {
52 if (!pr || pr === "null") return null;
53 const n = /\/(?:pull|pullrequest|merge_requests)\/(\d+)/.exec(pr);
54 if (n) return `PR #${n[1]}`;
55 const local = /^local-merge:([0-9a-f]{7})/.exec(pr);
56 return local ? `merged ${local[1]}` : "PR";
57}
58
59const WAITING_PHASES = new Set(["blocked", "review-pending"]);
60const fixLabel = (n) => (n && n !== "0" && n !== "null" ? `fix-cycle ${n}` : null);
61const repoLabel = (r) => (r && r !== "null" && r !== "control-plane" ? r : null);
62
63export const money = (usd) => (usd == null || !(usd > 0) ? null : usd >= 10 ? `$${usd.toFixed(0)}` : usd < 0.01 ? "<$0.01" : `$${usd.toFixed(2)}`);
64
65// One run, as one line. `usd` is what it has cost so far, when the ledger measured it.
66export function runLine(run, usd) {
67 const waiting = WAITING_PHASES.has(run.phase);
68 const bits = [run.item, repoLabel(run.repo), run.phase, fixLabel(run.fixCycles), prLabel(run.pr), money(usd)].filter(Boolean);
69 const hint = run.phase === "blocked" ? ` → /aidlc:run ${run.item}` : run.phase === "review-pending" ? " → waiting for your review" : "";
70 return { text: bits.join(" · ") + hint, tone: waiting ? "warning" : "subtle" };
71}
72
73// One PR that needs something, as one line.
74export const prLine = (p) => `${p.item} PR #${p.number}: ${p.next}`;
75
76// The line above the prompt, or null when there is nothing worth the room. A sprint, when one is
77// running, takes the band: it is the one place several runs can each be waiting on the person. Pull
78// requests that need something (review comments, a red check, a merge to clean up) follow either.
79export function bandModel(snapshot) {
80 if (!snapshot) return null;
81 if (snapshot.problem) return { lines: [{ text: `AIDLC cockpit: ${snapshot.problem}`, tone: "subtle" }], needsYou: 0 };
82 const lines = [];
83 let needsYou = 0;
84 const rows = snapshot.sprint?.rows ?? [];
85 if (rows.length && !snapshot.sprint.allTerminal) {
86 const need = rows.filter((r) => r.needsYou && !r.terminal);
87 const inFlight = rows.filter((r) => !r.terminal).length;
88 lines.push({
89 text: `AIDLC sprint · ${rows.length} run${rows.length === 1 ? "" : "s"} · ${inFlight} in flight${need.length ? ` · ${need.length} need${need.length === 1 ? "s" : ""} you` : ""}`,
90 tone: need.length ? "warning" : "subtle",
91 });
92 for (const r of need.slice(0, MAX_NEED_LINES))
93 lines.push({ text: ` ${r.item}: ${r.waitingFor ?? r.status}${r.session ? ` → claude attach ${r.session}` : ""}`, tone: "warning" });
94 if (need.length > MAX_NEED_LINES) lines.push({ text: ` +${need.length - MAX_NEED_LINES} more — /aidlc-board`, tone: "subtle" });
95 needsYou += need.length;
96 } else {
97 const runs = snapshot.runs ?? [];
98 // Runs waiting on the person first: they are the reason to look.
99 const ordered = [...runs].sort((a, b) => Number(WAITING_PHASES.has(b.phase)) - Number(WAITING_PHASES.has(a.phase)));
100 for (const r of ordered.slice(0, MAX_RUN_LINES)) lines.push(runLine(r, snapshot.costs?.[r.item]));
101 if (runs.length > MAX_RUN_LINES) lines.push({ text: `+${runs.length - MAX_RUN_LINES} more — /aidlc-board`, tone: "subtle" });
102 needsYou += runs.filter((r) => WAITING_PHASES.has(r.phase)).length;
103 }
104 const prs = (snapshot.prs ?? []).filter((p) => p.next);
105 for (const p of prs.slice(0, MAX_PR_LINES)) lines.push({ text: prLine(p), tone: "warning" });
106 if (prs.length > MAX_PR_LINES) lines.push({ text: `+${prs.length - MAX_PR_LINES} more PRs — /aidlc-board`, tone: "subtle" });
107 needsYou += prs.length;
108 if (!lines.length) return null;
109 // One label for the band; the sprint header carries its own, sprint detail lines keep their indent.
110 return {
111 lines: lines.map((l, i) => (i === 0 ? (l.text.startsWith("AIDLC") ? l : { ...l, text: `AIDLC ${l.text}` }) : l.text.startsWith(" ") ? l : { ...l, text: ` ${l.text}` })),
112 needsYou,
113 };
114}
115
116// The board as plain text: the /aidlc-board reply, for surfaces that draw no pane.
117export function boardText(snapshot) {
118 if (!snapshot) return "Not an AIDLC workspace (no .claude/aidlc.config.json here).";
119 if (snapshot.problem) return `AIDLC cockpit: ${snapshot.problem}`;
120 const out = [];
121 const runs = snapshot.runs ?? [];
122 out.push(runs.length ? `Runs (${runs.length}):` : "No AIDLC runs in flight.");
123 for (const r of runs) out.push(` ${runLine(r, snapshot.costs?.[r.item]).text}`);
124 const rows = snapshot.sprint?.rows ?? [];
125 if (rows.length) {
126 out.push("", `Sprint (${rows.length}):`);
127 for (const r of rows) out.push(` ${r.item} · ${r.phase ?? "—"} · ${r.waitingFor ? `${r.status} (${r.waitingFor})` : r.status}${r.needsYou ? ` → ${r.needsYou}` : ""}`);
128 }
129 const prs = snapshot.prs ?? [];
130 if (prs.length) {
131 out.push("", `Pull requests (${prs.length}):`);
132 for (const p of prs) out.push(` ${p.item} PR #${p.number}: ${p.error ?? prState(p)}${p.next ? ` → ${p.next.split(" → ").slice(-1)[0]}` : ""}`);
133 }
134 return out.join("\n");
135}
136
137// A PR's state in words: "open · changes requested · 2 unresolved · checks failing".
138export function prState(p) {
139 const bits = [p.state + (p.draft ? " (draft)" : "")];
140 if (p.review && p.review !== "none") bits.push(p.review.replace(/_/g, " "));
141 if (p.unresolved) bits.push(`${p.unresolved} unresolved`);
142 if (p.checks && p.checks !== "none") bits.push(`checks ${p.checks}`);
143 return bits.join(" · ");
144}
145
146// A PR newly needing something is worth a notification; one that needed the same thing last time is
147// not. The first look of a session notifies nothing.
148export function prTransitions(prev, now) {
149 if (!prev || !now) return [];
150 const before = new Map(prev.map((p) => [p.pr, p.next ?? null]));
151 return now.filter((p) => p.next && before.has(p.pr) && before.get(p.pr) !== p.next).map((p) => ({ item: p.item, kind: "pr", text: prLine(p) }));
152}
153
154// Is the core plugin new enough for this cockpit? Compares a.b.c numerically.
155export function coreTooOld(version, min) {
156 const a = String(version ?? "0").split(".").map(Number);
157 const b = String(min).split(".").map(Number);
158 for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
159 return false;
160}
161
162// What changed between two sprint boards that is worth interrupting someone for: a run starting to
163// wait on them, and a run finishing. The first board of a session notifies nothing; the person can
164// see it, and a notification for every row already on it would be noise.
165export function transitions(prevRows, rows) {
166 if (!prevRows || !rows) return [];
167 const before = new Map(prevRows.map((r) => [r.item, r]));
168 const out = [];
169 for (const r of rows) {
170 const was = before.get(r.item);
171 if (!was) continue;
172 const waitsNow = r.status === "NEEDS YOU" && !r.terminal;
173 const waitedBefore = was.status === "NEEDS YOU" && !was.terminal;
174 if (waitsNow && (!waitedBefore || was.waitingFor !== r.waitingFor))
175 out.push({ item: r.item, kind: "needs-you", text: `${r.item} needs you: ${r.waitingFor ?? "an answer"}` });
176 else if (r.terminal && !was.terminal) out.push({ item: r.item, kind: "over", text: `${r.item}: ${r.status}` });
177 }
178 return out;
179}
180
181// ---- the cost ledger --------------------------------------------------------------------------
182//
183// What a work item costs is charged by phase. Claude Code reports the session's running cost
184// (`$.session.usage().cost.usd`, what /cost shows, subagents included). The mod notes the figure when
185// a run moves to a new phase, and charges the difference to the phase that just ended. The run file is
186// how it knows: the pipeline writes `.aidlc/runs/<ID>.md` at every phase change, whichever door the run
187// came in by (/aidlc:run, /aidlc:next, /aidlc:do, a sprint). The ledger's format is defined by its
188// reader, core's skills/insights/costs.mjs.
189
190// Phases after which nothing more is charged until the run writes its file again (a resume).
191const NOT_WORKING = new Set(["done", "abandoned", "blocked", "review-pending"]);
192
193const RUN_FILE = /(?:^|[\\/"'\s=])((?:[^\s"'`<>|;&]*[\\/])?\.aidlc[\\/]runs[\\/]([A-Za-z0-9][\w.-]*)\.md)\b/;
194
195// The run file a tool call writes, if any: `{ item, path }`. Edit and Write name it; a shell command
196// that writes it (a heredoc, `sed -i`) names it in its text. Archived run files are not live runs.
197export function runFileTarget(tool, input) {
198 const t = String(tool);
199 const text = t === "Bash" || t === "PowerShell" ? String(input?.command ?? "") : ["Edit", "Write", "MultiEdit"].includes(t) ? String(input?.file_path ?? "") : "";
200 const m = RUN_FILE.exec(text);
201 if (!m || /[\\/]archive[\\/]/.test(m[1])) return null;
202 return { item: m[2], path: m[1] };
203}
204
205// The `phase:` of a run file's frontmatter, or null.
206export function phaseOf(text) {
207 const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text));
208 const m = fm && /^phase:\s*([\w-]+)/m.exec(fm[1]);
209 return m ? m[1] : null;
210}
211
212// `/aidlc:run PROJ-1 --sprint` → "PROJ-1": a run typed by name is charged from its first turn, before
213// its run file exists. Other doors are picked up at the first run-file write.
214export function commandItem(command) {
215 if (!command || command.name !== "aidlc:run") return null;
216 const id = String(command.args ?? "").split(/\s+/).find((a) => /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(a));
217 return id ?? null;
218}
219
220// One step of the bookkeeping. `ledger` is `{ item, phase, baseline }` (baseline: the session cost when
221// the current phase began); `next` is the `{ item, phase }` now in force, or null for "nothing"; `usd`
222// is the session cost now. Returns the new ledger and what to charge. Passing the current item and
223// phase again charges what has been spent so far and carries on: that is a flush.
224export function advance(ledger, next, usd) {
225 const charges = [];
226 if (ledger.item && ledger.baseline != null && usd > ledger.baseline)
227 charges.push({ item: ledger.item, phase: ledger.phase ?? "start", usd: usd - ledger.baseline });
228 // A cost that went down (a new ledger behind the session) restarts from here, charging nothing.
229 const working = next && next.item && !NOT_WORKING.has(next.phase);
230 return { ledger: working ? { item: next.item, phase: next.phase, baseline: usd } : { item: null, phase: null, baseline: usd }, charges };
231}
232
233// The ledger file's name for an item and a session. Item ids are already path-safe; anything else is
234// replaced so a strange id cannot leave the folder.
235export const ledgerName = (item, session) => `${String(item).replace(/[^\w.-]/g, "_")}--${String(session).replace(/[^\w-]/g, "").slice(0, 8)}.json`;
236
237// The ledger file after adding `charges` (all for one item): phases summed, `started` kept.
238export function mergeLedger(existing, { item, session, now }, charges) {
239 const phases = { ...(existing?.phases ?? {}) };
240 for (const c of charges) phases[c.phase] = Math.round(((phases[c.phase] ?? 0) + c.usd) * 1e6) / 1e6;
241 return { item, session, started: existing?.started ?? now, updated: now, phases };
242}
243types/index.d.ts 63 lines1// What aidlc-cockpit keeps in the session's state. The values come from core's own scripts:
2// `run-files.mjs --json` (runs) and `sprint.mjs board --json` (the sprint board).
3
4export type CockpitRun = {
5 item: string;
6 phase: string;
7 repo?: string;
8 branch?: string;
9 fixCycles?: string;
10 pr?: string;
11};
12
13export type CockpitBoardRow = {
14 item: string;
15 repo: string | null;
16 phase: string | null;
17 fixCycles: string | null;
18 branch: string | null;
19 pr: string | null;
20 session: string | null;
21 status: string;
22 terminal: boolean;
23 waitingFor?: string;
24 needsYou?: string;
25};
26
27export type CockpitSprint = { rows: CockpitBoardRow[]; allTerminal: boolean };
28
29/** One pull request the pipeline opened, as core's `pr-state.mjs --json` reports it. */
30export type CockpitPr = {
31 item: string;
32 pr: string;
33 number: number;
34 host: 'github' | 'azure-repos';
35 state?: 'open' | 'merged' | 'closed' | 'unknown';
36 draft?: boolean;
37 review?: string;
38 unresolved?: number;
39 checks?: 'passing' | 'failing' | 'pending' | 'none';
40 error?: string;
41 next: string | null;
42};
43
44export type CockpitSnapshot = {
45 runs: CockpitRun[];
46 sprint: CockpitSprint | null;
47 /** What each item has cost so far (ledger + this session's uncharged spend), when measured. */
48 costs?: Record<string, number>;
49 /** The pipeline's open pull requests, looked at every few minutes. */
50 prs?: CockpitPr[];
51 at: number;
52 problem: string | null;
53};
54
55declare module 'claude-code' {
56 interface PluginState {
57 'aidlc-cockpit': {
58 snapshot: Shaped<CockpitSnapshot | null>;
59 hidden: boolean;
60 };
61 }
62}
63