SLOPSHOPPER

code-kit

Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check…

newpanebandrowsguardcommand
v0.5.0MITupdated 2026-10-09warrendeanlangeveldt/code-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-kit
│ ┃ code-kit-lanes ✕ › fix the failing auth test and add an audit log call │ ┃ This project doesn't use code-kit. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lanes │ ⎿ code-kit: This project doesn't use code-kit. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · code-kit-lanes
This project doesn't use code-kit.
Pane · code-kit-approve
Nothing to show.
Pane · code-kit-result
Nothing to show.
README

<img src="https://raw.githubusercontent.com/warrendeanlangeveldt/code-kit/main/assets/logo.png" alt="code-kit" width="300">

code-kit

A Claude Code plugin that keeps AI coding agents inside a project's engineering rules. You write the rules once, per project, in .claude/code-kit.json. The plugin's hooks enforce them on every tool call, for the main session and every subagent. A rule Claude would otherwise only be asked to follow becomes one the hooks refuse to let it break. The same rules run in CI for every pull request.

It also carries the work from idea to merged code: spec an idea into requirements, hand stories to lanes, review each branch against its requirements, and track the build against the spec.

Pairs with Context Graph. code-kit decides who may change what, and what has to be proved. Context Graph gives each agent the why behind a file before it edits it: the file's card, its rules and the decisions made on it, kept in git. Each detects the other: Context Graph's cards and slices then carry the file's spec requirement and layer rule. Install both for agents that stay in their lane and know why the code is the way it is.

<img src="https://raw.githubusercontent.com/warrendeanlangeveldt/code-kit/main/assets/workflow.svg" alt="How code-kit works: the specs create the agents, skills and rules; the lead agent delegates stories to lane agents and takes decisions and approvals to you; lanes send scope and dependency requests back to the lead; hooks enforce ownership, layers, approvals and spec-check while each lane implements, tests and commits; the lead reviews against the requirements and CI runs verify." width="820">

Quick start

You need Claude Code, Node 22 or later, and git. Then, in Claude Code:

/plugin marketplace add warrendeanlangeveldt/code-kit
/plugin install code-kit@code-kit
/code-kit:next

/code-kit:next works out where your project stands and takes you through the next step. A blank folder starts with speccing an idea; an existing codebase starts with drafting its rules. Nothing is enforced until you approve the rules it drafts. In a project without .claude/code-kit.json, the plugin does nothing.

Why

Rules in CLAUDE.md and agent prompts are advice. Over a long session, or across a dozen parallel subagents, advice drifts:

  • a backend agent "quickly fixes" a frontend file, and two agents overwrite each other's work;
  • a domain module starts importing the database client, and the layering the architecture doc describes stops being true;
  • a contract or schema changes with no record of who changed it or why;
  • code gets written before anyone checked it against the spec, or a screen before it was designed;
  • an agent declares "done" with failing type checks, or with work nobody committed;
  • someone force-pushes, commits to main, adds a dependency, or runs a deploy nobody asked for.

code-kit stops each of these at the moment it would happen. The hook refuses the tool call and tells the agent why and what to do instead. The rules live in one reviewed file, not in every prompt, so they hold for every agent and every session.

What it enforces

  • Lanes: each lane agent writes only its own paths. The main session (the lead) writes contracts, specs and config, and delegates the rest. Any other subagent is read-only.
  • Layers: each layer depends only on the layers it lists, and never on packages it's denied. This is checked before every write (a write that would add a violation is refused, so a layer is never broken on disk), after it, and again before finishing, for JavaScript and TypeScript imports. In brownfield code, existing violations can be recorded in a baseline so only new ones block.
  • Protected paths and the approval log: every committed change to the kit, registers or contracts is appended to .claude/approval-log.jsonl, in the same commit. Only the lead writes these paths without a person's approval, and the person reviews them in the pull request.
  • Spec-check before code: when the project has specs, a lane can't write code on a branch until the spec-check report for that branch exists.
  • Designs before screens (optional): a screen file can't be written until the design register lists it.
  • Shell rules:
  • no force-push, pushing to or committing on protected branches, --no-verify, destructive deletes, or piping downloads into a shell;
  • no new dependency without a person's approval, for that package and that lane; the approval also covers the manifest and lockfile changes the install makes;
  • no forged approvals;
  • the project's own blocked commands (deploys, releases) and lane-restricted commands (shared local services).
  • Proof before finishing: before any agent finishes, the plugin runs:
  • an ownership audit of every uncommitted change;
  • the layer rules;
  • the project's checks, for the files that changed.

A lane also has to commit its work first.

  • The same rules in CI: code-kit verify checks a pull request as a whole, including commits made outside Claude Code:
  • a <lane>/… branch stays in that lane's paths, apart from manifest and lockfile changes logged under a dependency approval;
  • every protected change is in the approval log, and the log is append-only;
  • no new layer violations, and the baseline doesn't grow;
  • no screens without an approved design, and no secrets anywhere in the branch's history, including ones committed and deleted again;
  • the project's checks pass.

A project without .claude/code-kit.json isn't governed at all. An invalid config fails closed: nothing but the config itself can be written until it's fixed.

The hooks are guard rails, not a sandbox (SECURITY.md): they refuse the actions they recognise. Two limits are deliberate. The finish check stops blocking once it has reported the same problem three times without progress, and says so, for the lead to take up. A check whose tool isn't installed is skipped unless it sets ifMissing: "fail".

When to use it

Use it when:

  • Several agents work on one codebase at once, such as a lead delegating to frontend, backend and data lanes, possibly in separate worktrees. Lanes are what keep them out of each other's files.
  • The architecture has layers worth protecting, like domain, services, adapters and UI, or a shared package that mustn't reach into apps.
  • Some files need an audit trail: API contracts, schemas, registers, the kit's own config.
  • The project has specs or designs that code should follow rather than improvise.
  • "Done" has to mean done: checks passing and work committed, not an agent's say-so.
  • You're starting from an idea, and want the specification worked out before anyone writes code.

