@warren-dean/code-kit 0.3.15 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
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
+
5
+ export const PANE_ID = 'code-kit-lanes';
6
+ export const NOT_CODE_KIT = "This project doesn't use code-kit.";
7
+ const LEAD = 'lead';
8
+
9
+ /** A CLI run's stdout as JSON, or null when it printed none. */
10
+ export 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. */
19
+ const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
+
21
+ /** The subagent types at work, from `$.agent.list()`. */
22
+ export 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. */
27
+ export 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
+ */
42
+ function 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
+ */
66
+ export 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
+
83
+ const 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. */
92
+ export 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
+
161
+ export const APPROVE_ID = 'code-kit-approve';
162
+ export const RESULT_ID = 'code-kit-result';
163
+
164
+ /** An approval name as a person reads it: a dependency approval by its package. */
165
+ export 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. */
170
+ export 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). */
177
+ export 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
+
183
+ const 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
+ */
190
+ export 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. */
235
+ export 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. */
262
+ export 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. */
301
+ export 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
+
315
+ const 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
+ */
323
+ export 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. */
378
+ export 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`. */
409
+ export 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
+ }
@@ -39,7 +39,18 @@ const blockCounted = (message) => {
39
39
  const count = last.message === message ? last.count + 1 : 1;
40
40
  if (count > MAX_REPEATS) passed();
41
41
  mkdirSync(join(memory, '..'), { recursive: true });
42
- writeFileSync(memory, JSON.stringify({ message, count }));
42
+ // Also what `code-kit stops` shows the person: whose finish was refused, when, and why.
43
+ writeFileSync(
44
+ memory,
45
+ JSON.stringify({
46
+ message,
47
+ count,
48
+ session: input.session_id ?? null,
49
+ agent: input.agent_id ?? null,
50
+ agentType: input.agent_type ?? null,
51
+ at: new Date().toISOString(),
52
+ }),
53
+ );
43
54
  const final =
44
55
  count === MAX_REPEATS
45
56
  ? `\n\n(code-kit has reported this ${count} times without progress; it won't block on it again. Tell the lead what is still failing.)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.3.15",
3
+ "version": "0.5.0",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",
@@ -38,6 +38,6 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "scripts": {
41
- "test": "node --test test/units.test.mjs && node test/hooks.test.mjs"
41
+ "test": "node --test test/units.test.mjs && node test/hooks.test.mjs && node test/mod.mjs"
42
42
  }
43
43
  }
@@ -31,7 +31,7 @@ Say which one applies and why before you start.
31
31
  - **Design-first rules:** screens designed before they are built, and where approvals are listed.
32
32
  - **Commands:** test, lint, typecheck, database and deploy commands, and which must never run locally.
33
33
  2. **The repository.** Map the real layout: `git ls-files | cut -d/ -f1-3 | sort | uniq -c`, package manifests (`package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`), workspace files, existing lint, format and test scripts, CI workflows and CODEOWNERS.
34
- 3. **Existing kit.** If the project has `.claude/hooks`, agents or skills, read them. The kit replaces local hooks; agents and skills are kept and adapted, not rewritten.
34
+ 3. **Existing kit.** If the project has `.claude/hooks`, agents or skills, read them. The kit replaces local hooks; agents and skills are kept and adapted, not rewritten. On an update (section 4), skills that came from the docs' recipes follow those recipes as they are now.
35
35
  4. **Other tools.** Run `adapters --config .claude/code-kit.draft.json` once the draft exists. It lists the tools code-kit has an adapter for (such as Context Graph, found by its `.ctx/` folder), what each adds to the rules, and the setup each needs. Don't copy an adapter's paths into the config; they're added automatically while the tool is present.
36
36
 
37
37
  **Brownfield, in addition:**
@@ -97,6 +97,8 @@ Draft, alongside the config:
97
97
  - Drop the spec-check lines if the config has no `docs.specs`.
98
98
  - **CLAUDE.md:** add the section from `${CLAUDE_PLUGIN_ROOT}/templates/claude-md.md`.
