SLOPSHOPPER

aidd-mods

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…

panetoastpromptprocesstimer
★ 1v?Apache-2.0updated 2026-10-26thaithienvanid/aidd/plugins/aidd-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aidd-mods
│ ┃ aidd-status ✕ › fix the failing auth test and add an audit log call │ ┃ aidd [ Refresh ] [ Close ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Press r to load. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ↑↓ move · Enter open · a all · r refresh · … ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aidd-status
aidd [ Refresh ] [ Close ] Press r to load. ↑↓ move · Enter open · a all · r refresh · x close · ctrl+x…
README

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

How it works

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 decisionWhenWhat you look at
D1The planbefore any codewhat gets built, in which small goals, and in what order
D2Spec approvalbefore each change is builtthe contract: its intent, its scope, and the tests that define done
D3The releasewhen a change is readywhat was built, with its proof: you land it on your machine, then publish it
D4The launchfor a feature built behind a flagwhen 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.

LaneForYou decide
Fastsmall fixes, and pre-approved routine changes such as a bot's dependency updatethe result (nothing, for a pre-approved change)
Specanything worth planningthe Spec and the result, plus a specialist on a major change
Emergencyproduction is down, and you declare itthat 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:

  • Humans decide. An approval names the exact commit you read, so a later change to the Spec or its tests voids it. An agent can never approve its own work.
  • Evidence, not prose. A change lands on its tests' results for the exact commit that lands. A summary, or "tests pass" in a chat message, never counts.
  • Two checks that don't trust the agent. The guard and the gate check each step and each change.
  • State lives in git, in files you can read, with no server and no database.
  • Quiet and small. aidd calls no model, sends no telemetry, and has no runtime dependencies.

Get started

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.

Everyday commands

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 sessionAt your terminalWhat it does
/aidd—every decision that is yours, and the words that make it
/aidd statusaidd list --waiting-on-mewhat waits on you now, and what to type
your agent runs itaidd listall open work, and who holds each step
/aidd approve SPEC-3aidd approve spec SPEC-3Spec approval
your agent runs itaidd gateevery gate rule on this branch, before Merge approval
/aidd landaidd landMerge approval, then it lands on your local trunk
/aidd publishaidd publishthe verb shows what would leave your machine; typing publish, or aidd publish, pushes it and opens its PR/MR
/aidd updateaidd self-update, then aidd upgrade --yesaidd 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 delivery team

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:

StageForRun
Startan existing repositoryonboard
a client proposalpropose
a brief to a launched MVPmvp
Plantrim the backlogrefine
a goal into Epics and Specsplan
compare designsexplore
Buildwrite the Specspecify
a threat model or security reviewsecure
build the Specimplement
verify and reviewreview
answer findings and red checksrework
verify a new headverify
Fixa small bug, on the fast lanefix
Shipwatch the rolloutrollout
hand client work overhandover
Learnafter an incidentpostmortem
improve the loopretro
Any timedue upkeepmaintain
run between your decisionsautopilot

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.

How it stays honest

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

From one person to many teams

Pick a profile, and tighten it later. A repository can never loosen what its organization set.

ProfileForWhat it adds
soloone person directing agentsyou approve your own work, on record; Spec approval only on major changes
teamone team or a fewSpec approval before building, an Approver who is not the author, a specialist on high-risk changes
orgmany teams and repositoriesorganization rules on high-risk and emergency changes, enforced from the start
auditeda SOC 2 or ISO 27001 audit, finance, health, public sectortwo 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.

Works with what you have

aidd needs git and the repository. Everything else is optional, and aidd uses it only if you already have it.

  • Your agents. An agent without the guard, such as Cursor or Copilot, reads 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.
  • Your platform. GitHub or GitLab adds approvals the platform enforces, and aidd reuses CODEOWNERS, your CI checks, rulesets, and merge queues. GitLab support is experimental.
  • Your planning tools. Keep GitHub Spec Kit, OpenSpec, Kiro, or BMAD for planning, and aidd import turns their plan into a Spec.
  • Know-how. The roles read <!-- aidd:gen count:packs -->25<!-- aidd:end --> domain packs, from payments and health to embedded systems, and <!-- aidd:gen count:practices -->34<!-- aidd:end --> practice skills. A library of standards and stacks gives each open choice, such as the linter, one default for each ecosystem.

What it does not do

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.

Documentation

StartUnderstandOperate
QuickstartThe methodCLI
How do I…The loop, step by stepConfig
InstallThe delivery teamPlatforms
AdoptionWhat the team coversAutopilot
FAQGlossaryTroubleshooting
ChangelogStandards and stacksError codes
ContributingRationaleSecurity
Extending: plugins, skills, packsControlsEnterprise readiness
ArchitectureGovernance
Running inside Orca

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.

Source 3 files
hooks/register.tsx 1256 lines
1import { 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 lines
1import 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}
955
types/index.d.ts 113 lines
1// 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