It's overkill for a throwaway script, a spike, or a single-file change. In those, just don't add .claude/code-kit.json: the plugin stays installed and does nothing.

Set it up at the start of a new build, straight from the architecture docs, or bring it into an existing codebase at any point (init works the rules out from the code). Re-run init whenever the shape of the project changes: new apps or packages, moved folders, a changed plan.

Where it runs

  • In Claude Code, as a plugin. It's installed once per machine and governs only projects that have .claude/code-kit.json.
  • On every tool call in those projects, through Claude Code hooks:
HookRunsEnforces
PreToolUse Edit/Writehooks/guard-paths.mjslanes, protected paths, spec-check and design gates
PreToolUse Bashhooks/guard-bash.mjsshell rules, dependencies, approval log and secret scan on commit
PostToolUse Edit/Writehooks/post-edit-check.mjslayer rules and the project's postEdit commands
PostToolUse Bashhooks/lane-audit.mjsownership of files written by shell commands
Stop, SubagentStophooks/stop-check.mjsproof before finishing
  • For the main session and every subagent, including agents in worktrees under .claude/worktrees/.
  • In front of you, in the terminal and the Desktop app, through the code-kit mod (Claude Code 2.1.287 or later; see In the session). It shows and acts, and never enforces: the hooks above do that everywhere, the mod or not.
  • In CI, through code-kit verify in a GitHub Actions workflow that init can install (templates/github-workflow.yml).
  • Locally, with Node 22 or later. Nothing is sent anywhere. The CLI in bin/ runs the same rules from a terminal.

Install

Requirements: Claude Code, Node 22 or later, and git. gitleaks is optional: when it's installed, every commit gets a secret scan, and verify scans every commit on the branch.

The plugin, from this repository's marketplace:

/plugin marketplace add warrendeanlangeveldt/code-kit
/plugin install code-kit@code-kit

To update later, run /plugin marketplace update code-kit and restart Claude Code; sessions started before an update keep the old hooks. To pin it for a whole team, add it to the project's .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "code-kit": { "source": { "source": "github", "repo": "warrendeanlangeveldt/code-kit" } }
  },
  "enabledPlugins": { "code-kit@code-kit": true }
}

The command line is optional: the skills run it for you in a session. Use it for CI, or from a terminal:

npx @warren-dean/code-kit check          # no install
npm i -g @warren-dean/code-kit           # or install it, then: code-kit check

From a clone, for working on code-kit itself: /plugin marketplace add <path to the clone>.

Its companion, Context Graph, installs the same way. Nothing needs configuring between the two:

/plugin marketplace add warrendeanlangeveldt/context-graph
/plugin install context-graph@context-graph

One command: /code-kit:next

Don't want to remember which skill comes when? Run /code-kit:next, from a blank folder or at any point in a build:

/code-kit:next a booking app for mobile dog groomers

It reads where the project stands and runs the right skill. It carries on to the following step until something needs you: questions to answer, a draft to approve, a pull request to merge, or lanes still building.

The project hasnext runs
Nothingspec-design
A spec still being shapedspec-design, picking up where it stopped
A ready spec, existing code, or a draft configinit
Stories finished on their branchesreview, then dispatch
Stories readydispatch
Stories being builtnothing: it tells you what's running
Everything donea status summary, then offers the next milestone

code-kit next on the command line shows the same decision without running anything.

Start from an idea

/code-kit:spec-design a booking app for mobile dog groomers

spec-design works an idea into a specification with you before any code exists. It asks the questions that decide the build, a few at a time with a recommended answer, and writes every decision down:

  1. Frame: the problem, the users, the outcome, the constraints and the non-goals.
  2. Explore: two or three different shapes of solution, the main user journeys, and the riskiest assumptions. You choose.
  3. Scope: capability areas, and what the first release has to deliver end to end.
  4. Specify: one spec per area, with numbered, testable requirements covering data, states, rules, permissions, errors and acceptance criteria.
  5. Architecture, principles and plan: the layers, contracts, definition of done, and stories split into lanes.
  6. Check: every journey maps to requirements and every requirement to a story, with no gaps left as "TBD".

It writes docs/brief.md (with the decision log and open questions), docs/specs/NN-<area>.md, docs/architecture.md, docs/principles.md and docs/plan.md. Those are exactly what /code-kit:init docs/ turns into lanes and layers, and what lanes spec-check against. Run it again to resume where it left off, to change a decision, or to spec a new feature in an existing codebase.

