@warren-dean/code-kit 0.11.1 → 0.12.1

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,140 @@
1
+ // Questions for the person (docs/specs/15-decisions-and-journal.md, ASK-1 to ASK-4): what the lead can't
2
+ // settle itself (a product choice, an account only the person can create, wording only they can sign off),
3
+ // asked through code-kit so it waits in the Decisions tab instead of scrolling away in the chat. A question
4
+ // may hold its story ("waiting on you") until it's answered. Kept in the main checkout, so every lane's
5
+ // worktree sees the same questions.
6
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { projectWorktrees } from './rules.mjs';
9
+
10
+ export const QUESTIONS_FILE = '.claude/state/questions.jsonl';
11
+
12
+ const fileOf = (root) => join(projectWorktrees(root)[0] ?? root, QUESTIONS_FILE);
13
+
14
+ function records(root) {
15
+ try {
16
+ return readFileSync(fileOf(root), 'utf8')
17
+ .split('\n')
18
+ .filter(Boolean)
19
+ .flatMap((l) => {
20
+ try {
21
+ return [JSON.parse(l)];
22
+ } catch {
23
+ return [];
24
+ }
25
+ });
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ function append(root, record) {
32
+ const file = fileOf(root);
33
+ mkdirSync(dirname(file), { recursive: true });
34
+ appendFileSync(file, `${JSON.stringify(record)}\n`);
35
+ }
36
+
37
+ /**
38
+ * Every question, oldest first: { id, at, by, question, why, story, blocks, options: [{ label, effect }],
39
+ * state: 'open' | 'answered' | 'withdrawn', answer: { text, at, by, via } | null, withdrawn: { at, reason } | null }.
40
+ */
41
+ export function readQuestions(root) {
42
+ const byId = new Map();
43
+ for (const r of records(root)) {
44
+ if (r.kind === 'ask')
45
+ byId.set(r.id, { ...r, kind: undefined, state: 'open', answer: null, withdrawn: null });
46
+ const q = byId.get(r.id);
47
+ if (!q || q.state !== 'open') continue;
48
+ if (r.kind === 'answer') {
49
+ q.state = 'answered';
50
+ q.answer = { text: r.answer, at: r.at, by: r.by, via: r.via };
51
+ }
52
+ if (r.kind === 'withdraw') {
53
+ q.state = 'withdrawn';
54
+ q.withdrawn = { at: r.at, reason: r.reason ?? null };
55
+ }
56
+ }
57
+ return [...byId.values()].map(({ kind, ...q }) => q);
58
+ }
59
+
60
+ export const openQuestions = (root) => readQuestions(root).filter((q) => q.state === 'open');
61
+
62
+ /** "Label: what it does" as an option; a label alone has no effect said. */
63
+ export function parseOption(text) {
64
+ const at = String(text).indexOf(':');
65
+ return at > 0
66
+ ? { label: text.slice(0, at).trim(), effect: text.slice(at + 1).trim() || null }
67
+ : { label: String(text).trim(), effect: null };
68
+ }
69
+
70
+ /**
71
+ * ASK-1: records a question for the person; the same question still open is asked once, and returned.
72
+ * `blocks` holds its story until it's answered (ASK-3).
73
+ */
74
+ export function askQuestion(
75
+ root,
76
+ { by, question, why = null, story = null, blocks = false, options = [] },
77
+ now = Date.now(),
78
+ ) {
79
+ const text = String(question).trim();
80
+ const open = openQuestions(root).find(
81
+ (q) => q.question === text && (q.story ?? null) === (story ?? null),
82
+ );
83
+ if (open) return { question: open, existed: true };
84
+ const n = readQuestions(root).length + 1;
85
+ const record = {
86
+ kind: 'ask',
87
+ id: `Q-${n}`,
88
+ at: new Date(now).toISOString(),
89
+ by,
90
+ question: text,
91
+ why: why?.trim() || null,
92
+ story: story ?? null,
93
+ blocks: Boolean(blocks && story),
94
+ options: options
95
+ .map((o) => (typeof o === 'string' ? parseOption(o) : o))
96
+ .filter((o) => o.label),
97
+ };
98
+ append(root, record);
99
+ return { question: readQuestions(root).find((q) => q.id === record.id), existed: false };
100
+ }
101
+
102
+ /** ASK-2: the person's answer, given in the pane (`via: 'pane'`) or in chat and recorded by the lead (`via: 'chat'`). */
103
+ export function answerQuestion(root, id, { answer, by, via }, now = Date.now()) {
104
+ const q = readQuestions(root).find((x) => x.id === id);
105
+ if (!q) return { ok: false, why: `There's no question ${id}. code-kit ask lists the open ones.` };
106
+ if (q.state !== 'open') return { ok: false, why: `${id} isn't open: it was ${q.state} already.` };
107
+ if (!String(answer ?? '').trim())
108
+ return { ok: false, why: 'Give the answer: the lead builds on it.' };
109
+ append(root, {
110
+ kind: 'answer',
111
+ id,
112
+ at: new Date(now).toISOString(),
113
+ answer: String(answer).trim(),
114
+ by,
115
+ via,
116
+ });
117
+ return { ok: true, question: readQuestions(root).find((x) => x.id === id) };
118
+ }
119
+
120
+ /** The lead takes a question back once it no longer needs the person (it was settled another way). */
121
+ export function withdrawQuestion(root, id, reason, now = Date.now()) {
122
+ const q = readQuestions(root).find((x) => x.id === id);
123
+ if (!q) return { ok: false, why: `There's no question ${id}.` };
124
+ if (q.state !== 'open') return { ok: false, why: `${id} isn't open: it was ${q.state} already.` };
125
+ append(root, {
126
+ kind: 'withdraw',
127
+ id,
128
+ at: new Date(now).toISOString(),
129
+ reason: reason?.trim() || null,
130
+ });
131
+ return { ok: true };
132
+ }
133
+
134
+ /** ASK-3: the stories an open question holds: Map(story id → the question). */
135
+ export function waitingStories(root) {
136
+ const out = new Map();
137
+ for (const q of openQuestions(root))
138
+ if (q.blocks && q.story && !out.has(q.story)) out.set(q.story, q);
139
+ return out;
140
+ }
@@ -0,0 +1,246 @@
1
+ // Bug reports for code-kit itself (docs/specs/15-decisions-and-journal.md, REPORT-1 to REPORT-3). With
2
+ // `harness.report.on`, an agent that code-kit got in the way of (a refusal that's wrong, a crash, a pane
3
+ // showing the wrong thing) drafts a report. The draft carries code-kit's own facts only (its version, the
4
+ // rule, the refusal, the command, what was expected), with the project's paths, home folder and email
5
+ // addresses taken out, and waits for the person: they read the exact text and send it, or discard it.
6
+ // Sending files a GitHub issue on code-kit's repository through their own `gh`, or, without it, gives
7
+ // them the new-issue link to open; a report someone already filed gets a comment instead of a second issue.
8
+ import { createHash } from 'node:crypto';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
11
+ import { homedir, platform, release } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { projectWorktrees } from './rules.mjs';
15
+
16
+ export const REPORTS_FILE = '.claude/state/reports.jsonl';
17
+ const PLUGIN = JSON.parse(
18
+ readFileSync(fileURLToPath(new URL('../../.claude-plugin/plugin.json', import.meta.url)), 'utf8'),
19
+ );
20
+ /** code-kit's own repository, where reports go: `owner/name`. */
21
+ export const REPO = PLUGIN.repository.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '');
22
+
23
+ const fileOf = (root) => join(projectWorktrees(root)[0] ?? root, REPORTS_FILE);
24
+
25
+ function records(root) {
26
+ try {
27
+ return readFileSync(fileOf(root), 'utf8')
28
+ .split('\n')
29
+ .filter(Boolean)
30
+ .flatMap((l) => {
31
+ try {
32
+ return [JSON.parse(l)];
33
+ } catch {
34
+ return [];
35
+ }
36
+ });
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ const append = (root, record) => {
43
+ const file = fileOf(root);
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ appendFileSync(file, `${JSON.stringify(record)}\n`);
46
+ };
47
+
48
+ /**
49
+ * REPORT-2: text as it may leave the machine: the project's folders, the home folder and email
50
+ * addresses replaced, and cut to `max` characters.
51
+ */
52
+ export function scrub(text, roots = [], max = 1500) {
53
+ let out = String(text ?? '');
54
+ const home = homedir();
55
+ for (const r of [...roots].filter(Boolean).sort((a, b) => b.length - a.length))
56
+ out = out.split(r).join('<project>');
57
+ if (home) out = out.split(home).join('~');
58
+ out = out.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>');
59
+ return out.length > max ? `${out.slice(0, max)}…` : out;
60
+ }
61
+
62
+ /** REPORT-3: what makes two reports the same bug: the title's words and the rule, as a short hash. */
63
+ export const fingerprint = (title, rule) =>
64
+ `ck-${createHash('sha1')
65
+ .update(
66
+ `${String(title)
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, ' ')
69
+ .trim()}|${rule ?? ''}`,
70
+ )
71
+ .digest('hex')
72
+ .slice(0, 8)}`;
73
+
74
+ /** The issue's body, as the person will read it before it's sent. */
75
+ function bodyOf(r) {
76
+ return [
77
+ `**What happened:** ${r.what}`,
78
+ ...(r.expected ? [`**Expected:** ${r.expected}`] : []),
79
+ ...(r.command ? ['**Command or call:**', '```', r.command, '```'] : []),
80
+ ...(r.refusal ? ['**What code-kit said:**', '```', r.refusal, '```'] : []),
81
+ ...(r.rule ? [`**Rule:** ${r.rule}`] : []),
82
+ `**code-kit:** ${r.version} · Node ${r.node} · ${r.os}`,
83
+ '',
84
+ `<sub>Drafted by a code-kit agent, read and sent by a person. Fingerprint: ${r.fingerprint}</sub>`,
85
+ ].join('\n');
86
+ }
87
+
88
+ /**
89
+ * Every report, oldest first: { id, at, by, title, body, fingerprint, state: 'draft' | 'sent' | 'discarded',
90
+ * sent: { at, url, comment } | null }.
91
+ */
92
+ export function readReports(root) {
93
+ const byId = new Map();
94
+ for (const r of records(root)) {
95
+ if (r.kind === 'draft') byId.set(r.id, { ...r, state: 'draft', sent: null });
96
+ const d = byId.get(r.id);
97
+ if (!d || d.state !== 'draft') continue;
98
+ if (r.kind === 'sent')
99
+ Object.assign(d, {
100
+ state: 'sent',
101
+ sent: { at: r.at, url: r.url, comment: Boolean(r.comment) },
102
+ });
103
+ if (r.kind === 'discarded') d.state = 'discarded';
104
+ }
105
+ return [...byId.values()].map(({ kind, ...r }) => r);
106
+ }
107
+
108
+ export const draftReports = (root) => readReports(root).filter((r) => r.state === 'draft');
109
+
110
+ /**
111
+ * REPORT-1: a draft from an agent: { title, what, expected, command, refusal, rule }. The same bug still
112
+ * waiting as a draft is drafted once.
113
+ */
114
+ export function draftReport(root, input, { by = 'lead', now = Date.now() } = {}) {
115
+ // Each checkout as written and as the system resolves it (/var and /private/var on macOS).
116
+ const roots = [
117
+ ...new Set(
118
+ [...projectWorktrees(root), root].flatMap((r) => {
119
+ let real = r;
120
+ try {
121
+ real = realpathSync(r);
122
+ } catch {
123
+ // gone: as written
124
+ }
125
+ return [r, real, real.replace(/^\/private(?=\/)/, '')];
126
+ }),
127
+ ),
128
+ ];
129
+ const clean = (v, max) => (v ? scrub(v, roots, max) : null);
130
+ const title = clean(input.title, 120);
131
+ if (!title?.trim())
132
+ return { ok: false, why: 'Give the report a title: what went wrong, in a few words.' };
133
+ if (!input.what?.trim()) return { ok: false, why: 'Say what happened, with --what "…".' };
134
+ const fp = fingerprint(title, input.rule);
135
+ const existing = draftReports(root).find((r) => r.fingerprint === fp);
136
+ if (existing) return { ok: true, report: existing, existed: true };
137
+ const r = {
138
+ title: title.trim(),
139
+ what: clean(input.what, 1500),
140
+ expected: clean(input.expected, 600),
141
+ command: clean(input.command, 600),
142
+ refusal: clean(input.refusal, 1500),
143
+ rule: clean(input.rule, 120),
144
+ version: PLUGIN.version,
145
+ node: process.version,
146
+ os: `${platform()} ${release()}`,
147
+ fingerprint: fp,
148
+ };
149
+ const record = {
150
+ kind: 'draft',
151
+ id: `R-${readReports(root).length + 1}`,
152
+ at: new Date(now).toISOString(),
153
+ by,
154
+ title: r.title,
155
+ fingerprint: fp,
156
+ body: bodyOf(r),
157
+ };
158
+ append(root, record);
159
+ return { ok: true, report: readReports(root).find((x) => x.id === record.id), existed: false };
160
+ }
161
+
162
+ /** The new-issue link, for someone without `gh`: the draft filled in, for them to submit. */
163
+ export const issueLink = (r) =>
164
+ `https://github.com/${REPO}/issues/new?title=${encodeURIComponent(r.title)}&body=${encodeURIComponent(r.body)}`;
165
+
166
+ const gh = (args) =>
167
+ execFileSync('gh', args, {
168
+ encoding: 'utf8',
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ timeout: 60000,
171
+ }).trim();
172
+
173
+ /**
174
+ * REPORT-3: the person sends a draft: a comment on the open issue with its fingerprint, if someone filed
175
+ * it already, otherwise a new issue; without a working `gh`, nothing is sent and the link is returned.
176
+ */
177
+ export function sendReport(root, id, now = Date.now()) {
178
+ const r = readReports(root).find((x) => x.id === id);
179
+ if (!r) return { ok: false, why: `There's no report ${id}.` };
180
+ if (r.state !== 'draft') return { ok: false, why: `${id} was ${r.state} already.` };
181
+ let url;
182
+ let comment = false;
183
+ try {
184
+ gh(['auth', 'status']);
185
+ } catch {
186
+ return {
187
+ ok: false,
188
+ link: issueLink(r),
189
+ why: "gh isn't installed or signed in, so nothing was sent.",
190
+ };
191
+ }
192
+ try {
193
+ const found = JSON.parse(
194
+ gh([
195
+ 'issue',
196
+ 'list',
197
+ '--repo',
198
+ REPO,
199
+ '--state',
200
+ 'open',
201
+ '--search',
202
+ `${r.fingerprint} in:body`,
203
+ '--json',
204
+ 'number,url',
205
+ '--limit',
206
+ '1',
207
+ ]) || '[]',
208
+ );
209
+ if (found.length) {
210
+ gh([
211
+ 'issue',
212
+ 'comment',
213
+ String(found[0].number),
214
+ '--repo',
215
+ REPO,
216
+ '--body',
217
+ `Seen again, on code-kit ${PLUGIN.version}.\n\n${r.body}`,
218
+ ]);
219
+ url = found[0].url;
220
+ comment = true;
221
+ } else
222
+ url = gh(['issue', 'create', '--repo', REPO, '--title', r.title, '--body', r.body])
223
+ .split('\n')
224
+ .at(-1);
225
+ } catch (err) {
226
+ return {
227
+ ok: false,
228
+ link: issueLink(r),
229
+ why: `GitHub didn't take it (${
230
+ String(err.stderr || err.message)
231
+ .trim()
232
+ .split('\n')[0]
233
+ }).`,
234
+ };
235
+ }
236
+ append(root, { kind: 'sent', id, at: new Date(now).toISOString(), url, comment });
237
+ return { ok: true, url, comment };
238
+ }
239
+
240
+ export function discardReport(root, id, now = Date.now()) {
241
+ const r = readReports(root).find((x) => x.id === id);
242
+ if (!r) return { ok: false, why: `There's no report ${id}.` };
243
+ if (r.state !== 'draft') return { ok: false, why: `${id} was ${r.state} already.` };
244
+ append(root, { kind: 'discarded', id, at: new Date(now).toISOString() });
245
+ return { ok: true };
246
+ }
@@ -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;
@@ -315,8 +317,8 @@ const sameRequest = (a, b) =>
315
317
  a.what === b.what &&
316
318
  [...a.names].sort().join() === [...b.names].sort().join();
317
319
 
318
- /** Every request recorded, oldest first; unreadable lines are skipped. */
319
- export function readRequests(root) {
320
+ /** Every line of the requests file: the requests, and the drops (`{ drop: true, of }`). */
321
+ function requestRecords(root) {
320
322
  try {
321
323
  return readFileSync(requestsFile(root), 'utf8')
322
324
  .split('\n')
@@ -333,10 +335,40 @@ export function readRequests(root) {
333
335
  }
334
336
  }
335
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
+
336
368
  /**
337
369
  * Records that `actor` was refused something the approvals `names` would allow, once: the same
338
- * actor, names and command or file within the approval lifetime count as one request. Recording
339
- * 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.
340
372
  */
341
373
  export function recordRequest(root, actor, names, what, why, now = Date.now()) {
342
374
  // A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
@@ -351,10 +383,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
351
383
  what: String(what).slice(0, 200),
352
384
  why: String(why).slice(0, 300),
353
385
  };
354
- const fresh = readRequests(root).filter(
355
- (r) => now - Date.parse(r.at) < APPROVAL_MINUTES * 60_000,
356
- );
357
- if (fresh.some((r) => sameRequest(r, request))) return;
386
+ if (openRequests(root, now).some((r) => sameRequest(r, request))) return;
358
387
  const file = requestsFile(root);
359
388
  mkdirSync(dirname(file), { recursive: true });
360
389
  appendFileSync(file, `${JSON.stringify(request)}\n`);
@@ -363,13 +392,21 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
363
392
  }
364
393
  }
365
394
 
366
- /** 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
+ */
367
399
  export function openRequests(root, now = Date.now()) {
368
400
  const open = [];
369
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 = [];
370
405
  for (const r of readRequests(root).reverse()) {
371
- if (now - Date.parse(r.at) >= APPROVAL_MINUTES * 60_000) continue;
372
- 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;
373
410
  // From before read-only agents stopped asking: only the lead and the lanes are ever approved.
374
411
  if (!r.lane && r.actor !== 'lead session') continue;
375
412
  const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
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';
@@ -18,6 +18,10 @@ export const DECISION_KINDS = new Set([
18
18
  'teamIntent',
19
19
  'teamReview',
20
20
  'teamDrift',
21
+ 'question',
22
+ 'brief',
23
+ 'denied',
24
+ 'report',
21
25
  ]);
22
26
 
23
27
  /** What each answer does, in the person's words. */
@@ -41,8 +45,97 @@ const EFFECT = {
41
45
  'The issue closes as agreed, with your words; then put it in the specs or decisions so every agent builds on it.',
42
46
  dropIntent: 'The issue closes as dropped, and the team carries on as agreed before.',
43
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.",
44
60
  };
45
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
+
46
139
  /** Why a line needs the person, when the line itself doesn't say. */
47
140
  function whyOf(line, check) {
48
141
  if (line.kind === 'held') {
@@ -76,6 +169,17 @@ function whyOf(line, check) {
76
169
  if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
77
170
  if (line.kind === 'teamDrift')
78
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.";
79
183
  return null;
80
184
  }
81
185
 
@@ -103,6 +207,7 @@ export function limitsWhy(outside) {
103
207
  function storyOf(line, state) {
104
208
  const stories = state?.stories ?? [];
105
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;
106
211
  if (line.story) return stories.find((s) => s.id === line.story.id) ?? line.story;
107
212
  if (line.stall?.story) return stories.find((s) => s.id === line.stall.story) ?? null;
108
213
  if (line.branch) return stories.find((s) => s.branch === line.branch) ?? null;
@@ -124,7 +229,8 @@ export function decisionCards(lines, context = {}) {
124
229
  const titles = new Map((status?.requirements ?? []).map((r) => [r.id, r.title]));
125
230
  return lines
126
231
  .filter((l) => DECISION_KINDS.has(l.kind) && !l.mission)
127
- .map((line, i) => {
232
+ .flatMap((source) => eachDecision(source).map((line) => ({ source, line })))
233
+ .map(({ source, line }, i) => {
128
234
  const story = storyOf(line, state);
129
235
  const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
130
236
  const settled = [
@@ -141,6 +247,8 @@ export function decisionCards(lines, context = {}) {
141
247
  id: `${line.kind}-${i}`,
142
248
  kind: line.kind,
143
249
  line,
250
+ // The band line it came from: one line can stand for several decisions (CARD-8).
251
+ source,
144
252
  title: line.text,
145
253
  asked: [request, goal].filter(Boolean).join(' · ') || null,
146
254
  story: story
@@ -157,10 +265,16 @@ export function decisionCards(lines, context = {}) {
157
265
  (line.intent
158
266
  ? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
159
267
  : null) ??
160
- (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),
161
272
  settled,
162
273
  why: whyOf(line, check),
163
- 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
+ })),
164
278
  };
165
279
  });
166
280
  }