@warren-dean/code-kit 0.11.0 → 0.12.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.
@@ -35,6 +35,8 @@ import { globToRegExp, matchesAny } from './glob.mjs';
35
35
  export const APPROVAL_LOG = '.claude/approval-log.jsonl';
36
36
  export const APPROVALS_DIR = '.claude/approvals';
37
37
  export const APPROVAL_MINUTES = 60;
38
+ // CARD-7: a request waits until it's answered or dropped, and at most a week, so a forgotten one doesn't linger.
39
+ export const REQUEST_DAYS = 7;
38
40
  // A dependency approval lasts until the lane commits the install it allows (the story may resume
39
41
  // hours later), and at most a week, so a forgotten one doesn't linger.
40
42
  export const DEPENDENCY_APPROVAL_DAYS = 7;
@@ -62,6 +64,8 @@ export const RELAYED = '(given in chat, recorded by the lead)';
62
64
  export const PANE = '(approved in the code-kit pane)';
63
65
  /** Refusals a person's approval would allow, for the mod's band and `code-kit requests`. */
64
66
  export const REQUESTS_FILE = '.claude/state/requests.jsonl';
67
+ /** Every approval given through code-kit, and every one used up, kept for the history (CARD-6). */
68
+ export const GIVEN_FILE = '.claude/state/approvals-given.jsonl';
65
69
 
66
70
  /** The command the lead runs to record approvals a person gave in chat. */
67
71
  /** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
@@ -107,11 +111,52 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
107
111
  export function grantApprovals(root, names, lane, reason, marks = {}) {
108
112
  const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
109
113
  mkdirSync(dir, { recursive: true });
110
- return names.map((name) => {
114
+ const at = new Date().toISOString();
115
+ const files = names.map((name) => {
111
116
  const file = join(dir, name);
112
117
  writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
113
118
  return file;
114
119
  });
120
+ noteGiven(
121
+ root,
122
+ names.map((name) => ({
123
+ at,
124
+ name,
125
+ ...(lane ? { lane } : {}),
126
+ reason: `${reason.trim()} ${marks[name] ?? RELAYED}`,
127
+ })),
128
+ );
129
+ return files;
130
+ }
131
+
132
+ const givenFile = (root) => join(projectWorktrees(root)[0] ?? root, GIVEN_FILE);
133
+
134
+ function noteGiven(root, entries) {
135
+ try {
136
+ const file = givenFile(root);
137
+ mkdirSync(dirname(file), { recursive: true });
138
+ appendFileSync(file, entries.map((e) => `${JSON.stringify(e)}\n`).join(''));
139
+ } catch {
140
+ // the approval stands; only its history misses it
141
+ }
142
+ }
143
+
144
+ /** The approvals given through code-kit (and those used up), oldest first. */
145
+ export function approvalsGiven(root) {
146
+ try {
147
+ return readFileSync(givenFile(root), 'utf8')
148
+ .split('\n')
149
+ .filter(Boolean)
150
+ .flatMap((l) => {
151
+ try {
152
+ return [JSON.parse(l)];
153
+ } catch {
154
+ return [];
155
+ }
156
+ });
157
+ } catch {
158
+ return [];
159
+ }
115
160
  }
116
161
 