Requirements are headings (### BOOK-4 Cancel a booking). Stories are ### ST-12 headings in the plan with Lane:, Requirements:, Depends on: and Status: lines (templates/plan.md). The lane lead marks the lead's own stories, such as contracts and spikes, which the lead builds itself instead of dispatching; Requirements: none marks a story that delivers no requirement. Those two formats are what the build loop below runs on.

Set up a project

/code-kit:init docs/
  • A new build: init reads the docs in full: the architecture and layer map, the plan or work split, and the principles. It drafts the rules from them.
  • An existing codebase (brownfield): the docs can be thin or missing. Init works the layers out from the code: which folders import which, and who works where according to the git history. It also runs each check once and flags any that already fail, so you decide what to do with them.
  • Either way: you get a draft (.claude/code-kit.draft.json), one agent per lane, any project skills you approve, a CLAUDE.md section and a list of questions (see The agents and skills it creates). Nothing is enforced until you approve and the draft becomes .claude/code-kit.json.

Existing violations. Brownfield code usually breaks some layer rules already. On approval, baseline --write records them in .claude/code-kit.baseline.json:

  • the violations it records don't block;
  • new ones, even in the same file, do;
  • the baseline only ever shrinks: re-recording drops what's been fixed and refuses anything new.

No specs yet? Leave docs.specs out, and lanes aren't held to a spec-check report. Add it once specs exist.

The agents and skills it creates

code-kit ships no fixed team. Init writes one for each project, from its docs or its code, and re-running init keeps it in step as the project changes.

.claude/
├── code-kit.json          # the rules: lanes, layers, protected paths, checks
├── agents/
│   ├── web-engineer.md    # one lane agent per lane
│   ├── api-engineer.md
│   └── db-engineer.md
└── skills/
    └── new-migration/     # project recipes, only the ones you approve
        └── SKILL.md
CLAUDE.md                  # plus a section telling the lead how the rules work

Who's who. There are four kinds of actor. The hooks tell the three Claude kinds apart on every tool call; the person works outside them:

ActorWho it isMay write
The personYouAnything; you also approve and merge
The leadThe main Claude Code sessionContracts, specs, config, the kit: lead.paths
A lane agentA subagent whose name is a lane's agent in the config, or a session in a .lane worktreeOnly its lane's paths, after its spec-check
Any other subagentExplore, Plan, reviewers, anything not mapped to a laneNothing: read-only

Lane agents (.claude/agents/<agent>.md, from templates/agent.md). Each is filled in from the project's docs, or from the code for brownfield projects:

  • the paths the lane owns, its layers and what they may depend on;
  • the specs it reads;
  • the skills it uses: spec-check, plus its project recipes;
  • the command that proves its work, and the lane's own rules.

It also carries the working rules: start from the story's branch and spec-check, build end to end with no mocks or placeholders, name requirement IDs in test titles, and commit before finishing.

The config's lanes.<name>.agent must match the agent file's name. That's how the hooks know a subagent is the web lane and not a read-only helper.

Instructions and enforcement are separate. The agent file tells the agent the rules, so it rarely hits them. The hooks enforce them whether or not the agent follows its file:

  • writes outside its lane are blocked;
  • code before a spec-check report is blocked;
  • finishing with failing checks or uncommitted work is blocked.

Editing an agent file can't loosen a rule. And the agent files are kit files (.claude/**), so only the lead changes them, and every change is logged.

Spawned per story. Dispatch starts a fresh instance of the lane's agent for each story, in its own worktree on <lane>/st-<n>. Agents for different lanes run in parallel. Each starts knowing only its agent file and the story's brief, so nothing leaks between stories.

How the spec is enforced, step by step:

  1. The brief names the story's requirement IDs.
  2. The agent runs the spec-check skill and saves a requirement table for its branch.
  3. The path guard blocks any code in its lane until that report exists.
  4. The agent's tests name the requirement IDs.
  5. The stop hook runs the checks before the agent can finish.
  6. Review compares the branch with the requirements and the report, and verify checks the rules again in CI.

Project skills (.claude/skills/<recipe>/SKILL.md) are the step-by-step recipes your docs describe, like "add a migration" or "add an endpoint". Init lists the ones it finds and writes them only if you say yes, never as empty stubs. Each is listed in the owning lane agent's "Skills to use". Like the agents, they're kit files: the lead maintains them, and changes are logged.

Kept in step. When the project changes (a new app, a new lane, moved folders), re-run /code-kit:init. It drafts new agents for new lanes and updates the paths of existing ones, keeping anything written by hand. You see the difference and approve it before anything changes.

In the session

With Claude Code 2.1.287 or later, code-kit's mod shows the project in the session, in the terminal (VS Code's terminal included) and the Desktop app. Older versions skip it and keep enforcing everything through the hooks.

  • The band sits above the prompt whenever something waits for you, and is gone when nothing does. It shows:
  • approvals agents have asked for: Approve… opens a confirmation with a reason prefilled from the request, which you keep or rewrite;
  • stories finished and waiting for review: Review asks the lead to run /code-kit:review on the branch, and Merge runs verify, checks included, then merges if it passes;
  • a finish check failing in this session: Lanes opens the pane.

Each button has a digit: type it in an empty prompt to press it.

  • /lanes opens the Lanes pane: each lane with its agent, story, branch and state (building, in review, blocked, idle), a mark while its agent is at work, and the stories ready to start. It stays current within 2 seconds of a commit, a branch change, a plan edit or an agent starting or stopping. /lanes again, or Escape, closes it.
  • Refusals a code-kit hook gives an agent are drawn as cards: what was refused, the rule, what to do, and Approve… where your approval would allow it. The full text is a press away.
  • /approvals lists the requests waiting and the approvals in force, with minutes left. /verify-branch runs code-kit verify on this branch. Neither calls the model.

Approving from the band records (approved in the code-kit pane) with your reason, and a merge from it says it was merged by the person from the pane. Both happen only on your press: the hooks refuse approve --via pane and merge --person from every agent. Outside a project with .claude/code-kit.json, the mod draws nothing. Mods don't draw in the VS Code extension's chat panel, the Agent SDK or claude -p.

Build

Once init has switched the rules on, the lead runs the build in a loop:

/code-kit:dispatch next      # start every ready story on its lane
/code-kit:review ST-12       # check a finished branch, then merge or send it back
/code-kit:status             # where the build stands against the spec
  • Dispatch picks the stories whose dependencies are done. It checks that no open question blocks them and that their contracts exist, then starts each lane agent in its own worktree on the branch <lane>/st-<n>, in parallel. Each brief names the story's requirements and acceptance criteria. Lanes spec-check first, build end to end, name requirement IDs in their test titles, and commit.
  • Review runs verify on the branch, then checks each requirement: built end to end, proved by a test, matching the spec-check report, and nothing beyond the story. It ends with merge, send back with the list of problems, or a question for you.
  • Status traces every requirement to its story, branch and tests. It shows what's done, in review, ready and blocked, which requirements no test names, and where the plan's status lines are out of date.

Enforce it in CI

The hooks only see Claude Code sessions. For everything else, init offers .github/workflows/code-kit.yml (from templates/github-workflow.yml). It runs verify on every pull request, through npx @warren-dean/code-kit@<version>, pinned so CI doesn't change under you. It needs no secret. The one setting to make is marking the code-kit check as required on the protected branches.

verify can't see spec-check reports, because .claude/state/ isn't committed. Review covers those.

Keep it up to date

Re-run /code-kit:init whenever the build moves on: new apps or packages, moved folders, a changed plan, or files unowned keeps listing. It drafts the whole config again, then shows you the difference before anything changes:

