Optional, experimental: aidd inside Claude Code's own interface. /aidd status opens a side pane: your next decision and what to type, the shape of the work…

<img src="docs/assets/hero.svg" alt="AIDD, AI-Driven Development: you decide what to build, what lands, and what leaves your machine, and AI agents do everything in between and prove it on the exact commit. Four decisions are yours: the plan, Spec approval, Merge approval (land it, on your local trunk), and the launch of a feature built behind a flag. On your machine the AI team specifies and implements until the tests pass, then verifies and reviews side by side on that exact commit, on its own branches. Nothing leaves your machine until you publish; then the work rolls out, and Learn feeds every lesson back into the plan." width="100%">
<a href="#get-started"><b>Get started</b></a> · <a href="docs/QUICKSTART.md"><b>Quickstart</b></a> · <a href="docs/HOW-TO.md"><b>How do I…</b></a> · <a href="docs/METHOD.md"><b>The method</b></a> · <a href="docs/TEAM.md"><b>The delivery team</b></a> · <a href="docs/FAQ.md"><b>FAQ</b></a>
You decide what to build, what lands, and what leaves your machine. AI agents do everything in between, and prove it on the exact commit.
AIDD, short for AI-Driven Development, is a way to deliver software with AI agents as your team. The agents plan, specify, build, test, and review, each change on its own branch. You make four decisions, and no agent can make them for you.
aidd is the open-source toolkit that runs it. Your coding agent follows aidd's skill, and an optional delivery team of agent roles shares the work. In <!-- aidd:gen list:guard-harnesses -->Claude Code, Codex, Google Antigravity, Grok Build, and OpenCode V2<!-- aidd:end -->, a guard checks each step the agent takes. A check named aidd/gate decides whether each change may merge, on proof, on GitHub, on GitLab, or on your own machine with nothing but git.
[!IMPORTANT] Pre-release. aidd 1.0.0 is not released yet, so install it from a commit. Release status says what the release waits on.
Agents can write much of the code now. The hard part is knowing what came back, whether it works, and who decided what. AIDD splits the work: four decisions are yours, and the team does everything else.
| Your decision | When | What you look at | |
|---|---|---|---|
| D1 | The plan | before any code | what gets built, in which small goals, and in what order |
| D2 | Spec approval | before each change is built | the contract: its intent, its scope, and the tests that define done |
| D3 | The release | when a change is ready | what was built, with its proof: you land it on your machine, then publish it |
| D4 | The launch | for a feature built behind a flag | when users see it |
You decide in your own words, at your agent's prompt: approve SPEC-3, land it, publish. aidd records each decision before the agent reads it, and /aidd lists every word you can type. Your words decide has them all, including approve all, which lets the agents record approvals for you until you stop it.
Every change runs one loop. You decide at the steps in brackets:
(Plan) → Specify → [Spec approval] → Implement ⟲ Verify → Review → [Merge approval] → [Publish] → Roll out → (Learn)
The agents build inside the Spec's scope until every test passes on one commit. Then Verify and Review run side by side on that commit, and a failure in either sends the change back. Not every change needs every step: before the first edit, aidd sizes the change by the files it touches. That size is its route, from standard and minor up to normal and major, and the route picks a lane.
| Lane | For | You decide |
|---|---|---|
| Fast | small fixes, and pre-approved routine changes such as a bot's dependency update | the result (nothing, for a pre-approved change) |
| Spec | anything worth planning | the Spec and the result, plus a specialist on a major change |
| Emergency | production is down, and you declare it | that it is an emergency, and the result; the full review comes after |
The author may ask for more control, never less, and the gate computes the route again from the real diff. The method, §4 shows how the work items connect, from a big goal down to each change, and the loop diagram shows which role works at each step.
What makes it different:
Ask your agent. In any coding agent that can run commands, say this:
Install thaithienvanid/aidd and apply it to this project.
It follows llms-install.md: it asks which profile you want, then installs aidd, checks the setup, and commits it.
In Claude Code, install the plugins, then type /aidd init in each repository:
/plugin marketplace add thaithienvanid/aidd
/plugin install aidd@aidd # the skill, the guard, and the commands
/plugin install aidd-team@aidd # the delivery team (optional)
/plugin install aidd-mods@aidd # the status pane and the /aidd verbs (optional)
Only /plugin install aidd@aidd is needed; /plugin install aidd-team@aidd and the pane are optional.
With the CLI, which needs Python 3.11 or later and nothing else, in any agent:
uv tool install git+https://github.com/thaithienvanid/aidd # or: pipx install git+https://github.com/thaithienvanid/aidd
aidd init --dry-run # see what it proposes; writes nothing
aidd init --team # asks the profile and the approver, wires the agents it finds, and offers the commit
aidd doctor # check the wiring
To look before you install anything, uvx --from git+https://github.com/thaithienvanid/aidd aidd init --dry-run reads your repository and writes nothing.
aidd init writes the config, the skill, AGENTS.md, and the CI workflows, and wires each coding agent it finds, out of the <!-- aidd:gen count:harnesses -->26<!-- aidd:end --> it supports. For a script, pass the answers: aidd init --profile solo --approvers email:you@example.com --team.
Nothing blocks you at first. The solo and team profiles start in advisory mode, so the guard and the gate report and stop nothing until you set mode: enforce, though no agent can ever approve, land, merge, or publish for you. With no GitHub or GitLab yet, the local-first path runs the whole loop on your machine.
Next, take one change through the loop in the Quickstart, or read a solo day. To turn on every part at once, follow Use all of aidd, and Install has every path and what each coding agent needs.
You work in your agent's session, so the /aidd verbs cover the everyday steps, and your terminal does the same:
<!-- aidd:gen cli:everyday -->
| In the session | At your terminal | What it does |
|---|---|---|
/aidd | — | every decision that is yours, and the words that make it |
/aidd status | aidd list --waiting-on-me | what waits on you now, and what to type |
| your agent runs it | aidd list | all open work, and who holds each step |
/aidd approve SPEC-3 | aidd approve spec SPEC-3 | Spec approval |
| your agent runs it | aidd gate | every gate rule on this branch, before Merge approval |
/aidd land | aidd land | Merge approval, then it lands on your local trunk |
/aidd publish | aidd publish | the verb shows what would leave your machine; typing publish, or aidd publish, pushes it and opens its PR/MR |
/aidd update | aidd self-update, then aidd upgrade --yes | aidd and this repository's aidd files, brought up to date |
<!-- aidd:end -->
In place of a verb you can type the words: approve SPEC-3, land it, publish. The /aidd verbs come with the aidd-mods plugin in Claude Code and the terminal plugin aidd init writes for OpenCode, and in every other agent the guard runs in, the words work the same. Install lists every verb, and CLI every command.
The team is optional. It's <!-- aidd:gen count:roles -->13<!-- aidd:end --> agent roles, from product manager to SRE, led by humans. Each role writes only its own files. The verifier writes the tests and runs the built change, but never writes the code. The reviewer stays the separate check. In Claude Code each command runs as /aidd-team:<name>, and in OpenCode, Grok Build, and Google Antigravity as /aidd-<name>.
In the order work meets them:
| Stage | For | Run |
|---|---|---|
| Start | an existing repository | onboard |
| a client proposal | propose | |
| a brief to a launched MVP | mvp | |
| Plan | trim the backlog | refine |
| a goal into Epics and Specs | plan | |
| compare designs | explore | |
| Build | write the Spec | specify |
| a threat model or security review | secure | |
| build the Spec | implement | |
| verify and review | review | |
| answer findings and red checks | rework | |
| verify a new head | verify | |
| Fix | a small bug, on the fast lane | fix |
| Ship | watch the rollout | rollout |
| hand client work over | handover | |
| Learn | after an incident | postmortem |
| improve the loop | retro | |
| Any time | due upkeep | maintain |
| run between your decisions | autopilot |
Every command stops where you decide. Commands and flows says when to pick each one, and Autopilot how a run stops at each decision, its time box, and its cost cap.
Two checks hold the agents to the loop, and neither trusts what an agent says about itself.
The guard runs inside your agent, on every edit and every command. It denies an agent's attempt to approve, land, merge, or publish for you, to change your decision records, or to loosen the config. In enforce mode it also keeps each role to its own files, and holds implementation until Spec approval. It reads what a shell command really runs, through pipes, substitutions, and loops, and fails closed on what it can't read. SECURITY.md lists what it denies.
The gate, aidd/gate, runs in your CI, or as aidd gate on your machine. It reads the change as git objects, takes its config from the base branch, and never runs the change's code. A change merges only when every rule holds for the exact commit:
<!-- aidd:gen rules:questions -->
| The gate asks | Its rules |
|---|---|
| Can the gate trust what it reads? | context, internal, config, edited_comments |
| Is the change what it says it is? | change_request, spec, confidence, route, scope, changed_lines, standard_change, explore, draft |
| Is it proven on this commit? | tests, red_green, merge_queue |
| Did the right humans approve it? | spec_approval, merge_approval, specialist_approval, authors |
| Is it safe to release? | secrets, policy, policy:<rule>, emergency, observe, provenance |
<!-- aidd:end -->
Every denial, finding, and error names the next step that works where you are, and Error codes explains each one.
Pick a profile, and tighten it later. A repository can never loosen what its organization set.
| Profile | For | What it adds |
|---|---|---|
solo | one person directing agents | you approve your own work, on record; Spec approval only on major changes |
team | one team or a few | Spec approval before building, an Approver who is not the author, a specialist on high-risk changes |
org | many teams and repositories | organization rules on high-risk and emergency changes, enforced from the start |
audited | a SOC 2 or ISO 27001 audit, finance, health, public sector | two Approvers on high-risk changes, rules on every change, provenance on every commit |
Growth adds Approvers, owners, and presets, never a second loop. Adoption has a rollout plan for each size.
aidd needs git and the repository. Everything else is optional, and aidd uses it only if you already have it.
AGENTS.md and the skill, and the gate still decides what merges. Tools that run many agents side by side, such as Orca, work as they are.aidd import turns their plan into a Spec.aidd proves who decided what, on which evidence. It doesn't judge code quality for you, so you still read what you release. It doesn't deploy, and it doesn't sandbox agents. The gate hasn't yet judged a live pull request, and autopilot's real runs were all in Claude Code. Rationale lists every known limit.
Other projects also use the name AIDD. This one is thaithienvanid/aidd on GitHub and aidd@aidd in Claude Code. It isn't on PyPI, so a package named aidd there is not this one. It's open source under Apache-2.0. The schemas and the conformance suite are the contract, so anyone can reimplement the gate anywhere.
hooks/register.tsx 1256 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Section, Snapshot, Step, Task } from '../types'
5import {
6 about,
7 AIDD_HELP,
8 AIDD_MENU,
9 countedLine,
10 EXPLORE_PREFIX,
11 EXPLORE_USAGE,
12 EXPLORE_WORD,
13 exploreAsked,
14 exploreQuestion,
15 outside,
16 outsideLines,
17 outsideHash,
18 weakens,
19 aiddVerb,
20 approvable,
21 approvalIds,
22 approvalQuestion,
23 clean,
24 cliChecked,
25 cliUpdated,
26 initArgs,
27 proposalAsked,
28 type ProposalAsk,
29 exploreNote,
30 pluginsSaid,
31 repoChecked,
32 REPO_WAITS,
33 shortName,
34 standingAsked,
35 standingNote,
36 PLUGINS,
37 UPDATE_TIMEOUT_MS,
38 pluginUpdate,
39 updateQuestion,
40 autopilot as pilotOf,
41 cells,
42 type Cell,
43 decisions,
44 flowLine,
45 shape as shapeOf,
46 firstLine,
47 frontTitle,
48 isInside,
49 isLand,
50 isMergeIt,
51 isRetiredShip,
52 json,
53 map,
54 mergePreview,
55 mergeQuestion,
56 NOT_FOUND,
57 NOT_ON_A_PLATFORM,
58 ON_A_PLATFORM,
59 preview,
60 publishPlan,
61 SHIP_RETIRED,
62 type StandingScope,
63 LAND_TIMEOUT_MS,
64 landQuestion,
65 specsFolder,
66 steps,
67 TIMEOUT_MS,
68 until,
69} from './aidd'
70import type { Approvable, Preview, Ran } from './aidd'
71
72// `/aidd status` opens a pane: drawn by the mod, never written to the transcript,
73// so none of it reaches the model. The aidd engine decides; the pane only shows.
74
75const PANE = 'aidd-status'
76const TITLE = 'aidd'
77// Below NARROW the key hints shorten; a row's own columns follow its room (MIN_TITLE, MAX_NOTE).
78const NARROW = 56
79const MIN_TITLE = 16
80const MAX_NOTE = 24
81
82const shot = atom({ plugin: 'aidd-mods', key: 'snapshot' } as const, null)
83const loading = atom({ plugin: 'aidd-mods', key: 'isLoading' } as const, false)
84const selected = atom({ plugin: 'aidd-mods', key: 'selected' } as const, null)
85// The Initiative or Epic the person chose to see alone; null shows everything.
86const focus = atom({ plugin: 'aidd-mods', key: 'focus' } as const, null)
87// The Task opened from the chosen Spec's detail card; null shows the Spec itself.
88const opened = atom({ plugin: 'aidd-mods', key: 'task' } as const, null)
89// The Plan section's lines, at most: the rest are counted.
90const PLAN_LINES = 6
91// The rows the detail card takes, about: the Spec's description and fields, and a few Tasks.
92const DETAIL_ROWS = 16
93
94
95// Running aidd and reading the Spec files: every engine call sits in this file.
96
97async function run($: EngineInterface, root: string, argv: readonly string[], timeoutMs = TIMEOUT_MS): Promise<Ran> {
98 try {
99 const { exitCode, stdout, stderr } = await $.process.run(argv, { cwd: root, timeoutMs })
100
101 return exitCode === 0
102 ? { isOk: true, value: stdout }
103 : { isOk: false, error: clean(firstLine(stderr) || firstLine(stdout)) || `\`${argv.join(' ')}\` exited ${exitCode}`, stdout, stderr }
104 } catch (error) {
105 const reason = error instanceof Error ? error.message : String(error)
106 if (/timed? ?out|still running/i.test(reason)) {
107 return { isOk: false, error: `\`${argv.join(' ')}\` took over ${timeoutMs / 1000} seconds` }
108 }
109
110 return { isOk: false, error: NOT_FOUND }
111 }
112}
113
114// Each item's title and what its Spec says (`about`), from one read of its file.
115async function titled($: EngineInterface, root: string, list: Step[]): Promise<Step[]> {
116 const read = async (step: Step): Promise<Step> => {
117 if (!isInside(step.where)) {
118 return step
119 }
120 try {
121 const source = await $.fs.read(`${root}/${step.where}`)
122 return { ...step, title: frontTitle(source), about: about(source) }
123 } catch {
124 return step
125 }
126 }
127
128 return Promise.all(list.map(read))
129}
130
131async function snapshot($: EngineInterface): Promise<Snapshot> {
132 // The project root, not the shell's directory: a `cd` in the session moves the latter.
133 const root = await $.session.root()
134 const [head, branch, waiting, pilot, shaped] = await Promise.all([
135 run($, root, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
136 run($, root, ['aidd', 'list', '--branch', '--json']),
137 run($, root, ['aidd', 'list', '--waiting-on-me', '--json']),
138 run($, root, ['aidd', 'autopilot', 'status', '--json']),
139 run($, root, ['aidd', 'goal', 'list', '--json']),
140 ])
141 const at = await $.clock.now()
142
143 const withTitles = async (section: Section<Step[]>): Promise<Section<Step[]>> =>
144 section.isOk ? { isOk: true, value: await titled($, root, section.value) } : section
145
146 const listed = await withTitles(map(json(branch), steps))
147 const parsedBranch = json(branch)
148 const mine = await withTitles(map(json(waiting), steps))
149
150 return {
151 root,
152 head: head.isOk ? head.value.trim() : '',
153 at,
154 branch: listed,
155 waiting: mine,
156 autopilot: map(json(pilot), status => pilotOf(status, at)),
157 shape: map(json(shaped), shapeOf),
158 standing: parsedBranch.isOk ? [exploreNote(parsedBranch.value), standingNote(parsedBranch.value)].filter(Boolean).join(' · ') || null : null,
159 }
160}
161
162async function refresh($: EngineInterface): Promise<void> {
163 await update($, loading, () => true)
164 try {
165 const next = await snapshot($)
166 await update($, shot, () => next)
167 } finally {
168 await update($, loading, () => false)
169 }
170}
171
172// A refresh nothing waits for, from a timer or a key: a failure says so in a toast, so no promise floats.
173function refreshInBackground($: EngineInterface): void {
174 refresh($).catch(() => $.ui.toast('aidd could not refresh the pane: press r to try again, or run `aidd list` in a terminal.'))
175}
176
177const APPROVE = 'Approve'
178const NOT_NOW = 'Not now'
179
180// Approving from the prompt. The person types "approve SPEC-1": their own words are the approval, so this mod, not
181// the model, records it at once for exactly the commit it shows (`aidd approve spec --confirmed`), and says what it
182// approved. The model never sees the prompt. When the request did not come from the person's keyboard (`askFirst`:
183// the SDK, or the agent through the skill), Claude Code's own dialog asks the person first. aidd's guard denies the
184// model's own `aidd approve`.
185async function approveFromPrompt($: EngineInterface, root: string, ids: string[], open: Approvable[], askFirst = false): Promise<void> {
186 const refOf = (id: string): string[] => {
187 const branch = open.find(item => item.id === id)?.branch
188
189 return branch ? ['--ref', branch] : []
190 }
191 const ready: Preview[] = []
192 const skipped: string[] = []
193 for (const id of ids) {
194 const said = map(json(await run($, root, ['aidd', 'approve', 'spec', id, ...refOf(id), '--show'])), preview)
195 if (!said.isOk) {
196 skipped.push(`${id}: ${said.error}`)
197 } else if (said.value.problems.length > 0) {
198 skipped.push(`${id}: ${said.value.problems[0]}`)
199 } else {
200 ready.push(said.value)
201 }
202 }
203 if (ready.length === 0) {
204 $.ui.toast(`Nothing was approved. ${skipped.join(' · ')}`, { timeoutMs: 12_000 })
205 return
206 }
207 // A change that weakens the guard is taken in only by a decision that showed it: the dialog, even from the keyboard.
208 askFirst ||= ready.some(p => weakens(p.outside))
209 // The safe answer first, and only the exact label approves: a dialog dismissed, answered by the idle timeout, or
210 // answered in free text ("Other") records nothing.
211 if (askFirst && !(await confirmed($, approvalQuestion(ready), 'Approve'))) {
212 $.ui.toast('Not approved: nothing was recorded.')
213 return
214 }
215 const approved: string[] = []
216 let counted = ''
217 for (const [at, p] of ready.entries()) {
218 // One at a time: each approval moves the same ref, so only the first names the digest of what the dialog showed.
219 const said = await run($, root, ['aidd', 'approve', 'spec', p.id, ...refOf(p.id), '--confirmed', p.commit, ...(at === 0 ? p.seen : [])])
220 if (said.isOk) {
221 approved.push(askFirst ? p.id : `${p.id} at ${p.commit.slice(0, 12)}`)
222 counted ||= countedLine(said.value)
223 } else {
224 skipped.push(`${p.id}: ${said.error}`)
225 }
226 }
227 const done = approved.length > 0 ? `Approved ${approved.join(', ')}.` : 'Nothing was approved.'
228 $.ui.toast([done, ...skipped, counted].filter(Boolean).join(' '), { timeoutMs: skipped.length > 0 || counted || !askFirst ? 12_000 : 6_000 })
229 if (await isOpen($)) {
230 await refresh($)
231 }
232}
233
234// Claude Code's own dialog: the safe answer first, and only the exact label confirms. A dialog dismissed, answered by
235// the idle timeout, or answered in free text ("Other") confirms nothing.
236async function confirmed($: EngineInterface, question: string, header: string): Promise<boolean> {
237 try {
238 return (await $.ui.ask(question, { header, options: [NOT_NOW, APPROVE] })) === APPROVE
239 } catch {
240 return false
241 }
242}
243
244// "approve the merge", typed by the person: recorded at once, for this checkout's branch at its head; a dialog first
245// when the request did not come from their keyboard (`askFirst`).
246// With `autoMerge` (the person's "merge it" where a platform merges), the pull request's auto-merge too.
247async function approveMergeFromPrompt($: EngineInterface, root: string, askFirst = false, autoMerge = false): Promise<void> {
248 const said = map(json(await run($, root, ['aidd', 'approve', 'merge', '--show'])), mergePreview)
249 if (!said.isOk || said.value.problems.length > 0) {
250 $.ui.toast(`Nothing was approved. ${said.isOk ? said.value.problems[0] : said.error}`, { timeoutMs: 12_000 })
251 return
252 }
253 const p = said.value
254 askFirst ||= weakens(p.outside)
255 if (askFirst && !(await confirmed($, mergeQuestion(p), 'Approve'))) {
256 $.ui.toast('Not approved: nothing was recorded.')
257 return
258 }
259 const done = await run($, root, ['aidd', 'approve', 'merge', '--confirmed', p.head, ...(autoMerge ? ['--auto-merge'] : []), ...p.seen])
260 const still = p.failing.length > 0 ? ` The gate still fails on ${p.failing.join(', ')}: the merge waits for that fix.` : ''
261 // With auto-merge, aidd's own last line says whether it is on; otherwise the pane names the head it recorded.
262 const recorded = autoMerge ? done.isOk && done.value.trim().split('\n').pop() : ''
263 const counted = done.isOk ? countedLine(done.value) : ''
264 const approved = `Approved merging ${p.branch} at ${p.head.slice(0, 12)}.${recorded && recorded !== counted ? ` ${recorded}` : ''}${still}${counted ? ` ${counted}` : ''}`
265 $.ui.toast(done.isOk ? approved : `Nothing was approved. ${done.error}`, { timeoutMs: done.isOk ? 8_000 : 12_000 })
266 if (await isOpen($)) {
267 await refresh($)
268 }
269}
270
271// "land it", typed by the person: `aidd land` runs the tests again and lands this head on the local trunk; a dialog
272// first when the request did not come from their keyboard (`askFirst`). Where the branch is on its platform, its pull
273// request merges there instead, so it says to type "merge it".
274async function landFromPrompt($: EngineInterface, root: string, askFirst = false): Promise<void> {
275 const said = map(json(await run($, root, ['aidd', 'land', '--show'])), mergePreview)
276 if (said.isOk && said.value.mergesOnPlatform) {
277 $.ui.toast(ON_A_PLATFORM, { timeoutMs: 12_000 })
278 return
279 }
280 if (!said.isOk || said.value.problems.length > 0) {
281 $.ui.toast(`Nothing was landed. ${said.isOk ? said.value.problems[0] : said.error}`, { timeoutMs: 12_000 })
282 return
283 }
284 const p = said.value
285 askFirst ||= weakens(p.outside)
286 if (askFirst && !(await confirmed($, landQuestion(p), 'Land'))) {
287 $.ui.toast('Not landed: nothing changed.')
288 return
289 }
290 $.ui.toast(`Landing ${p.branch}: running the tests on what lands…`)
291 const done = await run($, root, ['aidd', 'land', '--confirmed', p.head, ...p.seen], LAND_TIMEOUT_MS)
292 const counted = done.isOk ? countedLine(done.value) : ''
293 $.ui.toast(done.isOk ? `Landed ${p.branch} on ${p.baseBranch}.${counted ? ` ${counted}` : ''}` : `Not landed. ${done.error}`, { timeoutMs: 12_000 })
294 if (await isOpen($)) {
295 await refresh($)
296 }
297}
298
299// "merge it", typed by the person: their Merge approval on this branch's pull or merge request, with its auto-merge,
300// so it merges once `aidd/gate` passes there. Before the branch is on a platform there is nothing to merge there.
301async function mergeFromPrompt($: EngineInterface, root: string, askFirst = false): Promise<void> {
302 const said = map(json(await run($, root, ['aidd', 'land', '--show'])), mergePreview)
303 if (said.isOk && !said.value.mergesOnPlatform) {
304 $.ui.toast(NOT_ON_A_PLATFORM, { timeoutMs: 12_000 })
305 return
306 }
307 await approveMergeFromPrompt($, root, askFirst, true)
308}
309
310// `/aidd publish`: what would leave this machine (`aidd publish --show`, which reads only), and the words that publish
311// it. Publishing pushes, so this mod never runs it: aidd's own prompt hook records the person's typed "publish", and the
312// agent runs `aidd publish` inside what it grants.
313async function publishFromCommand($: EngineInterface, root: string): Promise<string> {
314 const said = json(await run($, root, ['aidd', 'publish', '--show']))
315 return said.isOk ? (publishPlan(said.value) ?? 'aidd said nothing about what would leave this machine.') : `aidd could not say what would leave this machine: ${said.error}`
316}
317
318// What a typed "approve …" or `/aidd approve …` approves: the Specs named, the merge, or what waits on you. Returns
319// what to tell the person when nothing can be asked, else undefined once the dialog is on its way.
320async function approveAsked($: EngineInterface, root: string, asked: string[] | 'merge', askFirst = false): Promise<string | undefined> {
321 if (asked === 'merge') {
322 $.clock.after(0, () => {
323 approveMergeFromPrompt($, root, askFirst).catch(() => $.ui.toast('aidd could not record the approval: run `aidd approve merge` in a terminal.'))
324 })
325 return undefined
326 }
327 const waiting = map(json(await run($, root, ['aidd', 'list', '--waiting-on-me', '--json'])), approvable)
328 const open = waiting.isOk ? waiting.value : []
329 const ids = asked.length > 0 ? asked : open.map(item => item.id)
330 if (ids.length === 0) {
331 return 'Nothing waits on your approval.'
332 }
333 $.clock.after(0, () => {
334 approveFromPrompt($, root, ids, open, askFirst).catch(() => $.ui.toast('aidd could not record the approval: run `aidd approve spec <ID>` in a terminal.'))
335 })
336 return undefined
337}
338
339const UPDATE = 'Update'
340const INSTALL = 'Install'
341
342// Each aidd plugin installed here, to its newest commit. Every plugin gets its try, and the answer says what each
343// came to: the ones updated and the next step, the ones not installed here, and why any failed, with the way by hand.
344async function updatePlugins($: EngineInterface, root: string): Promise<string> {
345 const updated: string[] = []
346 const absent: string[] = []
347 const failed: { plugin: string; reason: string }[] = []
348 for (const [at, plugin] of PLUGINS.entries()) {
349 const done = await run($, root, ['claude', 'plugin', 'update', plugin, '--json'], UPDATE_TIMEOUT_MS)
350 if (!done.isOk && done.error === NOT_FOUND) {
351 // `claude` won't start, so the plugins after this one won't either.
352 failed.push(...PLUGINS.slice(at).map(rest => ({ plugin: rest, reason: 'this session cannot run `claude`' })))
353 break
354 }
355 const outcome = pluginUpdate(plugin, done)
356 if (typeof outcome === 'object') {
357 failed.push({ plugin, reason: outcome.failed })
358 } else if (outcome === 'updated') {
359 updated.push(shortName(plugin))
360 } else if (outcome === 'absent') {
361 absent.push(shortName(plugin))
362 }
363 }
364 return pluginsSaid(updated, absent, failed)
365}
366
367// `/aidd update`: the CLI (`aidd self-update`), this repository's aidd files (`aidd upgrade --yes`), and the Claude Code
368// plugins, after one dialog that names them. The repository's files change in the working tree; the person commits
369// them on a branch.
370// `/aidd explore <topic>`: this worktree moves to the explore branch of `topic` (a new branch from here, or the one
371// that exists), where every edit is free and nothing merges as it is. Only a person starts one: the guard holds an
372// agent's switch, and from anywhere but the person's keyboard the dialog asks first. aidd's own prompt hook does the
373// same for the typed `explore branch <topic>` (cli/commands/typed_decisions.py).
374async function exploreFromCommand($: EngineInterface, root: string, rest: string, askFirst: boolean): Promise<string> {
375 const topic = exploreAsked(rest)
376 if (topic === null) {
377 return EXPLORE_USAGE
378 }
379 const branch = `${EXPLORE_PREFIX}${topic}`
380 const valid = await run($, root, ['git', 'check-ref-format', '--branch', branch])
381 if (!valid.isOk) {
382 return `${branch} is not a branch name git accepts. ${EXPLORE_USAGE}`
383 }
384 if (askFirst && !(await confirmed($, exploreQuestion(branch), 'Explore'))) {
385 return 'Nothing changed: this worktree stays on its branch.'
386 }
387 const exists = await run($, root, ['git', 'rev-parse', '-q', '--verify', `refs/heads/${branch}`])
388 const switched = await run($, root, ['git', 'switch', ...(exists.isOk ? [] : ['-c']), branch])
389 const now = await run($, root, ['git', 'symbolic-ref', '-q', '--short', 'HEAD'])
390 if (!now.isOk || now.value.trim() !== branch) {
391 return `git could not switch to ${branch}: ${switched.isOk ? 'it said nothing' : switched.error}. Commit or stash what blocks it, then ask again.`
392 }
393 if (await isOpen($)) {
394 await refresh($)
395 }
396 return `This worktree is on ${branch} now, and nothing merges from it as it is. To free its edits, type \`${EXPLORE_WORD} branch ${topic}\` at the prompt, which aidd records as yours. To keep the work, rename the branch with \`git branch -m <name>\`.`
397}
398
399async function updateFromCommand($: EngineInterface, root: string): Promise<string> {
400 const [checked, planned] = await Promise.all([run($, root, ['aidd', 'self-update', '--check']), run($, root, ['aidd', 'upgrade'])])
401 if (!checked.isOk && checked.error === NOT_FOUND) {
402 return NOT_FOUND
403 }
404 // A failed check is said in the dialog, never read as up to date; the plugins are still offered.
405 const cli = cliChecked(checked)
406 const repo = repoChecked(planned)
407 let answer = ''
408 try {
409 answer = await $.ui.ask(updateQuestion(cli, repo), { header: 'Update', options: [NOT_NOW, UPDATE] })
410 } catch {
411 answer = ''
412 }
413 if (answer !== UPDATE) {
414 return 'Nothing was updated.'
415 }
416 const said: string[] = []
417 // `aidd upgrade --yes` writes the files of the CLI that runs it, so it runs only on a CLI known to be current.
418 let current = cli.isCurrent
419 if (cli.isDue) {
420 $.ui.toast('Updating the aidd CLI…')
421 const done = await run($, root, ['aidd', 'self-update'], UPDATE_TIMEOUT_MS)
422 said.push(cliUpdated(done)) // the plugins still update
423 current = done.isOk
424 }
425 if (repo.isDue && !current) {
426 said.push(REPO_WAITS)
427 } else if (repo.isDue) {
428 const done = await run($, root, ['aidd', 'upgrade', '--yes'], UPDATE_TIMEOUT_MS)
429 said.push(done.isOk ? 'This repository\'s aidd files changed: commit them on a branch, as any change.' : `This repository was not updated: ${done.error}`)
430 }
431 said.push(await updatePlugins($, root))
432 return said.join(' ')
433}
434
435// A standing approval ("approve all", "automate", "but major") or its end ("stop approving"): typed by the person, their
436// words record it at once; from elsewhere, the dialog asks first. Ending one never asks: it only takes approval back.
437async function standingFromPrompt($: EngineInterface, root: string, asked: StandingScope | 'stop', askFirst = false): Promise<string> {
438 if (asked === 'stop') {
439 const done = await run($, root, ['aidd', 'approve', 'stop'])
440 return done.isOk ? firstLine(done.value) : `aidd could not stop it: ${done.error}`
441 }
442 const publishes = asked.includes('and-publish')
443 const what = `every Spec approval and Merge approval${publishes ? ', and publishing,' : ''}${asked.endsWith('but-major') ? ' except on major changes' : ''}`
444 const scope = [...(publishes ? ['--and-publish'] : []), ...(asked.endsWith('but-major') ? ['--but-major'] : [])]
445 // What changed outside the session since the last decision, which the answer takes in: the dialog lists it, and
446 // one that weakens the guard is asked about even from the person's keyboard.
447 const shown = json(await run($, root, ['aidd', 'approve', 'all', ...scope, '--show']))
448 const problems = shown.isOk && Array.isArray(shown.value['problems']) ? shown.value['problems'] : []
449 if (!shown.isOk || problems.length > 0) {
450 return `Nothing was recorded: ${shown.isOk ? clean(String(problems[0])) : shown.error}`
451 }
452 const changed = outside(shown.value)
453 askFirst ||= weakens(changed)
454 if (askFirst && !(await confirmed($, `Let the agent record ${what} for you, until you stop it?${outsideLines(changed)}`, 'Approve'))) {
455 return 'Nothing was recorded.'
456 }
457 const done = await run($, root, ['aidd', 'approve', 'all', ...scope, '--confirmed', asked, '--decided-via', 'claude-code', ...outsideHash(shown.value)])
458 const counted = done.isOk ? countedLine(done.value) : ''
459 return done.isOk ? [firstLine(done.value), counted].filter(Boolean).join(' ') : `Nothing was recorded: ${done.error}`
460}
461
462// `/aidd decide PROP-1 go` or `/aidd sign-off PROP-1`: what `--show` says the person decides, recorded with `--confirmed`
463// for exactly that decision, or for a sign-off exactly that text's hash; a dialog first when it did not come from their
464// keyboard (`askFirst`). aidd writes it into the proposal for them to commit.
465async function proposalFromCommand($: EngineInterface, root: string, asked: ProposalAsk, askFirst: boolean): Promise<string> {
466 const reason = asked.reason === '' ? [] : [`--reason=${asked.reason}`]
467 const tail = ['--', asked.id, ...(asked.decision === null ? [] : [asked.decision])]
468 const shown = json(await run($, root, ['aidd', 'proposal', asked.verb, '--show', ...reason, ...tail]))
469 if (!shown.isOk) {
470 return shown.error === NOT_FOUND ? NOT_FOUND : `Nothing was recorded: ${shown.error}`
471 }
472 const problems = shown.value['problems']
473 if (Array.isArray(problems) && problems.length > 0) {
474 return `Nothing was recorded: ${clean(String(problems[0]))}`
475 }
476 const hash = typeof shown.value['hash'] === 'string' ? shown.value['hash'] : ''
477 const what = asked.decision === null ? `Sign off ${asked.id}, as it reads now, so it may be sent?` : `Record ${asked.decision} on ${asked.id}?`
478 // A proposal's title is a file's words: cleaned, so it cannot draw a question of its own in the dialog.
479 const title = typeof shown.value['title'] === 'string' ? clean(shown.value['title']) : ''
480 if (askFirst && !(await confirmed($, `${what}${title === '' ? '' : ` ${asked.id}: ${title}.`}`, asked.decision === null ? 'Sign off' : 'Decide'))) {
481 return 'Nothing was recorded.'
482 }
483 const done = await run($, root, ['aidd', 'proposal', asked.verb, ...reason, '--confirmed', asked.decision ?? hash, '--decided-via', 'claude-code', ...tail])
484 if (!done.isOk) {
485 return `Nothing was recorded: ${done.error}`
486 }
487 return done.value.split('\n').find(line => line.startsWith('Recorded')) ?? firstLine(done.value)
488}
489
490// `/aidd init [flags]`: `aidd init [flags]` after one dialog that shows what init found.
491async function initFromCommand($: EngineInterface, root: string, rest: string): Promise<string> {
492 const flags = initArgs(rest)
493 const found = await run($, root, ['aidd', 'init', '--dry-run'])
494 if (!found.isOk) {
495 return found.error === NOT_FOUND ? NOT_FOUND : `aidd could not read this repository: ${found.error}`
496 }
497 const shown = found.value.split('\n').filter(l => l.trim() !== '').slice(0, 12)
498 let answer = ''
499 try {
500 answer = await $.ui.ask([...shown, '', `Run \`aidd init ${flags.join(' ')}\` here?`].join('\n'), { header: 'Install', options: [NOT_NOW, INSTALL] })
501 } catch {
502 answer = ''
503 }
504 if (answer !== INSTALL) {
505 return 'Nothing was installed.'
506 }
507 const done = await run($, root, ['aidd', 'init', ...flags], UPDATE_TIMEOUT_MS)
508 return done.isOk ? done.value.trim() : `aidd init failed: ${done.error}`
509}
510
511const isOpen = async ($: EngineInterface): Promise<boolean> => (await $.ui.panes()).some(pane => pane.id === PANE)
512
513const close = ($: EngineInterface) => $.ui.close({ id: PANE })
514
515// "15:20", in the time zone the mod's environment has.
516const clock = (ms: number): string => {
517 const at = new Date(ms)
518
519 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
520}
521
522// Who moves next decides a section's color and place: a person, the agent, other work it waits on, or a real block.
523type Tone = 'person' | 'agent' | 'waiting' | 'blocked'
524
525export type Group = { key: string; title: string; hint: string; tone: Tone; steps: Step[] }
526
527// Headings for aidd's step codes; any other code reads as its own words ("in_review" → "In review").
528const HEADINGS: Record<string, { title: string; hint?: string }> = {
529 'spec.spec_approval': { title: 'Needs Spec approval', hint: 'read each Spec and its tests, then approve' },
530 'spec.blocked_by': { title: 'Waiting on other work', hint: 'each starts when what it waits on finishes' },
531 'spec.blocked': { title: 'Blocked', hint: 'paused, or a Task out of retries: a person decides' },
532 'spec.draft': { title: 'Spec being written' },
533 'spec.ready': { title: 'Ready to build' },
534 'spec.in_progress': { title: 'Being built' },
535 'spec.verify': { title: 'Being verified' },
536 'spec.review': { title: 'In review' },
537 'spec.merge_approval': { title: 'In review' },
538 // The person's standing approval covers these: the agent records the approval, lands, or publishes.
539 'spec.spec_approval_standing': { title: 'Approving for you', hint: 'your standing approval covers it: the agent records it' },
540 'spec.land_standing': { title: 'Ready to land', hint: 'your standing approval covers it: the agent runs aidd land' },
541 'spec.publish': { title: 'Ready to publish', hint: 'type publish: aidd pushes it and opens its PR/MR' },
542 'spec.publish_standing': { title: 'Publishing', hint: 'agents publish here: the agent runs aidd publish' },
543 'spec.merge_approval_standing': { title: 'Approving the merge for you', hint: 'your standing approval covers it' },
544 'spec.rework': { title: 'Needs rework' },
545 'spec.triage': { title: 'Needs triage' },
546 'spec.invalid': { title: 'Spec has problems' },
547 'spec.unparsable': { title: 'Spec has problems' },
548 'spec.outcome_due': { title: 'Outcome check due' },
549 'flag.overdue': { title: 'Flag cleanup overdue', hint: 'write the Spec that removes it' },
550 'deprecation.overdue': { title: 'Past its sunset', hint: 'write the Spec that removes it' },
551 'spec.emergency': { title: 'Emergency change' },
552 // A Proposal's stages (METHOD, proposal status): a person decides go/no-go, signs it off, sends it, and converts it.
553 'proposal.qualifying': { title: 'Proposal: needs a go/no-go', hint: 'read its need and questions, then decide' },
554 'proposal.drafting': { title: 'Proposal being written' },
555 'proposal.signed_off': { title: 'Proposal ready to send', hint: 'signed off: send it to the addressee' },
556 'proposal.submitted': { title: 'Proposal with the addressee', hint: 'record their answer when it comes' },
557 'proposal.accepted': { title: 'Proposal accepted', hint: 'turn it into an Initiative: aidd proposal convert' },
558}
559
560const words = (code: string): string => {
561 const tail = (code.split('.').at(-1) ?? code).replace(/_/g, ' ')
562
563 return tail.charAt(0).toUpperCase() + tail.slice(1)
564}
565
566const toneOf = (step: Step): Tone =>
567 step.code === 'spec.blocked_by' ? 'waiting' : step.isBlocked ? 'blocked' : step.who === 'agent' ? 'agent' : 'person'
568
569// The items as sections: what waits on you first, then this branch's items grouped by what each needs.
570export function groups(waiting: Step[], branch: Step[]): Group[] {
571 const mine = new Set(waiting.map(step => step.id))
572 const out: Group[] = []
573 if (waiting.length > 0) {
574 out.push({ key: 'waiting-on-you', title: 'Waiting on you', hint: '', tone: 'person', steps: waiting })
575 }
576 const byCode = new Map<string, Group>()
577 for (const step of branch) {
578 if (step.id && mine.has(step.id)) {
579 continue
580 }
581 const tone = toneOf(step)
582 const key = `${tone}:${step.code}`
583 const heading = HEADINGS[step.code] ?? { title: words(step.code) }
584 const group = byCode.get(key) ?? { key, title: heading.title, hint: heading.hint ?? '', tone, steps: [] }
585 group.steps.push(step)
586 byCode.set(key, group)
587 }
588 const order: Tone[] = ['person', 'blocked', 'agent', 'waiting']
589
590 return [...out, ...[...byCode.values()].sort((a, b) => order.indexOf(a.tone) - order.indexOf(b.tone))]
591}
592
593// What each item holds up, directly or through others: approving SPEC-1 frees SPEC-2, which frees SPEC-3, ...
594export function unblocks(all: Step[]): Map<string, string[]> {
595 const direct = new Map<string, string[]>()
596 for (const step of all) {
597 for (const on of step.waitingOn) {
598 direct.set(on, [...(direct.get(on) ?? []), step.id])
599 }
600 }
601 const out = new Map<string, string[]>()
602 for (const id of direct.keys()) {
603 const seen = new Set<string>()
604 const queue = [...(direct.get(id) ?? [])]
605 while (queue.length > 0) {
606 const next = queue.shift() as string
607 if (!seen.has(next) && next !== id) {
608 seen.add(next)
609 queue.push(...(direct.get(next) ?? []))
610 }
611 }
612 out.set(id, [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })))
613 }
614
615 return out
616}
617
618const COLOR: Record<Tone, string | undefined> = { person: 'claude', agent: 'suggestion', waiting: undefined, blocked: 'warning' }
619
620// The width of the widest id in a list, at least 6.
621const idWidthFor = (ids: string[]): number => Math.max(6, ...ids.map(id => id.length))
622
623// A line cut to its room, with an ellipsis.
624const fit = (line: string, room: number): string =>
625 room <= 0 ? '' : line.length <= room ? line : `${line.slice(0, Math.max(0, room - 1))}…`
626
627export const register: Register = on => {
628 // `/aidd <verb>`: aidd's front door, answered here with no model turn. The command is the aidd skill; a verb this mod
629 // does not know goes on to the skill, which answers for the agent. `/aidd` alone, from the person's keyboard, lists
630 // every decision that is theirs and the words that make it; from anywhere else, and as `/aidd skill`, it loads the
631 // skill. Whoever runs it (the person, the SDK, or the agent through the skill), each verb that changes anything asks
632 // the person in a dialog first. Another plugin's run is passed on untouched.
633 on('command.run', async ($, e, next) => {
634 const asked = e.origin.kind !== 'plugin' ? aiddVerb(e.command, e.args) : null
635 if (asked === null || !['menu', 'status', 'close', 'help', 'approve', 'stop', 'land', 'merge', 'publish', 'ship', 'explore', 'decide', 'sign-off', EXPLORE_WORD, 'update', 'init', 'skill'].includes(asked.verb)) {
636 return next(e)
637 }
638 if (asked.verb === 'menu' && e.origin.kind !== 'composer') {
639 return next(e)
640 }
641 if (asked.verb === 'skill') {
642 return next({ ...e, args: '' })
643 }
644 if (asked.verb === 'menu') {
645 return { text: AIDD_MENU }
646 }
647 if (asked.verb === 'help') {
648 return { text: AIDD_HELP }
649 }
650 const root = await $.session.root()
651 // The person's own keyboard is their decision; anything else (the SDK, the agent through the skill) asks first.
652 const askFirst = e.origin.kind !== 'composer'
653 switch (asked.verb) {
654 case 'status': {
655 if (asked.rest === 'close') {
656 await close($)
657 return {}
658 }
659 const opened = await $.ui.open({ id: PANE, title: TITLE })
660 await refresh($)
661 // No text: a command's text is a transcript row the model reads.
662 return opened.isPlaced ? {} : { text: `The aidd pane could not open here: ${opened.reason}` }
663 }
664 case 'close':
665 await close($)
666 return {}
667 case 'approve': {
668 const standing = standingAsked(`/aidd approve ${asked.rest}`)
669 if (standing !== null) {
670 return { text: await standingFromPrompt($, root, standing, askFirst) }
671 }
672 const ids = approvalIds(`approve ${asked.rest}`)
673 if (ids === null) {
674 return { text: 'Name Specs (`/aidd approve SPEC-1`), `merge`, `all`, or nothing for what waits on you.' }
675 }
676 const said = await approveAsked($, root, ids, askFirst)
677 return said === undefined ? {} : { text: said }
678 }
679 case 'stop':
680 return { text: await standingFromPrompt($, root, 'stop', askFirst) }
681 case EXPLORE_WORD:
682 return { text: await exploreFromCommand($, root, asked.rest, askFirst) }
683 case 'land':
684 $.clock.after(0, () => {
685 landFromPrompt($, root, askFirst).catch(() => $.ui.toast('aidd could not land it: run `aidd land` in a terminal.'))
686 })
687 return {}
688 case 'merge':
689 $.clock.after(0, () => {
690 mergeFromPrompt($, root, askFirst).catch(() => $.ui.toast('aidd could not record the approval: run `aidd approve merge` in a terminal.'))
691 })
692 return {}
693 case 'publish':
694 return { text: await publishFromCommand($, root) }
695 case 'ship':
696 return { text: SHIP_RETIRED }
697 case 'decide':
698 case 'sign-off': {
699 const proposal = proposalAsked(asked.verb, asked.rest)
700 return {
701 text:
702 proposal === null
703 ? 'Say `/aidd decide PROP-1 go` (or no-go, with why after it), or `/aidd sign-off PROP-1`.'
704 : await proposalFromCommand($, root, proposal, askFirst),
705 }
706 }
707 case 'update':
708 return { text: await updateFromCommand($, root) }
709 case 'init':
710 return { text: await initFromCommand($, root, asked.rest) }
711 default:
712 return next(e)
713 }
714 }).catch(() => ({ text: '/aidd failed inside Claude Code; run the aidd command in a shell.' }))
715
716 // "approve SPEC-1", typed by the person at this terminal: handled here, never sent to the model. A bare "approve"
717 // is handled only when a Spec waits on your approval; otherwise it is an ordinary prompt.
718 on('prompt.submit', async ($, e, next) => {
719 const typedHere = e.origin.kind === 'composer' && !e.isMeta && !e.command
720 if (typedHere && isLand(e.text)) {
721 const root = await $.session.root()
722 $.clock.after(0, () => {
723 landFromPrompt($, root).catch(() => $.ui.toast('aidd could not land it: run `aidd land` in a terminal.'))
724 })
725
726 return { drop: 'aidd: landing this branch. This prompt does not go to the model.' }
727 }
728 if (typedHere && isMergeIt(e.text)) {
729 const root = await $.session.root()
730 $.clock.after(0, () => {
731 mergeFromPrompt($, root).catch(() => $.ui.toast('aidd could not record the approval: run `aidd approve merge` in a terminal.'))
732 })
733
734 return { drop: 'aidd: approving the merge of this branch\'s pull request. This prompt does not go to the model.' }
735 }
736 if (typedHere && isRetiredShip(e.text)) {
737 return { drop: `aidd: ${SHIP_RETIRED} This prompt does not go to the model.` }
738 }
739 const standing = typedHere ? standingAsked(e.text) : null
740 if (standing !== null) {
741 const root = await $.session.root()
742 $.clock.after(0, () => {
743 standingFromPrompt($, root, standing)
744 .then(said => $.ui.toast(said, { timeoutMs: 12_000 }))
745 .catch(() => $.ui.toast('aidd could not record it: run `aidd approve all` in a terminal.'))
746 })
747 return { drop: `aidd: ${standing === 'stop' ? 'stopping your standing approval' : 'recording your standing approval'}. This prompt does not go to the model.` }
748 }
749 const asked = typedHere ? approvalIds(e.text) : null
750 if (asked === null) {
751 return next(e)
752 }
753 const root = await $.session.root()
754 if ((await approveAsked($, root, asked)) !== undefined) {
755 return next(e)
756 }
757 const what = asked === 'merge' ? 'the merge of this branch' : asked.length > 0 ? asked.join(', ') : 'what waits on you'
758 return { drop: `aidd: approving ${what}. This prompt does not go to the model.` }
759 }).catch(($, e, next) => {
760 // The prompt goes on as typed, as it would without this mod: aidd's own prompt hook still reads it.
761 $.ui.toast(
762 'The aidd pane could not read this prompt, so it goes on as you typed it. aidd\'s own prompt hook still reads it, so a decision you typed is recorded there.',
763 { timeoutMs: 12_000 },
764 )
765 return next(e)
766 })
767
768 // Each turn may change what is open; refresh after it, without holding the turn up.
769 on('turn.complete', async ($, e, next) => {
770 if (e.agentId === undefined && (await isOpen($))) {
771 $.clock.after(0, () => refreshInBackground($))
772 }
773
774 return next(e)
775 }).catch(($, e, next) => next(e))
776
777 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
778 const { Box, Text, Button } = $.ui.resolve(e)
779 const snap: Snapshot | null = await read($, shot)
780 const isLoading = await read($, loading)
781 const chosen = await read($, selected)
782 const focused = await read($, focus)
783 const openTask = await read($, opened)
784 // Choosing another item closes the Task that was open.
785 const choose = async (id: string) => {
786 await update($, selected, () => id)
787 await update($, opened, () => null)
788 }
789 const columns = e.props.bodyColumns
790 const rows = e.props.scroll.bodyRows
791 const isTight = columns < NARROW
792 const isDocked = e.props.placement === 'dock'
793
794 const header = (
795 <Box key="header" flexDirection="row" justifyContent="space-between" flexWrap="wrap">
796 <Box flexShrink={1} minWidth={0}>
797 <Text wrap="truncate-start">
798 <Text bold>{snap?.head || 'aidd'}</Text>
799 {snap ? <Text dimColor>{` ${snap.root.split('/').at(-1)}`}</Text> : ''}
800 <Text dimColor>{isLoading ? ' · refreshing…' : snap ? ` · updated ${clock(snap.at)}` : ''}</Text>
801 </Text>
802 </Box>
803 <Box flexShrink={0} columnGap={1} flexDirection="row">
804 {focused ? <Button key="all" label={`All (showing ${focused})`} hotkey="a" onPress={() => void update($, focus, () => null)} /> : ''}
805 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refreshInBackground($)} />
806 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={() => void close($)} />
807 </Box>
808 </Box>
809 )
810
811 const footer = (
812 <Text dimColor wrap="truncate-end">
813 {isTight
814 ? `↑↓ · Enter${openTask ? ' · b' : ''} · a · r · x · ctrl+x tab`
815 : `↑↓ move · Enter open${openTask ? ' · b back' : ''} · a all · r refresh · x close · ctrl+x tab: use the pane · Esc prompt`}
816 </Text>
817 )
818
819 // The pane is as tall as its window: the body takes what is left, and the autopilot line and the key hints stay
820 // on the last rows.
821 // Inline above the prompt, the pane fits its content instead, so it never takes the whole room.
822 const frame = (body: unknown[], pinned: unknown = '') => (
823 <Box flexDirection="column" height={isDocked ? rows : undefined}>
824 <Box flexDirection="column" flexGrow={1} flexShrink={1} overflow={isDocked ? 'hidden' : 'visible'} gap={1}>
825 {body as string[]}
826 </Box>
827 <Box flexDirection="column" flexShrink={0}>
828 {pinned as string}
829 {footer}
830 </Box>
831 </Box>
832 )
833
834 if (snap === null) {
835 return frame([header, <Text key="wait" dimColor>{isLoading ? 'Asking aidd…' : 'Press r to load.'}</Text>])
836 }
837
838 const errors = [snap.waiting, snap.branch]
839 .filter((section): section is { isOk: false; error: string } => !section.isOk)
840 .map(section => section.error)
841 // Focus on an Initiative or an Epic shows only its items; what each item holds up counts over all of them.
842 const allMine = snap.waiting.isOk ? snap.waiting.value : []
843 const allListed = snap.branch.isOk ? snap.branch.value : []
844 const frees = unblocks(groups(allMine, allListed).flatMap(group => group.steps))
845 const inFocus = (step: Step) => !focused || step.id === focused || step.parents.some(parent => parent.id === focused)
846 const sections = groups(allMine.filter(inFocus), allListed.filter(inFocus))
847 const every = sections.flatMap(section => section.steps)
848 const detail = every.find(step => step.id === chosen) ?? every[0]
849 const count = (tone: Tone) => sections.filter(section => section.tone === tone).reduce((n, section) => n + section.steps.length, 0)
850 const pilot = snap.autopilot
851 const live = pilot.isOk ? pilot.value.leases.find(lease => !lease.isStale) : undefined
852
853 // One line to read at a glance: who has to move next. A count of zero is left out; autopilot has its own line below.
854 const tally = (n: number, label: string, tone: Tone) =>
855 n > 0 ? (
856 <Text color={COLOR[tone]} dimColor={tone === 'waiting'} bold={tone === 'person'}>
857 {n} {label}
858 </Text>
859 ) : null
860 // A list of what waits on you that failed says nothing about it: its red line says why, never "Nothing needs you".
861 const parts = [
862 count('person') > 0 ? tally(count('person'), 'need you', 'person') : snap.waiting.isOk ? <Text color="success">Nothing needs you</Text> : null,
863 tally(count('blocked'), 'blocked', 'blocked'),
864 tally(count('agent'), 'with the agent', 'agent'),
865 tally(count('waiting'), 'waiting on other work', 'waiting'),
866 ].filter(part => part !== null)
867 const summary = (
868 <Text key="summary" wrap="wrap">
869 {parts.flatMap((part, i) => (i === 0 ? [part] : [<Text dimColor> · </Text>, part]))}
870 </Text>
871 )
872
873 // DIRECTION: a decision ladder (surface seed a2fc9c1f, candidate 7 of 7). THESIS: the lead's next decision first, then
874 // every small goal on the four rungs of the person's decisions; it refuses a board of grouped tickets. OWN-WORLD:
875 // Claude Code's own terminal theme; the claude color means it waits on you, success that a rung is done, dim that
876 // it is still ahead; ◆ ✓ · — carry the same meaning without color. Raised by the wayfinding sign (only the choice
877 // ahead, with what to type) and by the ticket wallet (a done rung stays stamped; nothing vanishes).
878 const shaped = snap.shape.isOk ? snap.shape.value : null
879 const asked = decisions(allMine, shaped, pilot.isOk ? pilot.value.last : null, !(pilot.isOk && pilot.value.leases.some(lease => !lease.isStale)))
880 const next = asked[0]
881 const band = (
882 <Box key="band" flexDirection="column">
883 {next ? (
884 <Text wrap="truncate-end">
885 <Text color={COLOR.person} bold>{`◆ ${next.rung ? `${next.rung} ` : ''}${next.label}`}</Text>
886 <Text bold>{next.id ? ` ${next.id}` : ''}</Text>
887 <Text>{next.title ? ` ${next.title}` : ''}</Text>
888 </Text>
889 ) : snap.waiting.isOk ? (
890 <Text color="success">✓ Nothing needs you</Text>
891 ) : (
892 <Text color="warning">? aidd could not say what waits on you: press r to try again</Text>
893 )}
894 {next && next.type ? (
895 <Text wrap="truncate-end">
896 <Text dimColor>{' type '}</Text>
897 <Text color="suggestion">{next.type}</Text>
898 <Text dimColor>{asked.length > 1 ? ` · ${asked.length - 1} more wait on you` : ''}</Text>
899 </Text>
900 ) : (
901 ''
902 )}
903 </Box>
904 )
905
906 // The shape of the work: each big goal, then each small goal on its ladder. Enter on a small goal shows only its work.
907 const TONE: Record<Cell['tone'], string | undefined> = { person: COLOR.person, done: 'success', ahead: undefined, none: undefined }
908 const smallGoals = shaped?.smallGoals ?? []
909 const byGoal = new Map(smallGoals.map(goal => [goal.id, goal]))
910 const loose = smallGoals.filter(goal => !shaped?.initiatives.some(init => init.smallGoals.includes(goal.id)))
911 // Open Proposals lead: work someone outside the team must agree to before there is a plan.
912 const proposals = groups(allMine, allListed)
913 .flatMap(group => group.steps)
914 .filter(item => item.code.startsWith('proposal.'))
915 const ladderIds = [...proposals.map(item => item.id), ...(shaped?.initiatives ?? []).flatMap(init => [init.id, ...init.smallGoals]), ...loose.map(goal => goal.id)]
916 const ladderShown = ladderIds.slice(0, PLAN_LINES + 2)
917 const leadWidth = Math.max(10, ...ladderIds.map(id => id.length + 6))
918 // How a small goal reaches users, in a column wide enough for the longest name: two spaces, `incremental`, one space.
919 const REACH_COL = 14
920 // Wide, each rung says its decision and its name; narrow, the decision alone, as the band names it.
921 const isRoomy = columns >= leadWidth + REACH_COL + 11 * 4 + MIN_TITLE
922 const CELL = isRoomy ? 11 : 6
923 const RUNGS = isRoomy ? ['D1 plan', 'D2 specs', 'D3 release', 'D4 launch'] : ['D1', 'D2', 'D3', 'D4']
924 const titleRoom = Math.max(0, columns - leadWidth - REACH_COL - CELL * RUNGS.length - 2)
925 const ladder =
926 ladderIds.length > 0 && (smallGoals.length > 0 || proposals.length > 0) ? (
927 <Box key="ladder" flexDirection="column">
928 <Text wrap="truncate-end">
929 <Text bold>{'Shape of work'.padEnd(leadWidth + titleRoom + REACH_COL)}</Text>
930 <Text dimColor>{RUNGS.map(r => r.padEnd(CELL)).join('')}</Text>
931 </Text>
932 {ladderShown.map(id => {
933 const proposal = proposals.find(item => item.id === id)
934 if (proposal) {
935 return (
936 <Button key={`ladder-${id}`} plain onPress={() => void update($, focus, now => (now === id ? null : id))}>
937 <Text bold color={focused === id ? COLOR.person : undefined}>{` ${id}`.padEnd(leadWidth)}</Text>
938 <Text>{fit(proposal.title || proposal.what, titleRoom + REACH_COL)}</Text>
939 <Text dimColor>{` proposal, ${proposal.status}`}</Text>
940 </Button>
941 )
942 }
943 const goal = byGoal.get(id)
944 const init = shaped?.initiatives.find(one => one.id === id)
945 if (init) {
946 const finished = init.smallGoals.filter(gid => {
947 const g = byGoal.get(gid)
948 return g !== undefined && g.release.total > 0 && g.release.done >= g.release.total && (g.launch === null || g.launch.state === 'done')
949 }).length
950 return (
951 <Button key={`ladder-${id}`} plain onPress={() => void update($, focus, now => (now === id ? null : id))}>
952 <Text bold color={focused === id ? COLOR.person : undefined}>{` ${id}`.padEnd(leadWidth)}</Text>
953 <Text>{fit(init.title, titleRoom + REACH_COL)}</Text>
954 <Text dimColor>{` ${finished}/${init.smallGoals.length} small goals done`}</Text>
955 </Button>
956 )
957 }
958 if (!goal) {
959 return ''
960 }
961 const isFocused = focused === id
962 return (
963 <Button key={`ladder-${id}`} plain onPress={() => void update($, focus, now => (now === id ? null : id))}>
964 <Text bold={isFocused} color={isFocused ? COLOR.person : undefined}>
965 {` ${id}`.padEnd(leadWidth)}
966 </Text>
967 <Text>{fit(goal.title, titleRoom).padEnd(titleRoom)}</Text>
968 <Text dimColor>{` ${goal.reaches.padEnd(REACH_COL - 2)}`}</Text>
969 {cells(goal).map((cell, i) => (
970 <Text key={`cell-${id}-${i}`} color={TONE[cell.tone]} bold={cell.tone === 'person'} dimColor={cell.tone === 'none' || cell.tone === 'ahead'}>
971 {cell.mark.padEnd(CELL)}
972 </Text>
973 ))}
974 </Button>
975 )
976 })}
977 {ladderIds.length > ladderShown.length ? <Text dimColor>{` +${ladderIds.length - ladderShown.length} more: aidd goal list`}</Text> : ''}
978 </Box>
979 ) : snap.shape.isOk ? (
980 ''
981 ) : (
982 <Text key="ladder" dimColor wrap="truncate-end">{`Shape of work: ${snap.shape.error}`}</Text>
983 )
984
985 // Rows left for items once the header, the plan, the details (DETAIL_ROWS), autopilot (1), the key hints (1), the
986 // gaps, and each section's heading are drawn; at least 2 items a section.
987 const fixed = 2 + 2 + (ladderIds.length > 0 ? ladderShown.length + 2 : 0) + DETAIL_ROWS + 3 + sections.length + 2
988 const perSection = Math.max(2, Math.floor((rows - fixed - sections.length) / Math.max(1, sections.length)))
989
990 // One column layout for every section, so ids, titles, waits and priorities line up down the pane. The title
991 // keeps at least MIN_TITLE cells: when the room is short, the priority goes first, then the waits.
992 const noteOf = (step: Step): string => {
993 const freed = frees.get(step.id)?.length ?? 0
994 return step.waitingOn.length > 0 ? `waits on ${step.waitingOn.join(', ')}` : freed > 0 ? `${freed} wait on it` : ''
995 }
996 const idWidth = Math.max(6, ...every.map(step => step.id.length)) + 2
997 let noteWidth = Math.min(MAX_NOTE, Math.max(0, ...every.map(step => noteOf(step).length)))
998 let priorityWidth = Math.max(0, ...every.map(step => step.priority.length))
999 // The Epic column shows only when the items serve more than one Epic, and never while one is in focus.
1000 const epicOf = (step: Step) => step.parents.at(-1)?.id ?? ''
1001 let epicWidth = !focused && new Set(every.map(epicOf)).size > 1 ? Math.max(...every.map(step => epicOf(step).length)) : 0
1002 const titleWidth = () =>
1003 columns - 2 - idWidth - (noteWidth ? noteWidth + 2 : 0) - (epicWidth ? epicWidth + 2 : 0) - (priorityWidth ? priorityWidth + 2 : 0)
1004 for (const drop of [() => (priorityWidth = 0), () => (epicWidth = 0), () => (noteWidth = 0)]) {
1005 if (titleWidth() < MIN_TITLE) {
1006 drop()
1007 }
1008 }
1009 const nameWidth = Math.max(1, titleWidth())
1010
1011 // A row is one Button: Enter or a click shows its details below.
1012 const row = (step: Step, tone: Tone) => {
1013 const isChosen = step.id === detail?.id
1014 const note = noteOf(step)
1015 const isLoud = step.priority === 'urgent' || step.priority === 'high'
1016 const quiet = tone === 'waiting' && !isChosen
1017
1018 return (
1019 <Button key={`row-${step.id || step.what}`} plain onPress={() => void choose(step.id)}>
1020 <Text color={isChosen ? COLOR[tone] : undefined} bold={isChosen}>
1021 {isChosen ? '› ' : ' '}
1022 </Text>
1023 <Text bold={isChosen} dimColor={quiet}>
1024 {fit(step.id, idWidth - 1).padEnd(idWidth)}
1025 </Text>
1026 <Text dimColor={quiet}>{fit(step.title || step.what, nameWidth).padEnd(nameWidth)}</Text>
1027 {noteWidth ? (
1028 <Text color={step.waitingOn.length === 0 && note ? COLOR[tone] : undefined} dimColor={step.waitingOn.length > 0}>
1029 {` ${fit(note, noteWidth).padEnd(noteWidth)}`}
1030 </Text>
1031 ) : (
1032 ''
1033 )}
1034 {epicWidth ? <Text dimColor>{` ${epicOf(step).padEnd(epicWidth)}`}</Text> : ''}
1035 {priorityWidth ? <Text dimColor={!isLoud}>{` ${step.priority.padEnd(priorityWidth)}`}</Text> : ''}
1036 </Button>
1037 )
1038 }
1039
1040 const section = (group: Group) => {
1041 const shown = group.steps.slice(0, perSection)
1042
1043 return (
1044 <Box key={`section-${group.key}`} flexDirection="column">
1045 <Text wrap="truncate-end">
1046 <Text bold color={COLOR[group.tone]}>
1047 {group.title}
1048 </Text>
1049 <Text dimColor>
1050 {` ${group.steps.length}`}
1051 {group.hint ? ` ${group.hint}` : ''}
1052 </Text>
1053 </Text>
1054 {shown.map(step => row(step, group.tone))}
1055 {group.steps.length > shown.length ? <Text dimColor>{` +${group.steps.length - shown.length} more: aidd list --branch`}</Text> : ''}
1056 </Box>
1057 )
1058 }
1059
1060 // `aidd …` in backticks reads as a command to run.
1061 const commands = (line: string) =>
1062 line.split('`').map((part, i) => (i % 2 === 1 ? <Text color="suggestion">{part}</Text> : part))
1063
1064 // A labelled line of the detail card; the value wraps under itself, the labels stay in one column.
1065 const field = (label: string, value: unknown, isShown: boolean) =>
1066 isShown ? (
1067 <Box key={`field-${label}`} flexDirection="row">
1068 <Box width={12} flexShrink={0}>
1069 <Text dimColor>{label}</Text>
1070 </Box>
1071 <Box flexGrow={1} flexShrink={1} minWidth={0}>
1072 <Text wrap="wrap">{value as string}</Text>
1073 </Box>
1074 </Box>
1075 ) : (
1076 ''
1077 )
1078
1079 const rule = <Text dimColor>{'─'.repeat(Math.max(0, columns))}</Text>
1080
1081 // A part of the detail card: a dim heading after a blank line, so About, Status, and Tasks read apart without boxes.
1082 const group = (title: string) => (
1083 <Box key={`group-${title}`} marginTop={1}>
1084 <Text dimColor bold>
1085 {title}
1086 </Text>
1087 </Box>
1088 )
1089
1090 // A Spec's Tasks, one row each in a grid: Enter opens one.
1091 const taskRows = (list: Task[]) => {
1092 const idWidth = Math.max(...list.map(task => task.id.length)) + 2
1093 const statusWidth = Math.max(...list.map(task => task.status.length)) + 2
1094 const roleWidth = Math.max(...list.map(task => task.role.length))
1095 const room = Math.max(8, columns - 4 - idWidth - statusWidth - roleWidth)
1096 return list.map(task => (
1097 <Button key={`task-${task.id}`} plain onPress={() => void update($, opened, () => task.id)}>
1098 {' '}
1099 <Text bold>{task.id.padEnd(idWidth)}</Text>
1100 {fit(task.title, room).padEnd(room)}
1101 {' '}
1102 <Text color={task.status === 'blocked' ? 'warning' : task.status === 'done' ? 'success' : undefined}>{task.status.padEnd(statusWidth)}</Text>
1103 <Text dimColor>{task.role}</Text>
1104 </Button>
1105 ))
1106 }
1107
1108 // The item's folder, `specs/<ID>/`, one row per entry: its docs, its design, and its explorations, each named from
1109 // the folder. An open exploration waits on a person's pick, so it shows in the person's color.
1110 const fileRows = (step: Step, base: string) => {
1111 const kindWidth = Math.max(...step.files.filter(file => file.kind !== 'more').map(file => file.kind.length), 0) + 2
1112 return step.files.map(file => {
1113 if (file.kind === 'more') {
1114 return <Text key="file-more" dimColor>{` … ${file.count} more`}</Text>
1115 }
1116 const name = file.path.startsWith(base) ? file.path.slice(base.length) : file.path
1117 const isOpen = file.status === 'open'
1118 return (
1119 <Text key={`file-${file.path}`} wrap="truncate-end">
1120 {' '}
1121 <Text dimColor>{file.kind.padEnd(kindWidth)}</Text>
1122 {file.kind === 'exploration' || file.kind === 'design' || file.kind === 'folder' ? `${name}/` : name}
1123 {file.kind === 'exploration' ? <Text color={isOpen ? COLOR.person : 'success'}>{isOpen ? ' open: a person picks' : ' decided'}</Text> : ''}
1124 {file.question ? <Text dimColor>{` · ${file.question}`}</Text> : ''}
1125 </Text>
1126 )
1127 })
1128 }
1129
1130 // One Task opened from its Spec: why it is where it is, who does it, and what it may change.
1131 const taskCard = (step: Step, task: Task) => (
1132 <Box key="details" flexDirection="column">
1133 {rule}
1134 <Box flexDirection="row" justifyContent="space-between">
1135 <Text bold wrap="wrap">
1136 <Text color={COLOR[toneOf(step)]}>{task.id}</Text>
1137 {task.title ? ` ${task.title}` : ''}
1138 </Text>
1139 <Button key="back" label={`Back to ${step.id}`} hotkey="b" onPress={() => void update($, opened, () => null)} />
1140 </Box>
1141 <Text dimColor wrap="truncate-end">{[task.status, task.assignee, `in ${step.id}${step.title ? ` · ${step.title}` : ''}`].filter(Boolean).join(' · ')}</Text>
1142 {field('Why', task.why, task.why !== '')}
1143 {field('Role', task.role, task.role !== '')}
1144 {field('Scope', task.scope.join('\n'), task.scope.length > 0)}
1145 {field('Waits on', task.dependsOn.join(', '), task.dependsOn.length > 0)}
1146 </Box>
1147 )
1148
1149 // The chosen item in full, under a rule: what the Spec is about, where it stands, and its Tasks.
1150 const details = (step: Step) => {
1151 const task = step.taskList.find(one => one.id === openTask)
1152 if (task) {
1153 return taskCard(step, task)
1154 }
1155 const freed = frees.get(step.id) ?? []
1156 const meta = [step.priority, step.status, step.owner, step.where].filter(Boolean).join(' · ')
1157 const said = step.about
1158 const hasAbout = said.for !== '' || said.problem !== '' || said.goals.length > 0
1159 // A Proposal says who it is for, its need, and its goal; a Spec, its problem and goals.
1160 const isProposal = said.kind === 'proposal'
1161 // The item's own folder, beside its file: `specs/SPEC-12.md` keeps its docs in `specs/SPEC-12/`.
1162 const folder = `${specsFolder([step])}/${step.id}/`
1163
1164 return (
1165 <Box key="details" flexDirection="column">
1166 {rule}
1167 <Text bold wrap="wrap">
1168 <Text color={COLOR[toneOf(step)]}>{step.id}</Text>
1169 {step.title ? ` ${step.title}` : ''}
1170 </Text>
1171 {meta ? <Text dimColor wrap="truncate-end">{meta}</Text> : ''}
1172 {hasAbout ? group('About') : ''}
1173 {field('For', said.for, said.for !== '')}
1174 {field(isProposal ? 'Need' : 'Problem', said.problem, said.problem !== '')}
1175 {isProposal
1176 ? field('Goal', said.goals[0] ?? '', said.goals.length > 0)
1177 : field('Goals', said.goals.map(goal => `• ${goal}`).join('\n'), said.goals.length > 0)}
1178 {group('Status')}
1179 {field('Now', step.what, true)}
1180 {field('Waits on', step.waitingOn.join(', '), step.waitingOn.length > 0)}
1181 {field('Holds up', freed.join(', '), freed.length > 0)}
1182 {field('Next', commands(step.how), step.how !== '')}
1183 {field('Part of', step.why, step.why !== '')}
1184 {step.taskList.length > 0 ? group(`Tasks · ${step.tasks}`) : ''}
1185 {step.taskList.length > 0 ? taskRows(step.taskList) : ''}
1186 {step.files.length > 0 ? group(`Files · ${folder}`) : ''}
1187 {step.files.length > 0 ? fileRows(step, folder) : ''}
1188 </Box>
1189 )
1190 }
1191
1192 // Autopilot's one line, pinned above the key hints: its state, then what it did and said last.
1193 const autopilot = !pilot.isOk ? (
1194 <Text key="pilot" color="error" wrap="truncate-end">
1195 Autopilot: {pilot.error}
1196 </Text>
1197 ) : (
1198 <Text key="pilot" wrap="truncate-end">
1199 <Text bold>Autopilot </Text>
1200 {live ? <Text color="success">live</Text> : <Text dimColor>idle</Text>}hooks/aidd.ts 955 lines1import type { About, Autopilot, BranchAge, Decision, ItemFile, Lease, Link, Parent, Rung, Section, Shape, SmallGoal, Snapshot, Step, Task } from '../types'
2
3// Plain functions over what aidd printed: no engine calls here, so Claude Code reads every $ call in register.tsx.
4
5export const TIMEOUT_MS = 20_000
6
7export const NOT_FOUND = '`aidd` did not start: it is not on the PATH Claude Code was started with. Install it (docs/INSTALL.md).'
8
9type Json = Record<string, unknown>
10
11// The characters that can redraw a terminal or reorder what a person reads: the C0 and C1 controls (line breaks and ESC
12// among them), DEL, the bidi marks, embeddings, overrides, and isolates, and the Unicode line and paragraph separators.
13// The CLI's `plain()` (src/aidd/cli/commands/decide.py) owns this set; OpenCode's `oneLine` (status.js) uses it too.
14const UNSAFE = / *(?:[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069] *)+/g
15
16// Text from a file or an agent, as the pane shows it: each run of those characters, with the spaces beside it, is one
17// space, so a Spec's words can't move the cursor, recolor the pane, or reorder what a person reads.
18export const clean = (said: string): string => said.replace(UNSAFE, ' ')
19
20// Every string the pane reads from aidd's JSON is cleaned here, at the boundary, since a title, a Task, a label, or a
21// commit subject comes from a file or an agent.
22const text = (value: unknown): string => (typeof value === 'string' ? clean(value) : '')
23
24// The first line aidd said, for a failure: the rest is detail the pane has no room for.
25export const firstLine = (said: string): string => said.trim().split('\n')[0] ?? ''
26
27// The last line a command printed, for a failure whose reason comes after its progress, such as an installer's.
28export const lastLine = (said: string): string =>
29 said
30 .split('\n')
31 .map(line => line.trim())
32 .filter(Boolean)
33 .at(-1) ?? ''
34
35// "aidd", "aidd and aidd-mods", or "aidd, aidd-team, and aidd-mods".
36export function andList(words: readonly string[]): string {
37 if (words.length <= 2) {
38 return words.join(' and ')
39 }
40 return `${words.slice(0, -1).join(', ')}, and ${words.at(-1)}`
41}
42
43// The person's standing approval in force (`aidd list --json`'s `standing`), as the line that says so and how to end
44// it, or null when none is. OpenCode's status says the same (status.js `standingLine`).
45export function standingNote(raw: Json): string | null {
46 const s = raw['standing']
47 if (typeof s !== 'object' || s === null) {
48 return null
49 }
50 const standing = s as Json
51 const scope = text(standing['scope'])
52 const what = `all${scope.includes('and-publish') ? ' and publishing' : ''}${scope.endsWith('but-major') ? ' but major' : ''}`
53 const since = text(standing['since']).slice(0, 16).replace('T', ' ')
54 return `Standing approval is on, so aidd records your approvals. To end it: \`/aidd stop\`, or type \`stop approving\`. It covers ${what}, since ${since}.`
55}
56
57// The word that names an explore branch, in `/aidd explore <topic>` and the typed `explore branch <topic>`, and the
58// branch's prefix: one place each to rename (aidd's own are workitems.EXPLORE_WORD and EXPLORE_PREFIX).
59export const EXPLORE_WORD = 'explore'
60export const EXPLORE_PREFIX = `${EXPLORE_WORD}/`
61
62// `/aidd explore <topic>`: the topic, as the typed words take it (letters, digits, `.`, `_`, `-`), or null.
63export function exploreAsked(rest: string): string | null {
64 const m = new RegExp(`^(?:branch\\s+)?(?:${EXPLORE_PREFIX})?([A-Za-z0-9][A-Za-z0-9._-]{0,60})$`, 'i').exec(rest.trim())
65 return m ? (m[1] ?? null) : null
66}
67
68export const EXPLORE_USAGE = `Say \`/aidd ${EXPLORE_WORD} <topic>\`, such as \`/aidd ${EXPLORE_WORD} caching\`: letters, digits, \`.\`, \`_\`, or \`-\`.`
69
70export const exploreQuestion = (branch: string): string =>
71 `Move this worktree to the ${EXPLORE_WORD} branch ${branch}? Nothing merges from it as it is, and its edits are free once you type \`${EXPLORE_WORD} branch ${branch.slice(EXPLORE_PREFIX.length)}\`, which aidd records as yours.`
72
73// What changed outside the agent's session since the person's last decision, as a preview (`--show`) lists it: the
74// person's answer takes it in as theirs, so a dialog shows it before they answer. `held` marks a change that weakens the
75// guard, which only a dialog takes in: one is asked about even from the person's keyboard.
76export type Outside = { what: string; restore: string; held: boolean; guard: boolean }[]
77
78export function outside(raw: Json): Outside {
79 const listed = Array.isArray(raw['outside_changes']) ? (raw['outside_changes'] as Json[]) : []
80 return listed.filter(c => typeof c === 'object' && c !== null).map(c => ({ what: text(c['what']), restore: text(c['restore']), held: c['held'] === true, guard: c['guard'] === true }))
81}
82
83// The hash a preview names for what it showed changed, which the confirmed answer passes on (`--outside-hash`), so it
84// takes in exactly that; [] when nothing changed.
85export const outsideHash = (raw: Json): string[] => {
86 const hash = text(raw['outside_hash'])
87 return /^[0-9a-f]{64}$/.test(hash) ? ['--outside-hash', hash] : []
88}
89
90export const weakens = (changes: Outside): boolean => changes.some(c => c.held)
91
92// What a change asks of the answer: one that could stop the guard itself stays held whatever this dialog answers,
93// since only aidd's own MCP dialog or the person's terminal takes it in.
94const tag = (c: Outside[number]): string =>
95 c.guard ? " (it could stop the guard, so this answer leaves it held: aidd's own dialog or your terminal takes it in)" : c.held ? ' (it weakens the guard)' : ''
96
97// The lines a dialog shows for it, before its question; none when nothing changed.
98export function outsideLines(changes: Outside): string {
99 if (changes.length === 0) {
100 return ''
101 }
102 const each = changes.map(c => `\n - ${c.what}${tag(c)} (to put it back: ${c.restore})`).join('')
103 return `\n\nSince your last decision, this also changed outside the agent's session, and counts as yours with this answer:${each}`
104}
105
106// The line aidd adds after a decision it recorded when something changed outside the session since the last one, or ''.
107export const countedLine = (said: string): string =>
108 said
109 .split('\n')
110 .map(line => clean(line).trim())
111 .find(line => line.startsWith('Since your last decision')) ?? ''
112
113// An explore branch (`aidd list --json`'s `explore_branch`), as one line, or null on any other branch.
114export function exploreNote(raw: Json): string | null {
115 const branch = raw['explore_branch']
116 return typeof branch === 'string' && branch !== '' ? `${branch} is an explore branch: free edits, and nothing merges from it` : null
117}
118
119export function json(section: Section<string>): Section<Json> {
120 if (!section.isOk) {
121 return section
122 }
123 try {
124 const parsed: unknown = JSON.parse(section.value)
125
126 return typeof parsed === 'object' && parsed !== null
127 ? { isOk: true, value: parsed as Json }
128 : { isOk: false, error: 'aidd printed no JSON object' }
129 } catch (error) {
130 return { isOk: false, error: `aidd printed something that is not JSON: ${error instanceof Error ? error.message : error}` }
131 }
132}
133
134const RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
135
136// Natural order for ids: SPEC-2 before SPEC-10.
137const byId = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true })
138
139const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string').map(clean) : [])
140
141// "INIT-1 (0/3) → EPIC-1 (0/3)": where the item sits, from aidd's `why` chain.
142function chain(why: unknown): string {
143 const links = Array.isArray(why) ? (why as Json[]) : []
144
145 return links
146 .filter(link => text(link['id']))
147 .map(link => `${text(link['id'])}${link['progress'] ? ` (${text(link['progress'])})` : ''}`)
148 .join(' → ')
149}
150
151// The Initiative and the Epic of an item, outermost first, from aidd's `why` chain.
152function links(why: unknown): Link[] {
153 const chainOf = Array.isArray(why) ? (why as Json[]) : []
154
155 return chainOf
156 .filter(link => text(link['id']) && (link['kind'] === 'initiative' || link['kind'] === 'epic'))
157 .map(link => ({ kind: text(link['kind']), id: text(link['id']), progress: text(link['progress']) }))
158}
159
160// Every Initiative and Epic the items name, once each, in the order first met; titles come later.
161export function parentsOf(items: Step[]): Parent[] {
162 const seen = new Map<string, Parent>()
163 for (const item of items) {
164 for (const link of item.parents) {
165 if (!seen.has(link.id)) {
166 seen.set(link.id, { ...link, title: '', from: '' })
167 }
168 }
169 }
170
171 return [...seen.values()]
172}
173
174// The folder the work items sit in, the config's `specs_dir`: the folder of an item the CLI listed whose file is named
175// by its id (`<folder>/SPEC-1.md`; a Proposal's `proposals/PROP-1/proposal.md` is not), else `specs`. The CLI is the
176// one source of truth for where the work items are; the pane never assumes the folder.
177export function specsFolder(items: Step[]): string {
178 const where = items.map(item => item.where).find(w => isInside(w) && items.some(item => item.id && w.endsWith(`/${item.id}.md`)))
179
180 return where?.includes('/') ? where.slice(0, where.lastIndexOf('/')) : 'specs'
181}
182
183// The id an Initiative or an Epic file is named by, such as EPIC-2: read from `<specs folder>/<id>.md` only.
184export const isWorkId = (id: string) => /^[A-Z][A-Z0-9]*-[0-9]+$/.test(id)
185
186// "1 task, blocked" or "3 tasks: 1 done, 2 todo".
187function taskLine(tasks: unknown): string {
188 const list = Array.isArray(tasks) ? (tasks as Json[]) : []
189 if (list.length === 0) {
190 return ''
191 }
192 const counts = new Map<string, number>()
193 for (const task of list) {
194 const status = text(task['status']) || 'unknown'
195 counts.set(status, (counts.get(status) ?? 0) + 1)
196 }
197 const parts = [...counts].map(([status, n]) => (list.length === 1 ? status : `${n} ${status}`))
198
199 return `${list.length} task${list.length === 1 ? '' : 's'}${list.length === 1 ? ', ' : ': '}${parts.join(', ')}`
200}
201
202// Each Task of a Spec, as `aidd list --json` gives it.
203function taskList(tasks: unknown): Task[] {
204 const list = Array.isArray(tasks) ? (tasks as Json[]) : []
205
206 return list.map(task => ({
207 id: text(task['id']),
208 title: text(task['title']),
209 status: text(task['status']),
210 why: text(task['why']),
211 assignee: text(task['assignee']),
212 role: text(task['role']),
213 scope: strings(task['scope']),
214 dependsOn: strings(task['depends_on']),
215 }))
216}
217
218export const NO_ABOUT: About = { kind: '', for: '', problem: '', goals: [] }
219
220// What an item is about, from its file; a TODO is no text, so a stub says nothing yet.
221// A Spec: the first paragraph of `## Problem`, and the bullets of `## Goals` (at most three).
222// A Proposal: `audience` · `addressee`, the first paragraph of `## The need` (else `## Summary`), and its `goal`.
223export function about(source: string): About {
224 const body = source.replace(/<!--[\s\S]*?-->/g, '')
225 const section = (name: string): string[] => {
226 const at = body.search(new RegExp(`^## ${name}\\s*$`, 'm'))
227 if (at < 0) {
228 return []
229 }
230 const rest = body.slice(at).split('\n').slice(1)
231 const end = rest.findIndex(line => line.startsWith('## '))
232 return (end < 0 ? rest : rest.slice(0, end)).map(line => line.trimEnd())
233 }
234 const real = (line: string) => line !== '' && !/^TODO\b/i.test(line)
235 const paragraph = (lines: string[]) => {
236 const firstBlank = lines.findIndex((line, i) => line === '' && lines.slice(0, i).some(Boolean))
237 const text = clean((firstBlank < 0 ? lines : lines.slice(0, firstBlank)).filter(Boolean).join(' ')).trim()
238 return real(text) ? text : ''
239 }
240 const bullets = (lines: string[]) => lines.filter(line => /^\s*[-*] /.test(line)).map(line => clean(line.replace(/^\s*[-*] /, '')).trim())
241 const kind = frontField(source, 'kind')
242
243 if (kind === 'proposal') {
244 const goal = frontField(source, 'goal')
245 return {
246 kind,
247 for: [frontField(source, 'audience'), frontField(source, 'addressee')].filter(real).join(' · '),
248 problem: paragraph(section('The need')) || paragraph(section('Summary')),
249 goals: real(goal) ? [goal] : [],
250 }
251 }
252 return { kind: kind || 'spec', for: '', problem: paragraph(section('Problem')), goals: bullets(section('Goals')).filter(real).slice(0, 3) }
253}
254
255// `aidd list --json` steps, without the "nothing is open" placeholder rows: what a person holds
256// first, then blocked work, then by priority and id.
257// The item's folder, from `files` in `aidd list --json`: the open explorations first (a person may need to pick),
258// then the documents in the order aidd gives them.
259export function itemFiles(raw: unknown): ItemFile[] {
260 const list = Array.isArray(raw) ? (raw as Json[]) : []
261 const files = list
262 .map(entry => ({
263 path: text(entry['path']),
264 kind: text(entry['kind']),
265 question: text(entry['question']),
266 status: text(entry['status']),
267 count: typeof entry['count'] === 'number' ? entry['count'] : 0,
268 }))
269 .filter(file => file.kind && (file.path || file.kind === 'more'))
270 const open = (file: ItemFile) => file.kind === 'exploration' && file.status === 'open'
271
272 return [...files.filter(open), ...files.filter(file => !open(file))]
273}
274
275export function steps(listed: Json): Step[] {
276 const raw = Array.isArray(listed['steps']) ? (listed['steps'] as Json[]) : []
277
278 return raw
279 .filter(step => !text(step['code']).startsWith('nothing'))
280 .map(step => {
281 const id = text(step['id'])
282 const what = text(step['what'])
283
284 return {
285 id,
286 code: text(step['code']),
287 title: '',
288 what: id && what.startsWith(`${id} `) ? what.slice(id.length + 1) : what,
289 who: text(step['who']),
290 how: text(step['how']),
291 priority: text(step['priority']),
292 status: text(step['status']),
293 owner: text(step['owner']),
294 labels: strings(step['labels']),
295 waitingOn: strings(step['waiting_on']),
296 why: chain(step['why']),
297 parents: links(step['why']),
298 tasks: taskLine(step['tasks']),
299 taskList: taskList(step['tasks']),
300 where: text(step['where']),
301 isBlocked: step['blocked'] === true,
302 about: NO_ABOUT,
303 files: itemFiles(step['files']),
304 }
305 })
306 .sort(
307 (a, b) =>
308 Number(a.who === 'agent') - Number(b.who === 'agent') ||
309 Number(b.isBlocked) - Number(a.isBlocked) ||
310 (RANK[a.priority] ?? 9) - (RANK[b.priority] ?? 9) ||
311 byId(a.id, b.id),
312 )
313}
314
315// One plain `key: value` line of a work item's front matter, unquoted and clean; '' when it has none.
316export function frontField(source: string, key: string): string {
317 const front = /^---\n([\s\S]*?)\n---/.exec(source)?.[1] ?? ''
318 const line = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm').exec(front)?.[1] ?? ''
319
320 return clean(line.replace(/\s+#.*$/, '').replace(/^(["'])(.*)\1$/, '$2'))
321}
322
323// The `title:` of a work item's front matter: a person reads a Spec by its title, not its id.
324export const frontTitle = (source: string): string => frontField(source, 'title')
325
326// Only a plain relative path inside the repository is read.
327export const isInside = (where: string) => /^[\w./-]+\.md$/.test(where) && !where.split('/').includes('..')
328
329export function autopilot(status: Json, now: number): Autopilot {
330 const leases: Lease[] = (Array.isArray(status['leases']) ? (status['leases'] as Json[]) : []).map(lease => ({
331 work: text(lease['work']),
332 by: text(lease['by']),
333 session: text(lease['session_id']),
334 expiresAt: text(lease['expires_at']),
335 timebox: text(lease['timebox']),
336 isStale: lease['stale'] === true,
337 }))
338 const journal = Array.isArray(status['journal']) ? (status['journal'] as Json[]) : []
339 const notices = Array.isArray(status['notices']) ? (status['notices'] as Json[]) : []
340 const entry = journal.at(-1)
341 const notice = notices.at(-1)
342 const tokens = typeof entry?.['tokens'] === 'number' ? ` · ${Math.round(entry['tokens'] / 1000)}k tokens` : ''
343
344 return {
345 leases,
346 last: entry ? `${text(entry['code'])} ${text(entry['result'])}${tokens} · ${ago(text(entry['at']), now)}` : null,
347 heard: notice ? `${text(notice['event'])}${notice['reason'] ? ` (${text(notice['reason'])})` : ''}` : null,
348 }
349}
350
351// The words around the ids that still make a prompt an approval: "approve SPEC-1 and SPEC-4 please".
352const FILLER = new Set(['and', 'spec', 'specs', 'the', 'both', 'them', 'it', 'please', 'pls', 'now', 'too', 'also', 'these', 'those', 'this'])
353
354// A standing approval's scope: `approve all`, with "and publish" letting the agent publish for the person too, and "but
355// major" keeping major changes for them.
356export type StandingScope = 'all' | 'all-but-major' | 'all-and-publish' | 'all-and-publish-but-major'
357
358// A standing approval, typed by the person: "approve all" (or "automate"), "and publish", "but major", and "stop
359// approving" (`/aidd stop`) ending it. The same phrases aidd's own prompt hook reads (src/aidd/workitems/typed_words.py).
360export function standingAsked(text: string): StandingScope | 'stop' | null {
361 if (/^\s*(?:\/aidd(?::aidd)?\s+(?:approve\s+)?stop|stop\s+approving(?:\s+for\s+me)?)\s*[.!]?\s*$/i.test(text)) {
362 return 'stop'
363 }
364 const m =
365 /^\s*(?:\/aidd(?::aidd)?\s+approve\s+all|(?:i\s+)?approve\s+all(?:\s+from\s+now\s+on)?|automate(?:\s+it|\s+all|\s+everything)?)(\s*,?\s+and\s+publish)?(\s*,?\s+(?:but|except)\s+major)?\s*[.!]?\s*$/i.exec(
366 text,
367 )
368 return m ? (`all${m[1] ? '-and-publish' : ''}${m[2] ? '-but-major' : ''}` as StandingScope) : null
369}
370
371// What a typed prompt approves: Spec ids ("approve SPEC-1", "I approve SPEC-1 and SPEC-4", "approve": [] for what
372// waits on you), or 'merge' ("approve the merge", "approve merging"), or null when the prompt is anything else, which
373// goes to the model as typed.
374export function approvalIds(text: string): string[] | 'merge' | null {
375 const m = /^\s*(?:i\s+)?approved?\b([\s\S]*)$/i.exec(text)
376 if (!m) {
377 return null
378 }
379 const words = (m[1] ?? '').split(/[\s,.;!&+]+/).filter(Boolean)
380 const ids: string[] = []
381 let merge = false
382 for (const word of words) {
383 if (/^[A-Za-z][A-Za-z0-9]*-[0-9]+$/.test(word)) {
384 ids.push(word.toUpperCase())
385 } else if (/^merg(e|ing)$/i.test(word)) {
386 merge = true
387 } else if (!FILLER.has(word.toLowerCase())) {
388 return null
389 }
390 }
391 if (merge) {
392 return ids.length === 0 ? 'merge' : null
393 }
394
395 return [...new Set(ids)]
396}
397
398// The three steps after Review, each the person's own word, read as aidd's prompt hook reads them
399// (src/aidd/workitems/typed_words.py): "land it" lands this branch on the local trunk (`aidd land`); "merge it" is the
400// Merge approval on its pull or merge request, with auto-merge; "publish" ("push it", "open a PR") takes it off this
401// machine, which aidd's own prompt hook records and the agent runs (`aidd publish`): this mod never pushes.
402export const isLand = (text: string): boolean => /^\s*(?:\/aidd(?::aidd)?\s+land|(?:please\s+)?land(?:\s+(?:it|this|this branch|now))?)\s*[.!]?\s*$/i.test(text)
403
404export const isMergeIt = (text: string): boolean =>
405 /^\s*(?:\/aidd(?::aidd)?\s+merge|(?:please\s+)?merge(?:\s+(?:it|this|now|(?:the|this)\s+(?:pr|mr|pull\s+request|merge\s+request)))?)\s*[.!]?\s*$/i.test(text)
406
407export const isPublish = (text: string): boolean =>
408 /^\s*(?:\/aidd(?::aidd)?\s+publish|(?:please\s+)?(?:publish|push)(?:\s+(?:it|this|this branch|now|to\s+(?:the\s+)?remote))?|(?:please\s+)?(?:open|create|make)\s+(?:a|the)\s+(?:pr|mr|pull\s+request|merge\s+request))\s*[.!]?\s*$/i.test(
409 text,
410 )
411
412// "ship it" named all three once: it records nothing now, and says which word to type.
413export const isRetiredShip = (text: string): boolean => /^\s*(?:\/aidd(?::aidd)?\s+ship|(?:please\s+)?ship(?:\s+(?:it|this|this branch|now))?)\s*[.!]?\s*$/i.test(text)
414
415
416export const SHIP_RETIRED =
417 '`ship it` named three steps: `land it` lands this branch on the local trunk, `publish` takes it off this machine, and `merge it` merges its PR/MR.'
418
419// Where a branch stands for "land it" and "merge it": its pull or merge request merges on the platform, or it lands here.
420export const ON_A_PLATFORM = 'This branch is on its platform, where its pull or merge request merges: type `merge it` there.'
421export const NOT_ON_A_PLATFORM = 'This branch is not on a platform yet: type `land it` to land it on the local trunk, or `publish` to push it and open its PR/MR.'
422
423// How long `aidd land` may run from the prompt: it runs the tests again on what lands.
424export const LAND_TIMEOUT_MS = 600_000
425
426// `/aidd <verb> [rest]`: aidd's front door. The command is the aidd skill, as a project skill (`aidd`) or the plugin's
427// (`aidd:aidd`). With no verb it is the menu of the person's decisions (`menu`).
428export type AiddVerb = { verb: string; rest: string }
429
430export function aiddVerb(command: string, args: string): AiddVerb | null {
431 if (command !== 'aidd' && command !== 'aidd:aidd') {
432 return null
433 }
434 const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
435 return { verb: verb === '' ? 'menu' : verb.toLowerCase(), rest: rest.join(' ') }
436}
437
438export type ProposalAsk = { verb: 'decide' | 'sign-off'; id: string; decision: 'go' | 'no-go' | null; reason: string }
439
440// `/aidd decide PROP-1 go [why]` or `/aidd sign-off PROP-1`: the proposal, and for decide the decision and its reason.
441export function proposalAsked(verb: string, rest: string): ProposalAsk | null {
442 const [id = '', second = '', ...more] = rest.trim().split(/\s+/).filter(Boolean)
443 if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(id)) {
444 return null
445 }
446 if (verb === 'sign-off') {
447 return second === '' ? { verb, id: id.toUpperCase(), decision: null, reason: '' } : null
448 }
449 const decision = second.toLowerCase()
450 if (verb !== 'decide' || (decision !== 'go' && decision !== 'no-go')) {
451 return null
452 }
453 return { verb, id: id.toUpperCase(), decision, reason: more.join(' ').replace(/^because\s+/i, '').slice(0, 500) }
454}
455
456export const AIDD_HELP = [
457 'aidd, from the prompt. Each answers at once, and the model never sees it:',
458 ' /aidd every decision that is yours, and the words that make it',
459 ' /aidd status the pane: what waits on you, this branch, and autopilot (/aidd close hides it)',
460 ' /aidd approve [SPEC-1] approve a Spec; /aidd approve merge approves this branch',
461 ' /aidd approve all let the agent record every approval for you (add "and publish", or "but major" to keep those)',
462 ' /aidd stop end that standing approval',
463 ' /aidd land land this branch on the local trunk, once the gate passes',
464 ' /aidd publish what would leave this machine; type `publish` to push it and open its PR/MR',
465 ' /aidd merge approve merging this branch\'s PR/MR, which merges once `aidd/gate` passes',
466 ' /aidd decide PROP-1 go record go or no-go on a proposal (add why after it); /aidd sign-off PROP-1 signs it off',
467 ` /aidd ${EXPLORE_WORD} <topic> move this worktree to a throwaway branch where every edit is free`,
468 ' /aidd update update the aidd CLI, this repository\'s aidd files, and the Claude Code plugins, after a dialog',
469 ' /aidd init [flags] adopt aidd here with `aidd init [flags]`, after a dialog (solo unless you name a profile)',
470 ' /aidd skill load the aidd skill for the agent',
471 'Typed by you, the words /aidd lists are recorded at once: your own words are the decision.',
472].join('\n')
473
474// `/aidd` alone: every decision that is the person's, one line each: the words to type, the verb, and when it is
475// theirs. OpenCode's terminal lists the same (status.js `menuLines`), and tests/test_opencode.py holds both to one list.
476export const AIDD_MENU = [
477 'Your decisions. Type the words at the prompt, or run the /aidd verb beside them:',
478 ' approve SPEC-1 /aidd approve SPEC-1 a Spec waits on you: approve it as it reads, and the agent builds it',
479 ' approve merge /aidd approve merge a reviewed branch: your Merge approval of its head',
480 ' land it /aidd land a reviewed branch: land it on your local trunk once the gate passes',
481 ' publish /aidd publish take this branch off your machine: push it and open its PR/MR',
482 ' merge it /aidd merge a branch with a PR/MR: approve it, and it merges once aidd/gate passes',
483 ' approve all /aidd approve all let the agent approve for you until you stop it (add "and publish" or "but major")',
484 ' stop approving /aidd stop end that standing approval: each decision waits on you again',
485 ' decide PROP-1 go /aidd decide PROP-1 go a proposal waits on your go or no-go (add why after it)',
486 ' sign off PROP-1 /aidd sign-off PROP-1 a proposal is ready to send, as it reads now',
487 ` ${EXPLORE_WORD} branch <topic> /aidd ${EXPLORE_WORD} <topic> try an idea on a throwaway branch: every edit is free, and nothing merges from it`,
488 '"approve" alone approves every Spec that waits on you. /aidd status shows what waits on you now, and /aidd help lists every verb.',
489].join('\n')
490
491// The flags `/aidd init` passes to `aidd init`: solo unless a profile is named, since solo needs no approver list.
492export function initArgs(rest: string): string[] {
493 const words = rest.split(/\s+/).filter(Boolean)
494 return words.some(w => w === '--profile' || w.startsWith('--profile=')) ? words : ['--profile', 'solo', ...words]
495}
496
497// What `aidd self-update --check` said: its line, whether it would change anything, and whether it said this CLI is
498// the latest. `failed` is why the check could not run (offline, or before the first release), so a failed check never
499// reads as up to date. Any other line, such as a checkout's, leaves the CLI unknown, never current.
500export type CliCheck = { line: string; isDue: boolean; isCurrent: boolean; failed?: string }
501
502export function cliUpdate(said: string): CliCheck {
503 const line = clean(firstLine(said))
504 return { line, isDue: /^would update /.test(line), isCurrent: / is up to date: the latest is /.test(line) }
505}
506
507export const cliChecked = (said: Ran): CliCheck =>
508 said.isOk ? cliUpdate(said.value) : { line: '', isDue: false, isCurrent: false, failed: said.error }
509
510// What `aidd upgrade` (a dry run) said: the changes it lists, or none when the repository already matches this aidd.
511// `failed` is why the dry run could not run, so a failed one is said, never dropped.
512export type RepoCheck = { lines: string[]; isDue: boolean; failed?: string }
513
514export function repoUpdate(said: string): RepoCheck {
515 const lines = said.split('\n').map(l => clean(l).trimEnd()).filter(l => l.trim() !== '')
516 if (lines.length === 0 || /^nothing to update/.test(lines[0] ?? '')) {
517 return { lines: [], isDue: false }
518 }
519 return { lines: lines.filter(l => !/^(The CLI itself|The Claude Code plugins):/.test(l)), isDue: true }
520}
521
522export const repoChecked = (said: Ran): RepoCheck => (said.isOk ? repoUpdate(said.value) : { lines: [], isDue: false, failed: said.error })
523
524// Why this repository's aidd files don't update on a CLI that may be behind, in OpenCode's words too (status.js
525// `REPO_WAITS`): `aidd upgrade` writes the files of the CLI that runs it, so an older CLI would write older files.
526export const REPO_WAITS =
527 "This repository's aidd files wait, because aidd can't tell that this CLI is current, and an older one would write older files over newer ones. Update the CLI (docs/INSTALL.md), then run `aidd upgrade --yes`."
528
529// What each check found, in OpenCode's words too (status.js `checkParts`): what would change, why a check could not
530// run, or that aidd is up to date.
531export function checkParts(cli: CliCheck, repo: RepoCheck): string[] {
532 if (!cli.isDue && !repo.isDue && !cli.failed && !repo.failed) {
533 return [`aidd is up to date. ${cli.line}`.trim()]
534 }
535 const parts: string[] = []
536 if (cli.failed) {
537 parts.push(`aidd could not check for a new CLI: ${cli.failed}`)
538 } else if (cli.isDue) {
539 parts.push(`The aidd CLI: ${cli.line.replace(/^would /, '')}`)
540 } else if (cli.line) {
541 parts.push(cli.line)
542 }
543 if (repo.failed) {
544 parts.push(`aidd could not check this repository's files: ${repo.failed}`)
545 } else if (repo.isDue) {
546 const shown = repo.lines.slice(0, 8)
547 const more = repo.lines.length - shown.length
548 parts.push(['This repository\'s aidd files (commit them on a branch afterwards):', ...shown.map(l => ` ${l}`), ...(more > 0 ? [` and ${more} more`] : [])].join('\n'))
549 if (!cli.isDue && !cli.isCurrent) {
550 parts.push(REPO_WAITS)
551 }
552 } else {
553 parts.push('This repository\'s aidd files are up to date.')
554 }
555 return parts
556}
557
558// What `aidd self-update` did: its first line, or why it failed. The installer prints its progress first, so the
559// reason is the last line it printed.
560export function cliUpdated(done: Ran): string {
561 if (done.isOk) {
562 return firstLine(done.value)
563 }
564 return `The CLI was not updated: ${(clean(lastLine(done.stderr ?? '')) || done.error).replace(/\.$/, '')}.`
565}
566
567// The Claude Code plugins aidd's marketplace serves. Each is versioned by its commit, so an update brings the newest.
568export const PLUGINS = ['aidd@aidd', 'aidd-team@aidd', 'aidd-mods@aidd'] as const
569
570export const shortName = (plugin: string): string => plugin.replace(/@aidd$/, '')
571
572// What each plugin update came to, as the person reads it: the ones updated and the next step, the ones not installed
573// here, and why any failed, with the way by hand that `aidd upgrade` and docs/INSTALL.md name.
574export function pluginsSaid(updated: string[], absent: string[], failed: { plugin: string; reason: string }[]): string {
575 const said: string[] = []
576 const plugins = (names: string[]) => `plugin${names.length === 1 ? '' : 's'} ${andList(names)}`
577 if (updated.length > 0) {
578 said.push(`Updated the Claude Code ${plugins(updated)}: run /reload-plugins to load ${updated.length === 1 ? 'it' : 'them'}.`)
579 }
580 if (absent.length > 0) {
581 said.push(`The Claude Code ${plugins(absent)} ${absent.length === 1 ? 'is' : 'are'} not installed here.`)
582 }
583 const reasons = new Map<string, string[]>()
584 for (const { plugin, reason } of failed) {
585 reasons.set(reason, [...(reasons.get(reason) ?? []), shortName(plugin)])
586 }
587 for (const [reason, names] of reasons) {
588 const which = names.length === PLUGINS.length ? 'plugins were' : `${plugins(names)} ${names.length === 1 ? 'was' : 'were'}`
589 said.push(`The Claude Code ${which} not updated: ${reason.replace(/\.$/, '')}.`)
590 }
591 if (failed.length > 0) {
592 const updates = failed.map(f => `\`claude plugin update ${f.plugin}\``)
593 said.push(`In a terminal, run \`claude plugin marketplace update aidd\`, then ${andList(updates)}. Then run /reload-plugins here.`)
594 }
595 return said.length > 0 ? said.join(' ') : 'The Claude Code plugins are up to date.'
596}
597
598// The update dialog: what changes, and where; the person answers once for all of it. The plugins are always in it,
599// because only an update can tell whether a newer commit waits.
600export function updateQuestion(cli: CliCheck, repo: RepoCheck): string {
601 const plugins = `The Claude Code plugins: ${andList(PLUGINS.map(shortName))}, at the newest commit of their marketplace.`
602 return [...checkParts(cli, repo), plugins, 'Update now?'].join('\n\n')
603}
604
605// What a command said: on a failure, its `stdout` and `stderr` too. A `--json` command puts its result line on stdout.
606export type Ran = { isOk: true; value: string } | { isOk: false; error: string; stdout?: string; stderr?: string }
607
608// The last line a command printed, as a JSON object, or null when it isn't one.
609function lastJson(said: string): Json | null {
610 try {
611 const parsed: unknown = JSON.parse(said.trim().split('\n').at(-1) ?? '')
612 return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Json) : null
613 } catch {
614 return null
615 }
616}
617
618const literally = (said: string): string => said.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
619
620// What `claude plugin update <plugin> --json` said: `updated`, `current`, `absent` (not installed here, so nothing to
621// update), or the reason it failed. Its JSON `failureCode` decides first. Without one, only Claude Code's own "not
622// installed" or "not found" message for this plugin is absent. Output with no update outcome, or one other than
623// `updated` and `up_to_date`, is a failure.
624export function pluginUpdate(plugin: string, said: Ran): 'updated' | 'current' | 'absent' | { failed: string } {
625 if (!said.isOk) {
626 const result = lastJson(said.stdout ?? '')
627 const code = text(result?.['failureCode'])
628 if (code !== '') {
629 return code === 'not_installed' || code === 'not_found' ? 'absent' : { failed: text(result?.['message']) || said.error }
630 }
631 const name = literally(plugin.replace(/@.*$/, ''))
632 const absent = new RegExp(`^(?:✘ )?Failed to update plugin "${literally(plugin)}": Plugin "${name}(?:@[^"]*)?" (?:is not installed|not found)\\.?$`)
633 return absent.test(said.error) ? 'absent' : { failed: said.error }
634 }
635 const outcome = text(lastJson(said.value)?.['updateOutcome'])
636 if (outcome === '') {
637 return { failed: 'claude printed no update outcome' }
638 }
639 // Only the outcomes Claude Code is known to print count; any other is no proof that the plugin is current.
640 return outcome === 'updated' ? 'updated' : outcome === 'up_to_date' ? 'current' : { failed: `claude printed an update outcome aidd does not know: ${outcome}` }
641}
642
643// How long an update may take: `aidd self-update` installs from git.
644export const UPDATE_TIMEOUT_MS = 600_000
645
646// What `aidd approve merge --show` prints: the branch, its head, what changed, and what else the gate waits on.
647export type MergePreview = {
648 branch: string
649 head: string
650 spec: string
651 changed: string
652 decision: string
653 pending: string[]
654 // What the gate fails on besides the approval: it never stops the person's Merge approval, only the merge.
655 failing: string[]
656 warnings: string[]
657 // What changed outside the agent's session since the person's last decision, which their answer takes in, and the
658 // flags that name what it showed.
659 outside: Outside
660 seen: string[]
661 problems: string[]
662 // `aidd land --show` only: the branch it lands on, and whether the pull request merges on the platform instead.
663 baseBranch: string
664 mergesOnPlatform: boolean
665}
666
667export function mergePreview(raw: Json): MergePreview {
668 return {
669 baseBranch: text(raw['base_branch']),
670 branch: text(raw['branch']),
671 head: text(raw['head']),
672 spec: text(raw['spec']),
673 changed: text(raw['changed']),
674 decision: text(raw['decision']),
675 pending: strings(raw['pending']),
676 failing: strings(raw['failing']),
677 warnings: strings(raw['warnings']),
678 outside: outside(raw),
679 seen: outsideHash(raw),
680 problems: strings(raw['problems']),
681 mergesOnPlatform: raw['merges_on_platform'] === true,
682 }
683}
684
685// The question the Merge approval dialog asks: the branch, its head, what changed, and what else the gate waits on.
686export function mergeQuestion(p: MergePreview): string {
687 const waits = p.pending.length > 0 ? p.pending.join(', ') : 'nothing else'
688 const warn = p.warnings.length > 0 ? `\n ! ${p.warnings[0]}` : ''
689 const fails = p.failing.length > 0 ? `\n ! it still fails on ${p.failing.join(', ')}: the merge waits for that fix` : ''
690
691 return (
692 `• ${p.branch} at ${p.head.slice(0, 12)}${p.spec ? ` (${p.spec})` : ''}\n ${p.changed || 'no change'} · ` +
693 `besides your approval the gate waits on ${waits}${fails}${warn}${outsideLines(p.outside)}\n\nApprove merging ${p.branch}?`
694 )
695}
696
697// The question the land dialog asks: what lands where, and that the tests run again first.
698export function landQuestion(p: MergePreview): string {
699 const waits = p.pending.length > 0 ? p.pending.join(', ') : 'nothing else'
700
701 return (
702 `• ${p.branch} at ${p.head.slice(0, 12)} on ${p.baseBranch}${p.spec ? ` (${p.spec})` : ''}\n ${p.changed || 'no change'} · ` +
703 `besides your approval the gate waits on ${waits}\n The tests run again first; it lands only if they pass.${outsideLines(p.outside)}\n\n` +
704 `Land ${p.branch} on ${p.baseBranch}?`
705 )
706}
707
708// What `aidd publish --show` says would leave this machine, as the lines `/aidd publish` shows, or null when it said
709// nothing readable.
710export function publishPlan(raw: Json): string | null {
711 const branch = text(raw['branch'])
712 if (branch === '') {
713 return null
714 }
715 const n = typeof raw['commits'] === 'number' ? raw['commits'] : 0
716 const pr = raw['pull_request'] === true ? `, then a pull request into ${text(raw['base'])}` : ''
717 const shown = strings(raw['shown']).map(c => ` ${c}`)
718 return [
719 `Would leave this machine: ${branch} at ${text(raw['head']).slice(0, 12)} to ${text(raw['remote'])}/${text(raw['to'])}${pr} (${n} commit${n === 1 ? '' : 's'} the remote lacks)`,
720 ...shown,
721 ...outside(raw).map(c => `Since your last decision, ${c.what}: it counts as yours when you publish (to put it back: ${c.restore})`),
722 'To publish it, type `publish` at the prompt: aidd records it from your words, and the agent runs `aidd publish`. Or run `aidd publish` in your own terminal.',
723 ].join('\n')
724}
725
726export type Approvable = { id: string; branch: string }
727
728// The Specs waiting on a Spec approval in `aidd list --json`, each with the branch that holds it.
729export function approvable(listed: Json): Approvable[] {
730 const raw = Array.isArray(listed['steps']) ? (listed['steps'] as Json[]) : []
731
732 return raw.filter(step => text(step['code']) === 'spec.spec_approval' && text(step['id'])).map(step => ({ id: text(step['id']), branch: text(step['branch']) }))
733}
734
735// What changed since the person last approved a Spec: the commit that approval named, the files, the first changed lines.
736export type Since = { commit: string; files: { path: string; added: string; removed: string }[]; lines: string[]; more: number }
737
738// What `aidd approve spec --show` prints: what a person approves, and why it cannot be recorded.
739export type Preview = {
740 id: string
741 title: string
742 acceptance: string[]
743 commit: string
744 ref: string
745 since: Since | null
746 // The route, risk dimensions, reversibility, and rollback, in one line, for the go/no-go.
747 risk: string
748 // The latest `aidd revise`: what was wrong, and the hash it reopened; empty when never revised.
749 revised: string
750 warnings: string[]
751 // What changed outside the agent's session since the person's last decision, which their answer takes in, and the
752 // flags that name what it showed.
753 outside: Outside
754 seen: string[]
755 problems: string[]
756}
757
758function since(raw: unknown): Since | null {
759 if (typeof raw !== 'object' || raw === null) {
760 return null
761 }
762 const s = raw as Json
763 const files = Array.isArray(s['files']) ? (s['files'] as Json[]) : []
764
765 return {
766 commit: text(s['commit']),
767 files: files.map(f => ({ path: text(f['path']), added: text(f['added']), removed: text(f['removed']) })),
768 lines: strings(s['lines']),
769 more: typeof s['more'] === 'number' ? s['more'] : 0,
770 }
771}
772
773export function preview(raw: Json): Preview {
774 return {
775 id: text(raw['id']),
776 title: text(raw['title']),
777 acceptance: strings(raw['acceptance']),
778 commit: text(raw['commit']),
779 ref: text(raw['ref']),
780 since: since(raw['since']),
781 risk: text(raw['risk']),
782 revised: text(raw['revised']),
783 warnings: strings(raw['warnings']),
784 outside: outside(raw),
785 seen: outsideHash(raw),
786 problems: strings(raw['problems']),
787 }
788}
789
790// A re-approval's change: each file with its counts, then the changed lines, so the person approves the change itself.
791function changeLines(s: Since): string {
792 const files = s.files.map(f => ` ${f.path} +${f.added} -${f.removed}`)
793 const more = s.more > 0 ? [` … ${s.more} more changed lines`] : []
794
795 return [`\n Changed since your last approval (${s.commit.slice(0, 12)}):`, ...files, ...s.lines.map(line => ` ${line}`), ...more].join('\n')
796}
797
798// The question the approval dialog asks: each Spec on its own line, what it is and what it freezes, and on a
799// re-approval what changed since the last one.
800export function approvalQuestion(list: Preview[]): string {
801 const lines = list.map(p => {
802 const n = p.acceptance.length
803 const warn = p.warnings.length ? `\n ! ${p.warnings[0]}` : ''
804 const changed = p.since ? changeLines(p.since) : ''
805 const risk = p.risk ? `\n Risk: ${p.risk}` : ''
806 const revised = p.revised ? `\n Revised: ${p.revised}` : ''
807 return `• ${p.id} ${p.title}\n ${n} acceptance item${n === 1 ? '' : 's'} · commit ${p.commit.slice(0, 12)}${risk}${revised}${warn}${changed}`
808 })
809 const what = list.length === 1 ? list[0]!.id : `these ${list.length} Specs`
810 const changed = [...new Map(list.flatMap(p => p.outside).map(c => [c.what, c])).values()]
811
812 return `${lines.join('\n')}${outsideLines(changed)}\n\nApprove ${what}?`
813}
814
815export const map = <A, B>(section: Section<A>, fn: (value: A) => B): Section<B> =>
816 section.isOk ? { isOk: true, value: fn(section.value) } : section
817
818const span = (ms: number): string => {
819 const minutes = Math.round(Math.abs(ms) / 60_000)
820 if (minutes < 1) {
821 return 'under 1m'
822 }
823 if (minutes < 60) {
824 return `${minutes}m`
825 }
826 const hours = Math.floor(minutes / 60)
827
828 return hours < 48 ? `${hours}h${String(minutes % 60).padStart(2, '0')}m` : `${Math.floor(hours / 24)}d`
829}
830
831// "1h52m left", "ended 5m ago": relative, so no time zone is needed.
832export function until(iso: string, now: number): string {
833 const at = Date.parse(iso)
834
835 return Number.isNaN(at) ? iso : at >= now ? `${span(at - now)} left` : `ended ${span(now - at)} ago`
836}
837
838export function ago(iso: string, now: number): string {
839 const at = Date.parse(iso)
840
841 return Number.isNaN(at) ? iso : `${span(now - at)} ago`
842}
843
844const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
845
846const rung = (value: unknown): Rung => {
847 const r = (typeof value === 'object' && value !== null ? value : {}) as Json
848 return { done: num(r['done']), total: num(r['total']), waits: strings(r['waits']) }
849}
850
851const age = (value: Json): BranchAge => ({ branch: text(value['branch']), ageDays: num(value['age_days']), behind: num(value['behind']) })
852
853const objects = (value: unknown): Json[] => (Array.isArray(value) ? value.filter((v): v is Json => typeof v === 'object' && v !== null) : [])
854
855// `aidd goal list --json`, as the pane draws it.
856export function shape(listed: Json): Shape {
857 const flow = (typeof listed['flow'] === 'object' && listed['flow'] !== null ? listed['flow'] : {}) as Json
858 const reaches = (value: unknown): SmallGoal['reaches'] => (value === 'flagged' || value === 'batched' ? value : 'incremental')
859 const launchOf = (value: unknown): SmallGoal['launch'] => {
860 if (typeof value !== 'object' || value === null) {
861 return null
862 }
863 const l = value as Json
864 const state = l['state'] === 'done' || l['state'] === 'waits' ? l['state'] : 'todo'
865 return { spec: text(l['spec']), state }
866 }
867 const goalWarnings = objects(listed['goals']).flatMap(g => strings(g['warnings']))
868 return {
869 initiatives: objects(listed['initiatives']).map(i => ({ id: text(i['id']), title: text(i['title']), smallGoals: strings(i['small_goals']) })),
870 smallGoals: objects(listed['small_goals']).map(g => ({
871 id: text(g['id']),
872 title: text(g['title']),
873 parent: text(g['parent']) || null,
874 reaches: reaches(g['reaches']),
875 branch: text(g['branch']) || null,
876 specs: objects(g['specs']).map(s => ({ id: text(s['id']), status: text(s['status']) })),
877 specApproval: rung(g['spec_approval']),
878 release: rung(g['release']),
879 launch: launchOf(g['launch']),
880 })),
881 goals: objects(listed['goals']).map(g => ({ ...age(g), releaseReady: g['release_ready'] === true, waiting: strings(g['waiting']) })),
882 branches: objects(flow['branches']).map(age),
883 warnings: [...goalWarnings, ...strings(flow['warnings'])],
884 }
885}
886
887// One cell of the ladder: a mark, and whose move it is. ◆ waits on you, ✓ done, a count while it is under way, ·
888// still ahead, — not part of this shape.
889export type Cell = { mark: string; tone: 'person' | 'done' | 'ahead' | 'none' }
890
891export function cells(goal: SmallGoal): Cell[] {
892 const of = (r: Rung): Cell =>
893 r.waits.length > 0
894 ? { mark: r.total > 1 ? `◆ ${r.done}/${r.total}` : '◆', tone: 'person' }
895 : r.total > 0 && r.done >= r.total
896 ? { mark: '✓', tone: 'done' }
897 : r.done > 0
898 ? { mark: `${r.done}/${r.total}`, tone: 'ahead' }
899 : { mark: '·', tone: 'ahead' }
900 const launch: Cell =
901 goal.launch === null
902 ? { mark: '—', tone: 'none' }
903 : goal.launch.state === 'done'
904 ? { mark: '✓', tone: 'done' }
905 : goal.launch.state === 'waits'
906 ? { mark: '◆', tone: 'person' }
907 : { mark: '·', tone: 'ahead' }
908 return [{ mark: '✓', tone: 'done' }, of(goal.specApproval), of(goal.release), launch]
909}
910
911// The first command in a step's `how`, between backticks, else ''.
912const firstCommand = (how: string): string => /`([^`]+)`/.exec(how)?.[1] ?? ''
913
914// The decisions that wait on the person, in the order of the loop: D1 the plan, D2 Spec approval, D3 the release, D4
915// the launch, then anything else a person decides. `pilotLast` is autopilot's last step: a plan it made and nobody has
916// agreed to yet is D1.
917export function decisions(waiting: Step[], found: Shape | null, pilotLast: string | null, isPlanOpen: boolean): Decision[] {
918 const launches = new Set((found?.smallGoals ?? []).flatMap(g => (g.launch ? [g.launch.spec] : [])))
919 const out: Decision[] = []
920 if (isPlanOpen && pilotLast !== null && /^goal\.(product|client) ok/.test(pilotLast)) {
921 out.push({ rung: 'D1', label: 'The plan', id: '', title: 'autopilot planned it: agree to go on', type: '/aidd-team:autopilot all' })
922 }
923 for (const step of waiting) {
924 const title = step.title || step.what
925 if (step.code === 'spec.spec_approval') {
926 out.push({ rung: 'D2', label: 'Spec approval', id: step.id, title, type: `approve ${step.id}` })
927 } else if (step.code === 'spec.merge_approval') {
928 const words = step.how.includes('land it') ? 'land it' : step.how.includes('merge it') ? 'merge it' : 'approve merge'
929 out.push(launches.has(step.id) ? { rung: 'D4', label: 'Launch', id: step.id, title, type: words } : { rung: 'D3', label: 'Release', id: step.id, title, type: words })
930 } else if (step.code === 'spec.publish') {
931 out.push({ rung: 'D3', label: 'Publish', id: step.id, title, type: 'publish' })
932 } else if (step.code === 'goal.release') {
933 out.push({ rung: 'D3', label: 'Release', id: step.id, title: step.what, type: `land it on ${step.id}` })
934 } else {
935 out.push({ rung: '', label: step.code.split('.').at(-1)?.replace(/_/g, ' ') ?? '', id: step.id, title, type: firstCommand(step.how) })
936 }
937 }
938 const order = ['D1', 'D2', 'D3', 'D4', '']
939 return out.sort((a, b) => order.indexOf(a.rung) - order.indexOf(b.rung))
940}
941
942// The trunk-based flow in one line: how many branches hold unmerged work, the oldest, and the goals.
943export function flowLine(found: Shape): string {
944 const n = found.branches.length
945 const oldest = found.branches[0]
946 const parts = [n === 0 ? 'every branch is merged' : `${n} branch${n === 1 ? '' : 'es'} with unmerged work`]
947 if (oldest && oldest.ageDays > 0) {
948 parts.push(`oldest ${oldest.branch}, ${oldest.ageDays}d`)
949 }
950 for (const g of found.goals) {
951 parts.push(`${g.branch} ${g.releaseReady ? 'ready to release' : `${g.ageDays}d`}${g.behind ? `, ${g.behind} behind` : ''}`)
952 }
953 return parts.join(' · ')
954}
955types/index.d.ts 113 lines1// One item of `aidd list --json`: what is open, who holds it, and the next step.
2export type Step = {
3 id: string
4 code: string
5 title: string
6 what: string
7 who: string
8 how: string
9 priority: string
10 status: string
11 owner: string
12 labels: string[]
13 waitingOn: string[]
14 why: string
15 // "1 task, blocked": the Tasks in one line; `taskList` holds each one.
16 tasks: string
17 taskList: Task[]
18 where: string
19 isBlocked: boolean
20 // The Initiative and the Epic it serves, from aidd's `why` chain, outermost first.
21 parents: Link[]
22 // What the Spec file says, for the detail card: read after `aidd list`, empty when there is no file to read.
23 about: About
24 // What the item's folder (`specs/<ID>/`) holds, as `aidd list --json` gives it: its docs, design, and explorations.
25 files: ItemFile[]
26}
27
28// One entry of a work item's folder. `kind` is a linked document's kind (`tech-spec`, or `doc`), `design`,
29// `exploration` (with its question and status, `open` or `decided`), `folder`, or `more` (with `count`).
30export type ItemFile = { path: string; kind: string; question: string; status: string; count: number }
31
32// One Task of a Spec, as `aidd list --json` gives it.
33export type Task = {
34 id: string
35 title: string
36 status: string
37 why: string
38 assignee: string
39 role: string
40 scope: string[]
41 dependsOn: string[]
42}
43
44// What an item is about, from its file. A Spec: the first paragraph of Problem, and its Goals. A Proposal: who it is
45// for (audience · addressee), the first paragraph of its need, and its goal.
46export type About = { kind: string; for: string; problem: string; goals: string[] }
47
48// One link of the chain: an Initiative or an Epic, with its progress as aidd counts it ("0/3").
49export type Link = { kind: string; id: string; progress: string }
50
51// An Initiative or an Epic as the pane lists it: its link, the title its file gives, and the Proposal it was made from
52// (`proposal:` in its front matter, from `aidd proposal convert`), or ''.
53export type Parent = Link & { title: string; from: string }
54
55// One autopilot lease from `aidd autopilot status --json`.
56export type Lease = { work: string; by: string; session: string; expiresAt: string; timebox: string; isStale: boolean }
57
58export type Autopilot = { leases: Lease[]; last: string | null; heard: string | null }
59
60// A section is what aidd answered, or the line it failed with.
61export type Section<T> = { isOk: true; value: T } | { isOk: false; error: string }
62
63// One rung of a small goal's decision ladder: how many are past it, of how many, and what waits on a person now.
64export type Rung = { done: number; total: number; waits: string[] }
65
66// A small goal (an Epic on the default branch), as `aidd goal list --json` gives it: how it reaches users, and where each of
67// the person's decisions stands (D1 the plan, D2 Spec approval, D3 the release, D4 the launch of a flagged one).
68export type SmallGoal = {
69 id: string
70 title: string
71 parent: string | null
72 reaches: 'incremental' | 'flagged' | 'batched'
73 branch: string | null
74 specs: { id: string; status: string }[]
75 specApproval: Rung
76 release: Rung
77 launch: { spec: string; state: 'todo' | 'waits' | 'done' } | null
78}
79
80// A branch with work the default branch lacks, or a goal branch: how old, and how far behind.
81export type BranchAge = { branch: string; ageDays: number; behind: number }
82
83// The shape of the work: big goals, small goals, the goal branches, and the trunk-based flow.
84export type Shape = {
85 initiatives: { id: string; title: string; smallGoals: string[] }[]
86 smallGoals: SmallGoal[]
87 goals: (BranchAge & { releaseReady: boolean; waiting: string[] })[]
88 branches: BranchAge[]
89 warnings: string[]
90}
91
92// One decision that waits on the person: which of the four (D1 to D4; '' for another kind), its words, and what to type.
93export type Decision = { rung: string; label: string; id: string; title: string; type: string }
94
95export type Snapshot = {
96 root: string
97 head: string
98 at: number
99 branch: Section<Step[]>
100 waiting: Section<Step[]>
101 autopilot: Section<Autopilot>
102 // The shape of the work from `aidd goal list --json`.
103 shape: Section<Shape>
104 // The person's standing approval in force, as one line; null when none is.
105 standing: string | null
106}
107
108declare module 'claude-code' {
109 interface PluginState {
110 'aidd-mods': { snapshot: Snapshot | null; isLoading: boolean; selected: string | null; focus: string | null; task: string | null }
111 }
112}
113