117
162
  /** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
@@ -249,8 +294,21 @@ export function useApproval(root, name, actor) {
249
294
  for (const file of [
250
295
  ...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
251
296
  join(dir, name),
252
- ])
253
- if (inForce(file)) return rmSync(file, { force: true });
297
+ ]) {
298
+ const a = inForce(file);
299
+ if (!a) continue;
300
+ // Kept in the history, and as the answer to the request it was given for, once its file is gone.
301
+ noteGiven(root, [
302
+ {
303
+ at: a.grantedAt,
304
+ name,
305
+ ...(file !== join(dir, name) ? { lane: actor.lane } : {}),
306
+ reason: a.reason,
307
+ usedAt: new Date().toISOString(),
308
+ },
309
+ ]);
310
+ return rmSync(file, { force: true });
311
+ }
254
312
  }
255
313
 
256
314
  const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
@@ -259,8 +317,8 @@ const sameRequest = (a, b) =>
259
317
  a.what === b.what &&
260
318
  [...a.names].sort().join() === [...b.names].sort().join();
261
319
 
262
- /** Every request recorded, oldest first; unreadable lines are skipped. */
263
- export function readRequests(root) {
320
+ /** Every line of the requests file: the requests, and the drops (`{ drop: true, of }`). */
321
+ function requestRecords(root) {
264
322
  try {
265
323
  return readFileSync(requestsFile(root), 'utf8')
266
324
  .split('\n')
@@ -277,12 +335,45 @@ export function readRequests(root) {
277
335
  }
278
336
  }
279
337
 
338
+ /** Every request recorded, oldest first; unreadable lines are skipped. */
339
+ export const readRequests = (root) => requestRecords(root).filter((r) => !r.drop);
340
+
341
+ /** CARD-7: whether a request was dropped (the person dismissed it, or the lead said it's no longer needed). */
342
+ const droppedIn = (drops, r) => drops.some((d) => d.of === r.at && sameRequest(d, r));
343
+
344
+ /**
345
+ * CARD-7: drops open requests: `which` is a request's number in `openRequests`' order (1 is the
346
+ * newest), or 'all'. By the person (`by: 'person'`, from the pane) or the lead, once nobody needs it.
347
+ */
348
+ export function dropRequests(root, which, { by, reason = null }, now = Date.now()) {
349
+ const open = openRequests(root, now);
350
+ const picked = which === 'all' ? open : [open[Number(which) - 1]].filter(Boolean);
351
+ if (!picked.length)
352
+ return {
353
+ ok: false,
354
+ why: open.length
355
+ ? `There's no open request ${which}: there are ${open.length} (code-kit requests lists them, newest first).`
356
+ : 'No approval requests are open.',
357
+ };
358
+ const file = requestsFile(root);
359
+ mkdirSync(dirname(file), { recursive: true });
360
+ for (const r of picked)
361
+ appendFileSync(
362
+ file,
363
+ `${JSON.stringify({ drop: true, of: r.at, actor: r.actor, names: r.names, what: r.what, by, reason, at: new Date(now).toISOString() })}\n`,
364
+ );
365
+ return { ok: true, dropped: picked };
366
+ }
367
+
280
368
  /**
281
369
  * Records that `actor` was refused something the approvals `names` would allow, once: the same
282
- * actor, names and command or file within the approval lifetime count as one request. Recording
283
- * never changes the refusal, so any failure here is ignored.
370
+ * actor, names and command or file while one is still open count as one request. Recording never
371
+ * changes the refusal, so any failure here is ignored.
284
372
  */
285
373
  export function recordRequest(root, actor, names, what, why, now = Date.now()) {
374
+ // A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
375
+ // person as a request.
376
+ if (actor?.kind === 'readonly') return;
286
377
  try {
287
378
  const request = {
288
379
  at: new Date(now).toISOString(),
@@ -292,10 +383,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
292
383
  what: String(what).slice(0, 200),
293
384
  why: String(why).slice(0, 300),
294
385
  };
295
- const fresh = readRequests(root).filter(
296
- (r) => now - Date.parse(r.at) < APPROVAL_MINUTES * 60_000,
297
- );
298
- if (fresh.some((r) => sameRequest(r, request))) return;
386
+ if (openRequests(root, now).some((r) => sameRequest(r, request))) return;
299
387
  const file = requestsFile(root);
300
388
  mkdirSync(dirname(file), { recursive: true });
301
389
  appendFileSync(file, `${JSON.stringify(request)}\n`);
@@ -304,19 +392,172 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
304
392
  }
305
393
  }
306
394
 
307
- /** Requests from the last 60 minutes that an approval in force doesn't yet answer, newest first. */
395
+ /**
396
+ * CARD-7: requests nobody has answered yet, newest first: each waits until an approval answers it, its
397
+ * file is committed by someone who could, or it's dropped, and at most REQUEST_DAYS.
398
+ */
308
399
  export function openRequests(root, now = Date.now()) {
309
400
  const open = [];
401
+ const given = approvalsGiven(root);
402
+ const drops = requestRecords(root).filter((r) => r.drop);
403
+ // The newest of the same request speaks for it: an older copy doesn't come back once that's dropped.
404
+ const seen = [];
310
405
  for (const r of readRequests(root).reverse()) {
311
- if (now - Date.parse(r.at) >= APPROVAL_MINUTES * 60_000) continue;
312
- if (open.some((o) => sameRequest(o, r))) continue;
406
+ if (now - Date.parse(r.at) >= REQUEST_DAYS * 24 * 60 * 60_000) continue;
407
+ if (seen.some((o) => sameRequest(o, r))) continue;
408
+ seen.push(r);
409
+ if (droppedIn(drops, r)) continue;
410
+ // From before read-only agents stopped asking: only the lead and the lanes are ever approved.
411
+ if (!r.lane && r.actor !== 'lead session') continue;
313
412
  const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
314
413
  if (r.names.every((n) => approval(root, n, actor))) continue;
414
+ // Answered already: approved after it was asked (since expired, or used up by the commit it
415
+ // allowed), or the file it named was committed since by someone who could.
416
+ if (r.names.every((n) => grantedSince(root, given, n, r))) continue;
417
+ if (committedSince(root, r)) continue;
315
418
  open.push(r);
316
419
  }
317
420
  return open;
318
421
  }
319
422
 
423
+ /** Whether `name` was approved for the request's lane (or for everyone) at or after it was asked. */
424
+ function grantedSince(root, given, name, r) {
425
+ const asked = Date.parse(r.at);
426
+ if (
427
+ given.some(
428
+ (g) => g.name === name && (!g.lane || g.lane === r.lane) && Date.parse(g.at) >= asked,
429
+ )
430
+ )
431
+ return true;
432
+ const dir = approvalsDir(root);
433
+ return [...(r.lane ? [join(dir, r.lane, name)] : []), join(dir, name)].some((file) => {
434
+ try {
435
+ return statSync(file).isFile() && statSync(file).mtimeMs >= asked;
436
+ } catch {
437
+ return false;
438
+ }
439
+ });
440
+ }
441
+
442
+ /** Whether the file a request named has been committed, on any branch, since it was asked. */
443
+ function committedSince(root, r) {
444
+ const path = String(r.what);
445
+ if (/\s/.test(path) || (!path.includes('/') && !path.includes('.'))) return false;
446
+ try {
447
+ const out = execFileSync(
448
+ 'git',
449
+ [
450
+ '-C',
451
+ root,
452
+ 'log',
453
+ '--all',
454
+ '-1',
455
+ '--format=%ct',
456
+ `--since=@${Math.floor(Date.parse(r.at) / 1000)}`,
457
+ '--',
458
+ path,
459
+ ],
460
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
461
+ );
462
+ return Boolean(out.trim());
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * CARD-6: every approval given, newest first: { at, name, lane, reason, by, files, branches, used,
470
+ * expired }. From the approvals given through code-kit, the approvals on disk (a person's own `!` echo
471
+ * included), and the audit log in every worktree, so an approval shows before its branch merges.
472
+ */
473
+ export function approvalHistory(root, now = Date.now()) {
474
+ const grants = [];
475
+ const near = (a, b) => Math.abs(Date.parse(a) - Date.parse(b)) < 2000;
476
+ const add = (g) => {
477
+ const found = grants.find(
478
+ (x) => x.name === g.name && (x.lane ?? null) === (g.lane ?? null) && near(x.at, g.at),
479
+ );
480
+ if (found) {
481
+ if (g.usedAt) found.used = true;
482
+ return found;
483
+ }
484
+ const grant = {
485
+ at: g.at,
486
+ name: g.name,
487
+ lane: g.lane ?? null,
488
+ reason: g.reason ?? '',
489
+ files: [],
490
+ branches: [],
491
+ used: Boolean(g.usedAt),
492
+ };
493
+ grants.push(grant);
494
+ return grant;
495
+ };
496
+ for (const g of approvalsGiven(root)) add(g);
497
+ const dir = approvalsDir(root);
498
+ const look = (folder, lane) => {
499
+ let names = [];
500
+ try {
501
+ names = readdirSync(folder);
502
+ } catch {
503
+ return;
504
+ }
505
+ for (const name of names) {
506
+ const file = join(folder, name);
507
+ const st = statSync(file);
508
+ if (st.isDirectory()) {
509
+ if (!lane) look(file, name);
510
+ continue;
511
+ }
512
+ add({
513
+ at: new Date(st.mtimeMs).toISOString(),
514
+ name,
515
+ lane,
516
+ reason: readFileSync(file, 'utf8').trim(),
517
+ });
518
+ }
519
+ };
520
+ look(dir, null);
521
+ const seen = new Set();
522
+ for (const tree of projectWorktrees(root).length ? projectWorktrees(root) : [root]) {
523
+ let lines = [];
524
+ try {
525
+ lines = readFileSync(join(tree, APPROVAL_LOG), 'utf8').split('\n').filter(Boolean);
526
+ } catch {
527
+ continue;
528
+ }
529
+ for (const line of lines) {
530
+ if (seen.has(line)) continue;
531
+ seen.add(line);
532
+ let e;
533
+ try {
534
+ e = JSON.parse(line);
535
+ } catch {
536
+ continue;
537
+ }
538
+ if (!e.approval || e.approval === 'lead') continue;
539
+ const grant =
540
+ grants.find((x) => x.name === e.approval && near(x.at, e.grantedAt ?? e.at)) ??
541
+ add({ at: e.grantedAt ?? e.at, name: e.approval, reason: e.reason });
542
+ if (e.file && !grant.files.includes(e.file)) grant.files.push(e.file);
543
+ if (e.branch && !grant.branches.includes(e.branch)) grant.branches.push(e.branch);
544
+ }
545
+ }
546
+ return grants
547
+ .map((g) => ({
548
+ ...g,
549
+ by: /within the delegated rules/.test(g.reason)
550
+ ? 'the lead, within your delegated rules'
551
+ : /given in chat, recorded by the lead/.test(g.reason)
552
+ ? 'you, in chat'
553
+ : /in the [\w -]+ pane/.test(g.reason)
554
+ ? 'you, in the pane'
555
+ : 'you',
556
+ expired: g.used || now - Date.parse(g.at) >= approvalMinutes(g.name) * 60_000,
557
+ }))
558
+ .sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
559
+ }
560
+
320
561
  /** Approvals in force now: [{ name, lane?, reason, grantedAt, minutesLeft }]. */
321
562
  export function approvalsInForce(root, now = Date.now()) {
322
563
  const dir = approvalsDir(root);
@@ -434,6 +675,8 @@ export function writeProblem(actor, rel, root, config) {
434
675
  // A package a person approved may change the manifests and lockfiles its install rewrites.
435
676
  if (dependencyChange(actor, rel, root, config)) return null;
436
677
  const guarded = protectedEntry(rel, config);
678
+ if (actor.kind === 'readonly' && (guarded || !matchesAny(rel, config.anyActor)))
679
+ return readonlyProblem(actor, rel);
437
680
  // The lead writes protected files without an approval: the person reviews them in the pull request,
438
681
  // and every committed change is in the audit log. Anyone else needs a person's approval as well.
439
682
  if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
@@ -441,12 +684,16 @@ export function writeProblem(actor, rel, root, config) {
441
684
  return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
442
685
  }
443
686
  if (matchesAny(rel, config.anyActor)) return null;
444
- if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
687
+ if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
445
688
  if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
446
689
  const specs = actor.kind === 'lane' && config.docs?.specs;
447
690
  return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
448
691
  }
449
692
 
693
+ /** A read-only agent's write, refused, with how to undo one a command it ran made (a build, a generator). */
694
+ const readonlyProblem = (actor, rel) =>
695
+ `${actor.label} is read-only; it may not write ${rel}. If a command you ran changed it (a build, a formatter or a code generator), put it back with git restore -- ${rel} (or delete it if it's new), and run such commands only in a throwaway worktree of the branch.`;
696
+
450
697
  function outsideAllowed(actor, rel, root, config) {
451
698
  if (actor.kind !== 'lead') return false;
452
699
  const target = resolve(root, rel);
package/hooks/mod/art.mjs CHANGED
@@ -107,7 +107,7 @@ export function spriteSvg(pose, tint, { lead = false } = {}) {
107
107
 
108
108
  /**
109
109
  * A lane's pose (VIEW-3): stalled when flagged or quiet past the restart time, waiting while a call
110
- * of its is held or its request is open, working while its agent is at work, quiet when it has
110
+ * of its is held, its request is open or its story waits on the person's answer, working while its agent is at work, quiet when it has
111
111
  * gone quiet, done once its branch is finished, idle otherwise.
112
112
  */
113
113
  export function poseOf(
@@ -115,7 +115,7 @@ export function poseOf(
115
115
  { live = null, waiting = false, flagged = false, shown = lane.state } = {},
116
116
  ) {
117
117
  if (flagged || live?.level === 'stalled') return 'stalled';
118
- if (waiting) return 'waiting';
118
+ if (waiting || shown === 'waiting on you') return 'waiting';
119
119
  if (live?.level === 'quiet') return 'quiet';
120
120
  if (lane.active) return 'working';
121
121
  if (['in review', 'queued'].includes(shown)) return 'done';
@@ -2,6 +2,7 @@
2
2
  // the person, each with its lineage. The band's "you" lines say what needs them; a decision card says why:
3
3
  // what they asked for, the story and requirements it belongs to, what the agent was doing, what was already
4
4
  // settled, why the lead couldn't settle this itself, and each option with what happens next.
5
+ import { askWords } from './view.mjs';
5
6
 
6
7
  /** The kinds of band line that are the person's to decide; the rest are notes. */
7
8
  export const DECISION_KINDS = new Set([
@@ -17,6 +18,10 @@ export const DECISION_KINDS = new Set([
17
18
  'teamIntent',
18
19
  'teamReview',
19
20
  'teamDrift',
21
+ 'question',
22
+ 'brief',
23
+ 'denied',
24
+ 'report',
20
25
  ]);
21
26
 
22
27
  /** What each answer does, in the person's words. */
@@ -40,8 +45,97 @@ const EFFECT = {
40
45
  'The issue closes as agreed, with your words; then put it in the specs or decisions so every agent builds on it.',
41
46
  dropIntent: 'The issue closes as dropped, and the team carries on as agreed before.',
42
47
  team: 'Opens the Team tab: who is building what, and what changed since you last looked.',
48
+ openDecision: 'Opens it here, with what each answer does.',
49
+ dropRequest: "Nobody needs it any more: it stops waiting, and nothing's approved.",
50
+ answer: 'Type your answer; the lead is told it and carries on.',
51
+ answerBrief: "Type your answer; the lead records it in the brief's decisions and builds on it.",
52
+ laterBrief: 'It waits; it comes back next session.',
53
+ runDenied:
54
+ 'Puts it in your prompt after !, so you run it yourself; press Enter to run it, then Done it.',
55
+ deniedDone: 'The lead is told you ran it, or allowed it, and carries on.',
56
+ deniedNo: "The lead is told you won't run it now, and carries on with other work.",
57
+ sendReport:
58
+ "Files it on code-kit's GitHub under your account (a comment instead, if it's filed already).",
59
+ discardReport: "It's not sent, and goes.",
43
60
  };
44
61
 
62
+ /**
63
+ * CARD-8: one card per decision. A band line can stand for several (approvals waiting, held calls, the
64
+ * lead's questions, the brief's, Claude Code's refusals, reports); each becomes its own line here, with
65
+ * the decision it's about, so each card's options answer that one.
66
+ */
67
+ function eachDecision(line) {
68
+ if (line.kind === 'approvals' && line.asks?.length > 1)
69
+ return line.asks.map((a) => ({
70
+ ...line,
71
+ text: `Approve ${askWords(a.request, a.files)}`,
72
+ request: a.request,
73
+ files: a.files,
74
+ }));
75
+ if (line.kind === 'approvals')
76
+ return [{ ...line, text: `Approve ${askWords(line.request, line.files)}` }];
77
+ if (line.kind === 'held' && line.calls?.length > 1)
78
+ return line.calls.map((c) => ({ ...line, text: c.text, held: c.held }));
79
+ if (line.kind === 'question')
80
+ return (line.questions ?? [line.question]).map((q) => ({
81
+ ...line,
82
+ text: q.question,
83
+ question: q,
84
+ }));
85
+ if (line.kind === 'brief')
86
+ return (line.briefs ?? [line.brief]).map((b) => ({
87
+ ...line,
88
+ text: `From your brief: ${b.question}`,
89
+ brief: b,
90
+ }));
91
+ if (line.kind === 'denied')
92
+ return (line.denials ?? [line.denial]).map((d) => ({
93
+ ...line,
94
+ text: `Claude Code refused ${d.who}'s ${d.what}`,
95
+ denial: d,
96
+ actions: [
97
+ ...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
98
+ { id: 'deniedDone', label: 'Done it' },
99
+ { id: 'deniedNo', label: 'Not now' },
100
+ ],
101
+ }));
102
+ if (line.kind === 'report')
103
+ return (line.reports ?? [line.report]).map((r) => ({ ...line, text: r.title, report: r }));
104
+ return [line];
105
+ }
106
+
107
+ /** A card's options: the line's own actions, or, for the kinds a card answers differently, its own. */
108
+ function optionsOf(line) {
109
+ if (line.kind === 'approvals')
110
+ return [
111
+ { id: 'approve', label: 'Approve…' },
112
+ { id: 'dropRequest', label: 'Not needed' },
113
+ ];
114
+ // ASK-2: each of the lead's options answers it at once; any answer can be typed instead.
115
+ if (line.kind === 'question')
116
+ return [
117
+ ...line.question.options.map((o, i) => ({
118
+ id: `answerWith-${i}`,
119
+ act: 'answerWith',
120
+ label: o.label,
121
+ effect: o.effect,
122
+ line: { ...line, choice: o.label },
123
+ })),
124
+ { id: 'answer', label: line.question.options.length ? 'Something else…' : 'Answer…' },
125
+ ];
126
+ if (line.kind === 'brief')
127
+ return [
128
+ { id: 'answerBrief', label: 'Answer…' },
129
+ { id: 'laterBrief', label: 'Later' },
130
+ ];
131
+ if (line.kind === 'report')
132
+ return [
133
+ { id: 'sendReport', label: 'Send' },
134
+ { id: 'discardReport', label: 'Discard' },
135
+ ];
136
+ return line.actions;
137
+ }
138
+
45
139
  /** Why a line needs the person, when the line itself doesn't say. */
46
140
  function whyOf(line, check) {
47
141
  if (line.kind === 'held') {
@@ -75,6 +169,17 @@ function whyOf(line, check) {
75
169
  if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
76
170
  if (line.kind === 'teamDrift')
77
171
  return "The team's drift check found your work building on something that has since changed, or heading into someone else's: settle it before it merges.";
172
+ if (line.kind === 'question')
173
+ return (
174
+ line.question.why ??
175
+ "The lead can't settle this itself: it's yours to decide, and the lead builds on your answer."
176
+ );
177
+ if (line.kind === 'brief')
178
+ return `It's an open question in ${line.brief.path}: what gets built depends on your answer.`;
179
+ if (line.kind === 'denied')
180
+ return `Claude Code's own permission check refused it${line.denial.reason ? ` (${line.denial.reason})` : ''}, which code-kit can't approve past: only you can run it or allow it.`;
181
+ if (line.kind === 'report')
182
+ return "code-kit got in an agent's way. It's sent under your GitHub account to a public repository, so read what it says first.";
78
183
  return null;
79
184
  }
80
185
 
@@ -102,6 +207,7 @@ export function limitsWhy(outside) {
102
207
  function storyOf(line, state) {
103
208
  const stories = state?.stories ?? [];
104
209
  const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? null;
210
+ if (line.question?.story) return stories.find((s) => s.id === line.question.story) ?? null;
105
211
  if (line.story) return stories.find((s) => s.id === line.story.id) ?? line.story;
106
212
  if (line.stall?.story) return stories.find((s) => s.id === line.stall.story) ?? null;
107
213
  if (line.branch) return stories.find((s) => s.branch === line.branch) ?? null;
@@ -123,7 +229,8 @@ export function decisionCards(lines, context = {}) {
123
229
  const titles = new Map((status?.requirements ?? []).map((r) => [r.id, r.title]));
124
230
  return lines
125
231
  .filter((l) => DECISION_KINDS.has(l.kind) && !l.mission)
126
- .map((line, i) => {
232
+ .flatMap((source) => eachDecision(source).map((line) => ({ source, line })))
233
+ .map(({ source, line }, i) => {
127
234
  const story = storyOf(line, state);
128
235
  const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
129
236
  const settled = [
@@ -140,6 +247,8 @@ export function decisionCards(lines, context = {}) {
140
247
  id: `${line.kind}-${i}`,
141
248
  kind: line.kind,
142
249
  line,
250
+ // The band line it came from: one line can stand for several decisions (CARD-8).
251
+ source,
143
252
  title: line.text,
144
253
  asked: [request, goal].filter(Boolean).join(' · ') || null,
145
254
  story: story
@@ -151,15 +260,21 @@ export function decisionCards(lines, context = {}) {
151
260
  })),
152
261
  doing:
153
262
  line.held?.command ??
154
- line.request?.what ??
263
+ (line.request ? askWords(line.request, line.files ?? [line.request.what]) : null) ??
155
264
  (line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
156
265
  (line.intent
157
266
  ? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
158
267
  : null) ??
159
- (line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null),
268
+ (line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null) ??
269
+ (line.question?.blocks ? `${line.question.story} waits on your answer.` : null) ??
270
+ (line.denial ? (line.denial.command ?? line.denial.what) : null) ??
271
+ (line.report ? line.report.body : null),
160
272
  settled,
161
273
  why: whyOf(line, check),
162
- options: line.actions.map((a) => ({ ...a, effect: EFFECT[a.id] ?? null })),
274
+ options: optionsOf(line).map((a) => ({
275
+ ...a,
276
+ effect: a.effect ?? EFFECT[a.act ?? a.id] ?? null,
277
+ })),
163
278
  };
164
279
  });
165
280
  }
@@ -14,6 +14,55 @@ const LEAD_STEPS = new Set([
14
14
  'update',
15
15
  ]);
16
16
 
17
+ /** The steps that name stories, which an agent already at work on one shouldn't be asked to start again. */
18
+ const STORY_STEPS = new Set(['dispatch', 'review', 'lead', 'design']);
19
+ const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
+
21
+ /**
22
+ * LOOP-9: the stories agents are at work on now, by the task each was started with: a lane agent is
23
+ * started as "ST-12 booking form", a reviewer as "Review web/st-12". `code-kit next` reads only the
24
+ * branches, so it can't tell a branch still being built, or already under review, from one waiting.
25
+ */
26
+ export function storiesInFlight(agents) {
27
+ const ids = new Set();
28
+ for (const a of agents ?? []) {
29
+ if (!AT_WORK.has(a.status)) continue;
30
+ const text = `${a.description ?? ''}`;
31
+ for (const m of text.matchAll(/\bST-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
32
+ for (const m of text.matchAll(/\b[\w-]+\/st-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
33
+ }
34
+ return ids;
35
+ }
36
+
37
+ /**
38
+ * LOOP-9: the step from `code-kit next`, without the stories already in flight. A step left with none
39
+ * gives way to the next one `next` listed; with nothing left, the loop waits, saying for what, until an
40
+ * agent finishes (which moves the project on).
41
+ */
42
+ export function withoutInFlight(step, inFlight) {
43
+ if (!step || !inFlight?.size) return step;
44
+ const chain = [step, ...(step.then ?? [])].map(({ then: _then, ...s }) => s);
45
+ const kept = [];
46
+ const busy = [];
47
+ for (const s of chain) {
48
+ if (!STORY_STEPS.has(s.step)) {
49
+ kept.push(s);
50
+ continue;
51
+ }
52
+ const ids = (s.args ?? '').split(/\s+/).filter(Boolean);
53
+ const free = ids.filter((id) => !inFlight.has(id.toUpperCase()));
54
+ busy.push(...ids.filter((id) => inFlight.has(id.toUpperCase())));
55
+ if (free.length) kept.push({ ...s, args: free.join(' ') });
56
+ }
57
+ if (kept.length) return { ...kept[0], ...(kept.length > 1 ? { then: kept.slice(1) } : {}) };
58
+ const names = [...new Set(busy)];
59
+ return {
60
+ step: 'wait',
61
+ why: `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} still with ${names.length === 1 ? 'its agent' : 'their agents'}; the loop goes on when one finishes`,
62
+ inFlight: names,
63
+ };
64
+ }
65
+
17
66
  /**
18
67
  * The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
19
68
  * take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
@@ -43,6 +43,7 @@ const GLYPH = {
43
43
  'in review': '◐',
44
44
  queued: '◆',
45
45
  'sent back': '↩',
46
+ 'waiting on you': '?',
46
47
  blocked: '■',
47
48
  idle: '·',
48
49
  ready: '○',
@@ -64,6 +65,7 @@ export function countsOf(state, queue = []) {
64
65
  ['in review', n('in review')],
65
66
  ['queued', n('queued')],
66
67
  ['sent back', n('sent back')],
68
+ ['waiting on you', n('waiting on you')],
67
69
  ['blocked', n('blocked')],
68
70
  ['ready', state.ready.length],
69
71
  ['merged', (state.stories ?? []).filter((s) => s.state === 'done').length],
@@ -1338,7 +1340,7 @@ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
1338
1340
  label: o.label,
1339
1341
  ...(o.hotkey ? { hotkey: o.hotkey } : {}),
1340
1342
  variant: 'primary',
1341
- onPress: () => on.onNeed(o.id, card.line),
1343
+ onPress: () => on.onNeed(o.act ?? o.id, o.line ?? card.line),
1342
1344
  }),
1343
1345
  ...(o.effect ? [text(o.effect, { dimColor: true, wrap: 'wrap' })] : []),
1344
1346
  ],