99
99
  - **Project skills** (recipes like "new migration" or "new endpoint"): list the recipes the docs describe step by step, and offer to write them. Don't write them unasked, and never write an empty one.
100
+ - Write each as `.claude/skills/<name>/SKILL.md`, saying in its description which recipe in which doc it comes from, so an update can find it again.
101
+ - List it under **Skills to use** on every lane agent whose paths the recipe touches, not only one: a recipe that adds a migration and the endpoint that uses it belongs to both lanes. A recipe only the lead follows goes in the CLAUDE.md section instead.
100
102
 
101
103
  Show the person:
102
104
 
@@ -144,10 +146,16 @@ Re-run whenever the build has moved on: new apps or packages, folders that moved
144
146
 
145
147
  4. **Also propose:**
146
148
  - agent files for new lanes, and updates to agents whose paths changed;
149
+ - **project skills**, by reading the recipes in the docs again and comparing them with `.claude/skills/`:
150
+ - a new skill for each recipe the docs now describe that has none;
151
+ - an update to each skill whose recipe changed, showing what changed;
152
+ - removing a skill whose recipe is gone from the docs (ask first: the person may want it kept);
153
+ - the matching **Skills to use** changes on every lane agent whose paths each recipe touches;
154
+ - skills the person wrote themselves, with no recipe behind them, stay as they are;
147
155
  - a smaller baseline when `baseline` shows violations were fixed. It only ever shrinks.
148
156
  5. **On approval:**
149
157
  1. Replace `.claude/code-kit.json` with the draft and delete the draft.
150
- 2. Write or update the agents.
158
+ 2. Write or update the agents, and write, update or remove the project skills as approved.
151
159
  3. Run `baseline --write` if it shrank.
152
160
  4. Run `check` and `unowned`.
153
161
  5. Commit on a branch.
@@ -50,8 +50,16 @@ Read the story, every requirement it cites, and the full diff (`git diff <base>.
50
50
 
51
51
  Report the table, `verify`'s result and one of:
52
52
 
53
+ - **Files any agent may write** (`anyActor`, such as a lockfile or recorded decisions) have no owning lane, but they have a reviewer: `trace <file>` names it (the lead unless the config names a lane). Review those changes as that reviewer would, or ask that lane's agent.
53
54
  - **Merge.** Everything passes. The lead merges the branch into its own branch (`git merge --no-ff <branch>`), sets the story's `**Status:**` to `done` in the plan, and commits. `main` and the other protected branches are merged by a person, through a pull request, after `verify` passes in CI. When the branch is merged, remove its worktree.
54
- - **Send back.** List each problem, where it is, and what "fixed" looks like. Send the list to the same lane agent if it's still running (SendMessage), or dispatch the story again with the list added to the brief. Set the story's status to `in progress`.
55
+ - **Send back.** List each problem, where it is, and what "fixed" looks like. Record the send-back, so `status` and `next` read the branch as waiting for its fix rather than finished:
56
+
57
+ ```bash
58
+ node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" sent-back <branch> --reason "<the problems, in one line>"
59
+ ```
60
+
61
+ Send the list to the same lane agent if it's still running (SendMessage), or dispatch the story again with the list added to the brief. Set the story's status to `in progress`.
62
+
55
63
  - **Ask the person.** When the right answer is a product or scope decision (the spec is silent, or the lane found a real conflict), put the question to the person with your recommendation. Don't merge around it.
56
64
 
57
65
  Keep the report short: the verdict first, then the table, then the problems.
@@ -9,7 +9,7 @@ You are the {{lane}} engineer for this repository.
9
9
  **Owned paths:** {{lane paths from .claude/code-kit.json}}
10
10
  **Layers:** {{the layers in these paths, and what each may depend on}}
11
11
  **Specs:** {{the spec files this lane reads}}
12
- **Skills to use:** spec-check{{, project skills for this lane}}
12
+ **Skills to use:** spec-check{{, every project skill whose recipe touches this lane's paths}}
13
13
  **Prove your work with:** `{{the command that proves this lane's work}}`
14
14
 
15
15
  Lane rules: