SLOPSHOPPER

AIDLC Cockpit

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…

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-24Sourabh1095/AIDLC/plugins/aidlc-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aidlc-cockpit
│ ┃ AIDLC ✕ › fix the failing auth test and add an audit log call │ ┃ Not an AIDLC workspace: no │ ┃ .claude/aidlc.config.json in this folder. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aidlc-board │ ⎿ aidlc-cockpit: Not an AIDLC workspace (no .claude/aidlc.config.j │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · AIDLC
Not an AIDLC workspace: no .claude/aidlc.config.json in this folder.
README

AIDLC

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.


Is this for you?

It fits when:

  • You work in Claude Code and want it to follow a repeatable process instead of improvising per task.
  • Your work lives in Jira, Azure Boards, or markdown files in the repo — all three are first-class.
  • You want the boring parts automated (branching, conventional commits, run state, PR bodies, tracker transitions) but you still want to review and merge.
  • Your project has its own conventions — a pytest gate, a GitFlow develop branch, squash merges, a PROJ-123-thing branch pattern — and you want those honored, not replaced.
  • You have several repos in one workspace, or a monorepo with independently-owned packages.

It does not fit when:

  • You want an autonomous agent that merges its own work. AIDLC deliberately stops at the PR.
  • You want a hosted service or a CI bot. This runs in your Claude Code session, on your machine.
  • You want it to invent process for a codebase nobody has read. On existing code it scans first.

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 isADRs, written by the architect or /aidlc:adopt-adr
Cut a release/aidlc:release
See what's running and what's waiting on you, without askinginstall aidlc-cockpit, then glance above the prompt or open /aidlc-board
Know when a reviewer commented or a check went red on your PRthe 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

Requirements

  • Claude Code 2.1.295 or later (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.
  • A Claude account — Pro, Max, Team or Enterprise. No API key is needed anywhere. Publishing a page or a Doc (--publish) needs a session signed in with a claude.ai account.
  • git and Node.js 20 or later (the guard hooks and scripts are plain Node, with no packages to install).
  • Your host's CLI, signed in: 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).
  • Windows, macOS or Linux.

Install

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+)
PluginWhat it addsInstall it when
aidlcThe 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-jiraThe Atlassian MCP server. wi-jira has no CLI fallback, so this is not optional on a Jira project.workItems.source: jira
aidlc-tracker-adoThe 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-webTypeScript 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-ux7 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-cockpitA 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

Updating

/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).


Quick start

A brand-new project

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

A codebase that already exists

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

Day to day

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


Commands

Start here

CommandPurpose
/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:initScaffold AIDLC into a project

Getting work onto the board

CommandInOut
`/aidlc:bootstrap <doc \brief>`a requirements document, or a batch of new featuresgreenfield: 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 languagewell-formed items, deduped against the board
/aidlc:adoptthe existing codean evidence-backed profile, read-only; a drift report on a re-scan
/aidlc:adopt-applyan approved profileconfig, CLAUDE.md, your real gate + git conventions + runtime constraints — behind a shown diff
/aidlc:adopt-adran approved profileretroactive ADRs, rationale left for a human
/aidlc:adopt-backlogan approved profilethe debt the scan found, as items — capped, deduped, each shown first

Running the pipeline

CommandPurpose
`/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:nextPick the highest-priority ready item and run it. On a team, scoped to what's assigned to you
/aidlc:sprint NRun 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:statusDashboard: 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:insightsIs 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:studyThe 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:doctorIs 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

Shaping the work

CommandPurpose
/aidlc:groomBacklog 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:releaseSemver from commits, changelog, tag, notes. Publishing is approval-gated

Design (needs aidlc-ux)

CommandPurpose
`/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

Cockpit (needs aidlc-cockpit)

CommandPurpose
/aidlc-boardA 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

Extending and maintaining

CommandPurpose
/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:upgradeCatch 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:syncReconcile local extensions after plugin updates
/aidlc:dogfoodLog 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:removeRemove AIDLC from the project: deletes its own files, reverts only what it merged, keeps everything you authored

How it works

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.


Configuration

Everything lives in .claude/aidlc.config.json, written by /aidlc:init or /aidlc:adopt-apply. The settings worth knowing on day one:

KeyWhat it decides
workItems.sourcemarkdown (default) · jira · ado
workspace.layoutmono (default) · poly — several repos under one control plane
git.moderemote (default: push + PR) · local (no remote yet: a confirmed local merge instead)
pipeline.ceremonyThe process floor: direct (default) · tracked · full
pipeline.effortReasoning 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.verificationHow 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.verifyYour project's real gate commands, per repo and package
team.modesolo (default) · shared
ux.*Jury threshold, render URL, Figma links

Full schema: docs/aidlc.config.schema.json.


Repository layout

PathWhat it is
.claude-plugin/marketplace.jsonThe 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-

Source 3 files
hooks/register.tsx 414 lines
1// 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}
414
hooks/view.mjs 243 lines
1// 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}
243
types/index.d.ts 63 lines
1// 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