+ lane jobs: jobs-engineer, workers/jobs/**
~ lane backend: exclude + workers/jobs/**
~ layer services: mayImport - schemas
Owner backend lane / backend-engineer → jobs lane / jobs-engineer: 14 file(s), e.g. workers/jobs/run.ts

Lanes never change silently: only when you approve the diff.

The CLI

Run from the project root, as code-kit <command> once installed (npm i -g @warren-dean/code-kit), or npx @warren-dean/code-kit <command>. Add --config .claude/code-kit.draft.json to any command to read a draft instead. In a session, the `/code-kit:check

Source 2 files
hooks/mod/register.mjs 360 lines
1// The code-kit mod: code-kit in the session, for the person. It draws and acts, but never enforces: the
2// settings hooks beside it enforce the rules, in every session, CI and harness. Claude Code before
3// 2.1.287 doesn't load this module and runs those hooks as before.
4//
5// Everything it shows comes from the code-kit CLI's JSON, run in the session's folder; the mod never
6// imports hooks/lib, which uses node modules a hooks module may not. What it draws is built by the pure
7// functions in view.mjs. It acts only on the person's presses: approving (`approve --via pane`), asking
8// the lead to review, and merging (`merge --person`). No agent reaches those: they are the mod's own
9// buttons, and the hooks refuse both commands from every agent.
10import {
11  APPROVE_ID,
12  NOT_CODE_KIT,
13  PANE_ID,
14  RESULT_ID,
15  approvalsText,
16  agentsAtWork,
17  approvePane,
18  band,
19  bandLines,
20  lanesPane,
21  parseJson,
22  prefilledReason,
23  projectState,
24  refusalCard,
25  refusalView,
26  resultPane,
27} from './view.mjs';
28
29// What the session knows of the project, read again when it changes.
30let model = { state: null, requests: null, stops: [] };
31let reviewing = new Set(); // stories the person asked the lead to review (ACT-3)
32let reviewTurn = null; // 'next' until the lead's review turn starts, then its id
33let approving = null; // the open Approve… confirmation: { request, reason, error }
34let result = null; // what the last merge reported
35let notice = null; // why the person's last act failed
36let act = null; // the session's actions, made at session start
37const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
38const expanded = new Set(); // cards showing their raw text
39const REFUSALS_KEPT = 200;
40
41export function register(on) {
42  on('session.start', async ($, e, next) => {
43    // A name another command already holds is refused; the rest of the mod goes on without it.
44    const unavailable = (name) => (err) =>
45      $.ui.log(`code-kit: /${name} isn't available in this session: ${err?.message ?? err}`);
46    await $.command
47      .register({
48        name: 'lanes',
49        description: "code-kit's lanes: agents, stories, branches and what's ready",
50        immediate: true,
51      })
52      .catch(unavailable('lanes'));
53    await $.command
54      .register({
55        name: 'approvals',
56        description: "code-kit's approval requests waiting, and the approvals in force",
57        immediate: true,
58      })
59      .catch(unavailable('approvals'));
60    await $.command
61      .register({
62        name: 'verify-branch',
63        description: 'Run code-kit verify on this branch, checks included',
64      })
65      .catch(unavailable('verify-branch'));
66    const cli = `${$.plugin.root}/bin/code-kit.mjs`;
67    const cwd = e.cwd ?? (await $.session.cwd());
68    const session = await $.session.id();
69    const json = async (...args) => {
70      const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
71      return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
72    };
73    const stamp = async (path) => {
74      const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
75      return s ? `${path}@${s.mtimeMs}` : `${path}-`;
76    };
77    const listing = async (path) => {
78      const entries = await $.fs.list(`${cwd}/${path}`).catch(() => []);
79      const stamps = [];
80      for (const entry of entries)
81        stamps.push(
82          entry.kind === 'dir'
83            ? await listing(`${path}/${entry.name}`)
84            : await stamp(`${path}/${entry.name}`),
85        );
86      return stamps.join(',');
87    };
88
89    act = {
90      // Everything the band and the pane show, read again.
91      reload: async () => {
92        const ran = await $.process.run(['node', cli, 'check', '--json'], { cwd });
93        const check = parseJson(ran.stdout);
94        const valid = Boolean(check?.valid);
95        const status = valid ? await json('status') : null;
96        const nextStep = valid ? await json('next') : null;
97        const requests = valid ? await json('requests') : null;
98        const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
99        const atWork = agentsAtWork(await $.agent.list());
100        model = {
101          state: projectState(check, status, nextStep, atWork),
102          requests,
103          stops,
104          check,
105          base: status?.base ?? null,
106        };
107        $.ui.invalidate('ui.render');
108      },
109      // What a refresh waits on, read cheaply: branches and HEAD, the config and the plan, requests,
110      // approvals and finish checks, and the agents at work. Only a change runs the CLI.
111      fingerprint: async () => {
112        const refs = await $.process.run(
113          ['git', 'for-each-ref', '--format=%(HEAD)%(objectname) %(refname)', 'refs/heads'],
114          { cwd },
115        );
116        const plan = model.check?.docs?.plan;
117        return [
118          refs.stdout,
119          await stamp('.claude/code-kit.json'),
120          plan ? await stamp(plan) : '',
121          await stamp('.claude/state/requests.jsonl'),
122          await listing('.claude/approvals'),
123          await listing('.claude/state/stop-blocks'),
124          [...agentsAtWork(await $.agent.list())].sort().join(','),
125        ].join('\n');
126      },
127      // PANE-1: open the Lanes pane, or close it when it's open.
128      lanes: async () => {
129        // Asked, not remembered: the person may have closed it with Escape.
130        if ((await $.ui.panes()).some((p) => p.id === PANE_ID)) {
131          await $.ui.close({ id: PANE_ID });
132          return {};
133        }
134        await act.reload();
135        if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
136        const placed = await $.ui.open({
137          id: PANE_ID,
138          title: 'Lanes',
139          focus: true,
140          closeOnEscape: true,
141        });
142        // Opened but not drawn yet: it waits for room, and the reason says what seats it.
143        if (!placed.isPlaced) return { text: `The Lanes pane is waiting: ${placed.reason}` };
144        return {};
145      },
146      // ACT-1: the confirmation, prefilled from the request.
147      approve: async (line) => {
148        approving = { request: line.request, reason: prefilledReason(line.request), error: null };
149        await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
150        $.ui.invalidate('ui.render');
151      },
152      confirmApproval: async (reason) => {
153        if (!approving) return;
154        if (!reason.trim()) {
155          approving = { ...approving, error: 'Give a reason: it goes in the approval log.' };
156          $.ui.invalidate('ui.render');
157          return;
158        }
159        const { request } = approving;
160        const ran = await $.process.run(
161          [
162            'node',
163            cli,
164            'approve',
165            ...request.names,
166            ...(request.lane ? ['--lane', request.lane] : []),
167            '--reason',
168            reason.trim(),
169            '--via',
170            'pane',
171          ],
172          { cwd },
173        );
174        if (ran.exitCode !== 0) {
175          const why = (ran.stderr || ran.stdout).trim().split('\n')[0];
176          notice = `Nothing was approved: ${why}`;
177          approving = { ...approving, error: notice };
178          $.ui.invalidate('ui.render');
179          return;
180        }
181        approving = null;
182        notice = null;
183        await $.ui.close({ id: APPROVE_ID });
184        await act.reload();
185      },
186      cancelApproval: async () => {
187        approving = null;
188        await $.ui.close({ id: APPROVE_ID });
189      },
190      // ACT-3: ask the lead, and show the story as being reviewed until its turn ends.
191      review: async (line) => {
192        reviewing.add(line.story.id);
193        reviewTurn = 'next';
194        $.ui.invalidate('ui.render');
195        await $.prompt.submit({
196          text: `Review ${line.story.id} (${line.story.title}): run /code-kit:review ${line.story.branch}.`,
197        });
198      },
199      // ACT-4: confirm, then verify and merge as the person; nothing merges unless verify passes.
200      merge: async (line) => {
201        const { branch } = line.story;
202        const into = model.base ?? 'the base branch';
203        const answer = await $.ui
204          .ask(
205            `Merge ${branch} into ${into}? code-kit verify runs first, checks included, and nothing merges unless it passes.`,
206            ['Merge', 'Cancel'],
207          )
208          .catch(() => 'Cancel');
209        if (answer !== 'Merge') return;
210        const ran = await $.process.run(['node', cli, 'merge', branch, '--person'], {
211          cwd,
212          timeoutMs: 600000,
213        });
214        result = {
215          ok: ran.exitCode === 0,
216          title: ran.exitCode === 0 ? `Merged ${branch}` : `Nothing merged: ${branch}`,
217          text: `${ran.stdout}${ran.stderr}`.trim(),
218        };
219        await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
220        await act.reload();
221      },
222      // CARD-2: no model call; the transcript shows it.
223      approvals: async () => {
224        await act.reload();
225        if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
226        return { text: approvalsText(model.requests) };
227      },
228      // CARD-3: verify's own report, checks included.
229      verify: async () => {
230        if (model.state?.kind === 'none') return { text: NOT_CODE_KIT };
231        const ran = await $.process.run(['node', cli, 'verify'], { cwd, timeoutMs: 600000 });
232        return { text: `${ran.stdout}${ran.stderr}`.trim() };
233      },
234      dismiss: async () => {
235        notice = null;
236        $.ui.invalidate('ui.render');
237      },
238    };
239
240    // PANE-4 and BAND-3: within 2 seconds of a change. With nothing changed, the project is read
241    // again every 10 seconds while the pane is open, and every minute otherwise (approvals expire).
242    let seen = null;
243    let quiet = 0;
244    let busy = false;
245    $.clock.every(2000, async () => {
246      if (busy) return;
247      busy = true;
248      try {
249        const now = await act.fingerprint();
250        const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
251        quiet += 1;
252        if (now === seen && quiet < (paneOpen ? 5 : 30)) return;
253        quiet = 0;
254        await act.reload();
255        // Taken again: what it covers (the plan) can change with what was read.
256        seen = await act.fingerprint();
257      } finally {
258        busy = false;
259      }
260    });
261    return next(e);
262  });
263
264  const starting = (name) => ({
265    text: `code-kit is still starting; try /${name} again in a moment.`,
266  });
267  on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
268  on('command.run', { command: 'approvals' }, async ($, e) =>
269    act ? act.approvals() : starting('approvals'),
270  );
271  on('command.run', { command: 'verify-branch' }, async ($, e) =>
272    act ? act.verify() : starting('verify-branch'),
273  );
274
275  // CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
276  on('tool.call', async ($, e, next) => {
277    const res = await next(e);
278    const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
279    if (text && refusalCard(text)) {
280      refusals.set(e.tool_use_id, text);
281      if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
282    }
283    return res;
284  }).catch(($, e, next) => next(e)); // it only watches: whatever fails here, the call goes on as it would
285  // A refused call's text: kept from its tool.call, or the output its row carries (what the model read).
286  const refusalOf = (id, output) =>
287    refusalCard(refusals.get(id) ?? (typeof output === 'string' ? output : null));
288  // Shell calls fold into one line ("Ran 1 shell command"); a group holding a refusal unfolds, so
289  // the refused call's row can be drawn as its card.
290  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
291    const refused = e.props.calls.some((c) => c.tool_use_id && refusalOf(c.tool_use_id, c.output));
292    return refused && !e.props.isExpanded
293      ? next({ ...e, props: { ...e.props, isExpanded: true } })
294      : next(e);
295  });
296  // The card takes the call's row; the result block beneath a standalone row is then left empty.
297  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
298    const card = refusalOf(e.props.tool_use_id, e.props.output);
299    if (!card || !act) return next(e);
300    return $.ui.resolve(e).Box({ children: [] });
301  });
302  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
303    const id = e.props.tool_use_id;
304    const card = refusalOf(id, e.props.output);
305    if (!card || !act) return next(e);
306    return refusalView(card, expanded.has(id), $.ui.resolve(e), {
307      onApprove: () => act.approve({ request: card.request }),
308      onToggle: () => {
309        if (expanded.has(id)) expanded.delete(id);
310        else expanded.add(id);
311        $.ui.invalidate('ui.render');
312      },
313    });
314  });
315
316  on('turn.start', async ($, e, next) => {
317    if (reviewTurn === 'next') reviewTurn = e.turnId;
318    return next(e);
319  });
320  // The lead has reported on the review it was asked for.
321  on('turn.complete', async ($, e, next) => {
322    if (reviewTurn && reviewTurn === e.turnId) {
323      reviewing = new Set();
324      reviewTurn = null;
325      $.ui.invalidate('ui.render');
326    }
327    return next(e);
328  });
329
330  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) =>
331    lanesPane(model.state, $.ui.resolve(e)),
332  );
333
334  // BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
335  // plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
336  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337    const lines = bandLines({ ...model, reviewing, notice });
338    if (!lines.length || !act) return next(e);
339    const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
340    const below = await next(e);
341    return below ? $.ui.resolve(e).Box({ flexDirection: 'column', children: [ours, below] }) : ours;
342  });
343
344  on('ui.render', { component: 'Pane', requestId: APPROVE_ID }, async ($, e) => {
345    if (!approving) return resultPane(null, $.ui.resolve(e));
346    return approvePane(approving, $.ui.resolve(e), {
347      onInput: (value) => {
348        approving = { ...approving, reason: value };
349        $.ui.invalidate('ui.render');
350      },
351      onSubmit: (value) => act.confirmApproval(value),
352      onCancel: () => act.cancelApproval(),
353    });
354  });
355
356  on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
357    resultPane(result, $.ui.resolve(e)),
358  );
359}
360
hooks/mod/view.mjs 428 lines
1// What the code-kit mod shows, as pure functions of the CLI's JSON: the mod's hooks run the CLI and
2// pass its output here, and pass the elements `$.ui.resolve` gives them. Nothing here touches the mods
3// API, files or processes, so it is tested with node as well as with `claude plugin test`.
4
5export const PANE_ID = 'code-kit-lanes';
6export const NOT_CODE_KIT = "This project doesn't use code-kit.";
7const LEAD = 'lead';
8
9/** A CLI run's stdout as JSON, or null when it printed none. */
10export function parseJson(stdout) {
11  try {
12    return JSON.parse(stdout);
13  } catch {
14    return null;
15  }
16}
17
18/** The agent statuses that mean an agent is at work in the session now. */
19const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
21/** The subagent types at work, from `$.agent.list()`. */
22export function agentsAtWork(agents) {
23  return new Set((agents ?? []).filter((a) => AT_WORK.has(a.status)).map((a) => a.type));
24}
25
26/** The stories `code-kit next --json` says can start now: dispatched to lanes, or the lead's own. */
27export function readyFrom(next) {
28  if (!next) return [];
29  const steps = [next, ...(next.then ?? [])].filter((s) => ['dispatch', 'lead'].includes(s.step));
30  return [...new Set(steps.flatMap((s) => (s.args ?? '').split(/\s+/).filter(Boolean)))];
31}
32
33/**
34 * One lane's row (PANE-2): the story it's on, its branch and its state.
35 *   building   a branch for the story and, if it has commits, the lane's agent still at work on it;
36 *   in review  commits on the branch (status's `review`) and the agent no longer at work;
37 *   sent back  a review sent the branch back, and the lane hasn't committed its fix yet;
38 *   blocked    the lane's next story waits on another;
39 *   idle       none of these.
40 * `status` can't tell a finished branch from one still being built, so the agent at work decides.
41 */
42function laneRow(name, agent, stories, atWork) {
43  const own = stories.filter((s) => s.lane === name);
44  const active = Boolean(agent && atWork.has(agent));
45  const story = own.find((s) => ['in progress', 'review', 'sent back'].includes(s.state));
46  if (story?.state === 'sent back')
47    return { name, agent, active, story, branch: story.branch, state: 'sent back' };
48  if (story) {
49    const state = story.state === 'review' && !active ? 'in review' : 'building';
50    return { name, agent, active, story, branch: story.branch, state };
51  }
52  const waiting = own.find((s) => ['blocked', 'ready', 'todo'].includes(s.state));
53  if (waiting?.state === 'blocked')
54    return { name, agent, active, story: waiting, branch: null, state: 'blocked' };
55  return { name, agent, active, story: null, branch: null, state: 'idle' };
56}
57
58/**
59 * Where the project stands, from `code-kit check --json` and, when the config is valid, `status
60 * --json` and `next --json` (each null when the config names no plan, or the run failed), and the
61 * subagent types at work:
62 *   { kind: 'none' }                             no .claude/code-kit.json
63 *   { kind: 'invalid', problems }                a config that doesn't validate
64 *   { kind: 'ok', lanes, ready, planGaps }       a row per lane, what's ready, the plan's gaps (or null)
65 */
66export function projectState(check, status, next = null, atWork = new Set()) {
67  if (!check || check.exists === false) return { kind: 'none' };
68  if (!check.valid) return { kind: 'invalid', problems: check.problems ?? [] };
69  const stories = status?.stories ?? [];
70  const lanes = [
71    laneRow(LEAD, null, stories, atWork),
72    ...Object.entries(check.lanes ?? {}).map(([name, l]) =>
73      laneRow(name, l.agent, stories, atWork),
74    ),
75  ];
76  const ready = readyFrom(next).map((id) => {
77    const story = stories.find((s) => s.id === id);
78    return { id, title: story?.title ?? '', lane: story?.lane ?? null };
79  });
80  return { kind: 'ok', lanes, ready, planGaps: status ? (status.problems ?? []).length : null };
81}
82
83const STATE_COLOR = {
84  building: 'blue',
85  'in review': 'yellow',
86  'sent back': 'magenta',
87  blocked: 'red',
88  idle: undefined,
89};
90
91/** The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned. */
92export function lanesPane(state, { Box, Text }) {
93  const text = (value, style = {}) => Text({ ...style, children: [value] });
94  if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
95  if (state.kind === 'invalid')
96    return text('The config is invalid: run code-kit check.', { color: 'red' });
97  const width = Math.max(...state.lanes.map((l) => l.name.length));
98  const rows = state.lanes.map((l) =>
99    Box({
100      key: `lane-${l.name}`,
101      flexDirection: 'column',
102      children: [
103        Box({
104          flexDirection: 'row',
105          columnGap: 2,
106          children: [
107            text(l.active ? '●' : ' ', { color: 'green' }),
108            text(l.name.padEnd(width), { bold: true }),
109            text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
110            text(l.agent ?? 'the main session', { dimColor: true }),
111          ],
112        }),
113        ...(l.story
114          ? [
115              text(
116                `    ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
117                  l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
118                }`,
119                { dimColor: true, wrap: 'truncate-end' },
120              ),
121            ]
122          : []),
123      ],
124    }),
125  );
126  const notes = [];
127  if (state.planGaps)
128    notes.push(
129      text(`The plan has ${state.planGaps} gap(s): run code-kit status.`, { color: 'yellow' }),
130    );
131  const ready = state.ready.length
132    ? [
133        text('Ready', { bold: true }),
134        ...state.ready.map((r) =>
135          Box({
136            key: `ready-${r.id}`,
137            flexDirection: 'row',
138            columnGap: 2,
139            children: [
140              text(r.id),
141              text(r.title, { wrap: 'truncate-end' }),
142              text(r.lane === LEAD ? "the lead's own" : (r.lane ?? ''), { dimColor: true }),
143            ],
144          }),
145        ),
146      ]
147    : [text('Nothing is ready to start.', { dimColor: true })];
148  return Box({
149    flexDirection: 'column',
150    rowGap: 1,
151    children: [
152      ...notes,
153      Box({ flexDirection: 'column', children: rows }),
154      Box({ flexDirection: 'column', children: ready }),
155    ],
156  });
157}
158
159// --- the band, and the person's acts -----------------------------------------------------------
160
161export const APPROVE_ID = 'code-kit-approve';
162export const RESULT_ID = 'code-kit-result';
163
164/** An approval name as a person reads it: a dependency approval by its package. */
165export function approvalLabel(name) {
166  return name.startsWith('dep-') ? name.slice(4).replaceAll('+', '/') : name;
167}
168
169/** A command as a person reads it: the part before its pipes, redirects and chained commands. */
170export function commandItself(command) {
171  return String(command)
172    .split(/\s+(?:\d?>>?&?\d*|\|\|?|&&|;)(?:\s|$)/)[0]
173    .trim();
174}
175
176/** The reason an Approve… confirmation starts with (ACT-1). */
177export function prefilledReason(request) {
178  return `Approve ${request.names.map(approvalLabel).join(', ')} for ${
179    request.lane ? `the ${request.lane} lane` : 'any agent'
180  }: ${commandItself(request.what)}`;
181}
182
183const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
184
185/**
186 * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
187 * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
188 * `notice` is why the person's last act failed, until they dismiss it.
189 */
190export function bandLines({ state, requests, stops, reviewing = new Set(), notice = null }) {
191  const lines = [];
192  if (notice)
193    lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
194  if (!state || state.kind !== 'ok') return lines;
195  const open = requests?.open ?? [];
196  if (open.length)
197    lines.push({
198      kind: 'approvals',
199      text: `${plural(open.length, 'approval', 'approvals')} waiting`,
200      request: open[0],
201      actions: [{ id: 'approve', label: 'Approve…' }],
202    });
203  const inReview = state.lanes.filter((l) => l.state === 'in review').map((l) => l.story);
204  if (inReview.length) {
205    const [story] = inReview;
206    const ids = inReview.map((s) => s.id).join(', ');
207    lines.push(
208      reviewing.has(story.id)
209        ? { kind: 'review', text: `${story.id} being reviewed`, story, actions: [] }
210        : {
211            kind: 'review',
212            text: `${ids} ready for review`,
213            story,
214            actions: [
215              { id: 'review', label: 'Review' },
216              { id: 'merge', label: 'Merge' },
217            ],
218          },
219    );
220  }
221  if (stops?.length)
222    lines.push({
223      kind: 'finish',
224      text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
225      actions: [{ id: 'lanes', label: 'Lanes' }],
226    });
227  // BAND-4: a digit for every action, in the order they're drawn.
228  let digit = 0;
229  for (const line of lines)
230    for (const action of line.actions) action.hotkey = digit < 9 ? String(++digit) : undefined;
231  return lines;
232}
233
234/** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
235export function band(lines, { Box, Text, Button }, onAction) {
236  const COLOR = { notice: 'red', approvals: 'yellow', review: 'blue', finish: 'red' };
237  return Box({
238    flexDirection: 'column',
239    children: lines.map((line) =>
240      Box({
241        key: `band-${line.kind}`,
242        flexDirection: 'row',
243        columnGap: 2,
244        children: [
245          Text({ color: COLOR[line.kind], children: ['code-kit'] }),
246          Text({ children: [line.text] }),
247          ...line.actions.map((a) =>
248            Button({
249              key: `band-${a.id}`,
250              label: a.hotkey ? `${a.hotkey} ${a.label}` : a.label,
251              hotkey: a.hotkey,
252              onPress: () => onAction(a.id, line),
253            }),
254          ),
255        ],
256      }),
257    ),
258  });
259}
260
261/** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
262export function approvePane(
263  approving,
264  { Box, Text, Input, Button },
265  { onInput, onSubmit, onCancel },
266) {
267  const { request, reason, error } = approving;
268  return Box({
269    flexDirection: 'column',
270    rowGap: 1,
271    children: [
272      Text({ bold: true, children: [`Approve ${request.names.join(', ')}`] }),
273      Text({
274        children: [
275          `For ${request.lane ? `the ${request.lane} lane` : 'any agent'}, ${request.names.every((n) => n.startsWith('dep-')) ? 'until the install is committed (at most 7 days)' : 'for 60 minutes'}. Asked for: ${request.what}`,
276        ],
277      }),
278      Input({
279        key: 'approve-reason',
280        label: 'Reason',
281        value: reason,
282        submitLabel: 'Approve',
283        autoFocus: true,
284        onInput,
285        onSubmit,
286      }),
287      ...(error ? [Text({ color: 'red', children: [error] })] : []),
288      Box({
289        flexDirection: 'row',
290        columnGap: 2,
291        children: [
292          Button({ key: 'approve-confirm', label: 'Approve', onPress: () => onSubmit(reason) }),
293          Button({ key: 'approve-cancel', label: 'Cancel', onPress: onCancel }),
294        ],
295      }),
296    ],
297  });
298}
299
300/** What a merge (or another act run through the CLI) reported. */
301export function resultPane(result, { Box, Text }) {
302  if (!result) return Text({ dimColor: true, children: ['Nothing to show.'] });
303  return Box({
304    flexDirection: 'column',
305    rowGap: 1,
306    children: [
307      Text({ bold: true, color: result.ok ? 'green' : 'red', children: [result.title] }),
308      Text({ children: [result.text.slice(-9000) || '(no output)'] }),
309    ],
310  });
311}
312
313// --- refusal cards and the commands ------------------------------------------------------------
314
315const APPROVAL_LINE =
316  /^\s*! echo "<what you are approving>" > .*?\.claude\/approvals\/(?:([^/\s]+)\/)?([^/\s]+)\s*$/gm;
317
318/**
319 * A code-kit refusal read back from the text its hooks print (CARD-1): what was refused, the rule and
320 * why, what to do, and the approval that would allow it, if any. Null for text it doesn't recognise,
321 * which Claude Code then draws as it would.
322 */
323export function refusalCard(raw) {
324  const at = typeof raw === 'string' ? raw.indexOf('Blocked: ') : -1;
325  if (at < 0) return null;
326  const text = raw.slice(at + 'Blocked: '.length).trim();
327  const [first, ...rest] = text.split('\n');
328  const approvals = [...text.matchAll(APPROVAL_LINE)];
329  const command = text.match(/^Command: (.+)$/m)?.[1] ?? null;
330  const request = (what) =>
331    approvals.length
332      ? { names: approvals.map((m) => m[2]), lane: approvals[0][1] ?? null, what }
333      : null;
334  let m = first.match(/^The (.+?) may not write (\S+?)(?: \(owned by the (.+)\))?\.$/);
335  if (m)
336    return {
337      kind: 'write',
338      title: `Write refused: ${m[2]}`,
339      why: m[3] ? `It's owned by the ${m[3]}, not the ${m[1]}.` : `Nobody may write it.`,
340      todo: rest.find((l) => l.trim()) ?? '',
341      request: null,
342      raw: text,
343    };
344  m = first.match(/^(\S+) is (.+) and needs a person's approval\./);
345  if (m)
346    return {
347      kind: 'approval',
348      title: `Approval needed: ${m[1]}`,
349      why: `It's ${m[2]}.`,
350      todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
351      request: request(`write ${m[1]}`),
352      raw: text,
353    };
354  m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
355  if (m)
356    return {
357      kind: 'dependency',
358      title: `Install refused: ${m[1]}`,
359      why: "A new dependency needs a person's approval.",
360      todo: 'A person approves it, here or with the `!` command, until the install is committed (at most 7 days).',
361      request: request(command ?? `install ${m[1]}`),
362      raw: text,
363    };
364  if (command)
365    return {
366      kind: 'command',
367      title: 'Command refused',
368      why: first,
369      todo: rest.filter((l) => l.trim() && !l.startsWith('Command: ')).join(' '),
370      command,
371      request: request(command),
372      raw: text,
373    };
374  return null;
375}
376
377/** A refusal card (CARD-1), with Approve… where an approval would allow it and the raw text a press away. */
378export function refusalView(card, expanded, { Box, Text, Button }, { onApprove, onToggle }) {
379  return Box({
380    flexDirection: 'column',
381    borderStyle: 'round',
382    paddingX: 1,
383    children: [
384      Text({ bold: true, color: 'red', children: [card.title] }),
385      Text({ children: [card.why] }),
386      ...(card.command ? [Text({ dimColor: true, children: [card.command] })] : []),
387      ...(card.todo ? [Text({ dimColor: true, children: [card.todo] })] : []),
388      Box({
389        flexDirection: 'row',
390        columnGap: 2,
391        children: [
392          ...(card.request
393            ? [Button({ key: 'card-approve', label: 'Approve…', onPress: onApprove })]
394            : []),
395          Button({
396            key: 'card-raw',
397            label: expanded ? 'Hide the text' : 'Show the text',
398            plain: true,
399            onPress: onToggle,
400          }),
401        ],
402      }),
403      ...(expanded ? [Text({ dimColor: true, children: [card.raw] })] : []),
404    ],
405  });
406}
407
408/** `/approvals` (CARD-2): the open requests and the approvals in force, from `requests --json`. */
409export function approvalsText(requests) {
410  const open = requests?.open ?? [];
411  const inForce = requests?.inForce ?? [];
412  const lines = [
413    open.length ? `${plural(open.length, 'request', 'requests')} waiting:` : 'No requests waiting.',
414    ...open.map(
415      (r) =>
416        `  ${r.names.map(approvalLabel).join(', ')} for ${r.lane ? `the ${r.lane} lane` : 'any agent'}: ${r.what}`,
417    ),
418    inForce.length
419      ? `${plural(inForce.length, 'approval', 'approvals')} in force:`
420      : 'No approvals in force.',
421    ...inForce.map(
422      (a) =>
423        `  ${approvalLabel(a.name)} for ${a.lane ? `the ${a.lane} lane` : 'any agent'}, ${a.minutesLeft} min left: ${a.reason}`,
424    ),
425  ];
426  return lines.join('\n');
427}
428