@warren-dean/code-kit 0.11.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +14 -5
- package/bin/code-kit.mjs +164 -4
- package/hooks/guard-bash.mjs +17 -0
- package/hooks/lib/harness.mjs +6 -0
- package/hooks/lib/next.mjs +19 -2
- package/hooks/lib/plan.mjs +16 -4
- package/hooks/lib/questions.mjs +140 -0
- package/hooks/lib/report.mjs +246 -0
- package/hooks/lib/rules.mjs +48 -11
- package/hooks/mod/art.mjs +2 -2
- package/hooks/mod/decisions.mjs +117 -3
- package/hooks/mod/panes.mjs +3 -1
- package/hooks/mod/register.mjs +183 -1
- package/hooks/mod/register.test.ts +284 -3
- package/hooks/mod/view.mjs +114 -2
- package/package.json +1 -1
- package/skills/init/SKILL.md +2 -1
- package/skills/next/SKILL.md +14 -0
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +3 -0
package/hooks/mod/panes.mjs
CHANGED
|
@@ -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
|
],
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// the lead to review, and merging (`merge --person`). No agent reaches those: they are the mod's own
|
|
9
9
|
// buttons, and the hooks refuse both commands from every agent.
|
|
10
10
|
import {
|
|
11
|
+
ANSWER_ID,
|
|
11
12
|
APPROVE_ID,
|
|
12
13
|
NOT_CODE_KIT,
|
|
13
14
|
PANE_ID,
|
|
@@ -17,7 +18,9 @@ import {
|
|
|
17
18
|
agentsAtWork,
|
|
18
19
|
shownState,
|
|
19
20
|
attribution,
|
|
21
|
+
answerPane,
|
|
20
22
|
approvePane,
|
|
23
|
+
commandItself,
|
|
21
24
|
band,
|
|
22
25
|
bandLines,
|
|
23
26
|
finishedStories,
|
|
@@ -89,6 +92,9 @@ let ledger = {}; // the session's tokens by agent and story (USE-1)
|
|
|
89
92
|
let rateLimits = []; // the session's limits, as session.measure last gave them
|
|
90
93
|
const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
|
|
91
94
|
const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
|
|
95
|
+
const denied = new Map(); // tool_use_id → a call Claude Code's own permission check refused (PERM-1)
|
|
96
|
+
const briefLater = new Set(); // the brief's questions the person put off this session (ASK-4)
|
|
97
|
+
let answering = null; // the open Answer… box: { kind: 'question' | 'brief', question, id?, path?, text, error }
|
|
92
98
|
let where = null; // { cli, cwd, interactive }, from session start
|
|
93
99
|
// The lead loop (spec 05): what it did, and what it waits on.
|
|
94
100
|
const loop = {
|
|
@@ -207,6 +213,8 @@ export function register(on) {
|
|
|
207
213
|
const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
|
|
208
214
|
return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
209
215
|
};
|
|
216
|
+
// What a CLI run said, in a line: why it refused, for a notice.
|
|
217
|
+
const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
210
218
|
// A session starts with the loop running: a pause recorded by an earlier one no longer holds.
|
|
211
219
|
if ((await json('loop').catch(() => null))?.paused)
|
|
212
220
|
await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
|
|
@@ -246,6 +254,13 @@ export function register(on) {
|
|
|
246
254
|
: [];
|
|
247
255
|
// TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
|
|
248
256
|
const team = valid && check.team ? await json('team') : null;
|
|
257
|
+
// ASK-2: the lead's questions waiting for the person; REPORT-3: bug reports waiting to be sent.
|
|
258
|
+
const questions = valid
|
|
259
|
+
? ((await json('ask')) ?? []).filter((q) => q.state === 'open')
|
|
260
|
+
: [];
|
|
261
|
+
const reports = valid
|
|
262
|
+
? ((await json('report')) ?? []).filter((r) => r.state === 'draft')
|
|
263
|
+
: [];
|
|
249
264
|
// TEAM-12: the plan mirrored onto the team's Project board, at most every 10 minutes, in the
|
|
250
265
|
// background: a slow GitHub never holds up the band.
|
|
251
266
|
const at = await $.clock.now();
|
|
@@ -265,6 +280,10 @@ export function register(on) {
|
|
|
265
280
|
adapters,
|
|
266
281
|
check,
|
|
267
282
|
team,
|
|
283
|
+
questions,
|
|
284
|
+
reports,
|
|
285
|
+
// ASK-4: the brief's open questions, less those put off this session.
|
|
286
|
+
brief: (nextStep?.brief ?? []).filter((b) => !briefLater.has(b.question)),
|
|
268
287
|
base: status?.base ?? null,
|
|
269
288
|
};
|
|
270
289
|
if (model.state.kind === 'ok') await act.registerReviewer();
|
|
@@ -473,6 +492,8 @@ export function register(on) {
|
|
|
473
492
|
$.ui.invalidate('ui.render');
|
|
474
493
|
return {};
|
|
475
494
|
},
|
|
495
|
+
// ASK-2, ASK-4, REPORT-3: a question's Answer… or a report's Read it opens its card.
|
|
496
|
+
openDecision: (line) => act.decide(line),
|
|
476
497
|
// ACT-1: the confirmation, prefilled from the request.
|
|
477
498
|
approve: async (line) => {
|
|
478
499
|
approving = {
|
|
@@ -521,6 +542,124 @@ export function register(on) {
|
|
|
521
542
|
await $.ui.close({ id: APPROVE_ID });
|
|
522
543
|
await act.reload();
|
|
523
544
|
},
|
|
545
|
+
// CARD-7: a request nobody needs any more stops waiting; nothing is approved.
|
|
546
|
+
dropRequest: async (line) => {
|
|
547
|
+
const open = model.requests?.open ?? [];
|
|
548
|
+
const same = (r) =>
|
|
549
|
+
[...r.names].sort().join() === [...line.request.names].sort().join() &&
|
|
550
|
+
(r.lane ?? r.actor) === (line.request.lane ?? line.request.actor);
|
|
551
|
+
const numbers = open.flatMap((r, i) => (same(r) ? [i + 1] : []));
|
|
552
|
+
if (!numbers.length) return;
|
|
553
|
+
const ran = await $.process.run(
|
|
554
|
+
['node', cli, 'requests', 'drop', numbers.join(','), '--via', 'pane'],
|
|
555
|
+
{ cwd },
|
|
556
|
+
);
|
|
557
|
+
notice = ran.exitCode === 0 ? null : `Nothing was dropped: ${firstLine(ran)}`;
|
|
558
|
+
await act.reload();
|
|
559
|
+
},
|
|
560
|
+
// ASK-2: the lead's option, pressed: the answer is recorded and sent to the lead.
|
|
561
|
+
answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
|
|
562
|
+
// ASK-2, ASK-4: Answer… opens a box for the person's own words.
|
|
563
|
+
answer: async (line) => {
|
|
564
|
+
answering = { kind: 'question', ...line.question, text: '', error: null };
|
|
565
|
+
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
566
|
+
$.ui.invalidate('ui.render');
|
|
567
|
+
},
|
|
568
|
+
answerBrief: async (line) => {
|
|
569
|
+
answering = { kind: 'brief', ...line.brief, text: '', error: null };
|
|
570
|
+
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
571
|
+
$.ui.invalidate('ui.render');
|
|
572
|
+
},
|
|
573
|
+
laterBrief: (line) => {
|
|
574
|
+
briefLater.add(line.brief.question);
|
|
575
|
+
model = {
|
|
576
|
+
...model,
|
|
577
|
+
brief: (model.brief ?? []).filter((b) => b.question !== line.brief.question),
|
|
578
|
+
};
|
|
579
|
+
$.ui.invalidate('ui.render');
|
|
580
|
+
},
|
|
581
|
+
confirmAnswer: async (text) => {
|
|
582
|
+
if (!answering) return;
|
|
583
|
+
if (!text.trim()) {
|
|
584
|
+
answering = { ...answering, error: 'Type your answer: the lead builds on it.' };
|
|
585
|
+
$.ui.invalidate('ui.render');
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const done = await act.sendAnswer(answering, text.trim());
|
|
589
|
+
if (done) {
|
|
590
|
+
answering = null;
|
|
591
|
+
await $.ui.close({ id: ANSWER_ID });
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
cancelAnswer: async () => {
|
|
595
|
+
answering = null;
|
|
596
|
+
await $.ui.close({ id: ANSWER_ID });
|
|
597
|
+
},
|
|
598
|
+
// The answer recorded (a question's, in code-kit) and told to the lead, which carries on with it.
|
|
599
|
+
sendAnswer: async (q, text) => {
|
|
600
|
+
if (q.kind === 'question') {
|
|
601
|
+
const ran = await $.process.run(
|
|
602
|
+
['node', cli, 'ask', 'answer', q.id, text, '--via', 'pane'],
|
|
603
|
+
{ cwd },
|
|
604
|
+
);
|
|
605
|
+
if (ran.exitCode !== 0) {
|
|
606
|
+
notice = `Your answer wasn't recorded: ${firstLine(ran)}`;
|
|
607
|
+
if (answering) answering = { ...answering, error: notice };
|
|
608
|
+
$.ui.invalidate('ui.render');
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
notice = null;
|
|
613
|
+
const said = /[.!?]$/.test(text) ? text : `${text}.`;
|
|
614
|
+
await $.prompt.submit({
|
|
615
|
+
text:
|
|
616
|
+
q.kind === 'question'
|
|
617
|
+
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
618
|
+
: `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
|
|
619
|
+
});
|
|
620
|
+
if (q.kind === 'brief') {
|
|
621
|
+
briefLater.add(q.question);
|
|
622
|
+
model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
|
|
623
|
+
}
|
|
624
|
+
await act.reload();
|
|
625
|
+
return true;
|
|
626
|
+
},
|
|
627
|
+
// PERM-1: a call Claude Code refused, which only the person can run or allow.
|
|
628
|
+
runDenied: async (line) => {
|
|
629
|
+
await $.prompt.fill({ text: `! ${line.denial.command}`, mode: 'replace' }).catch(() => {});
|
|
630
|
+
},
|
|
631
|
+
deniedDone: async (line) => {
|
|
632
|
+
denied.delete(line.denial.id);
|
|
633
|
+
$.ui.invalidate('ui.render');
|
|
634
|
+
await $.prompt.submit({
|
|
635
|
+
text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
deniedNo: async (line) => {
|
|
639
|
+
denied.delete(line.denial.id);
|
|
640
|
+
$.ui.invalidate('ui.render');
|
|
641
|
+
await $.prompt.submit({
|
|
642
|
+
text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
// REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
|
|
646
|
+
sendReport: async (line) => {
|
|
647
|
+
const ran = await $.process.run(['node', cli, 'report', 'send', line.report.id], {
|
|
648
|
+
cwd,
|
|
649
|
+
timeoutMs: 120000,
|
|
650
|
+
});
|
|
651
|
+
result = {
|
|
652
|
+
ok: ran.exitCode === 0,
|
|
653
|
+
title: ran.exitCode === 0 ? 'Report sent' : 'Report not sent',
|
|
654
|
+
text: `${ran.stdout}${ran.stderr}`.trim(),
|
|
655
|
+
};
|
|
656
|
+
await $.ui.open({ id: RESULT_ID, title: 'Report', focus: true, closeOnEscape: true });
|
|
657
|
+
await act.reload();
|
|
658
|
+
},
|
|
659
|
+
discardReport: async (line) => {
|
|
660
|
+
await $.process.run(['node', cli, 'report', 'discard', line.report.id], { cwd });
|
|
661
|
+
await act.reload();
|
|
662
|
+
},
|
|
524
663
|
// HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
|
|
525
664
|
approveHeld: (line) =>
|
|
526
665
|
act.approve({
|
|
@@ -1205,6 +1344,35 @@ export function register(on) {
|
|
|
1205
1344
|
// CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
|
|
1206
1345
|
// HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
|
|
1207
1346
|
// stay the judge: the call is run again once the approval is written, and refused as before otherwise.
|
|
1347
|
+
// PERM-1: Claude Code's own permission check refused a call (auto mode, a deny rule): code-kit can't
|
|
1348
|
+
// approve past it, so it waits in the band for the person, who can run it themselves or allow it.
|
|
1349
|
+
on('classic.PermissionDenied', async ($, e, next) => {
|
|
1350
|
+
const res = await next(e);
|
|
1351
|
+
const input = e.tool_input && typeof e.tool_input === 'object' ? e.tool_input : {};
|
|
1352
|
+
const command =
|
|
1353
|
+
e.tool_name === 'Bash' && typeof input.command === 'string' ? input.command : null;
|
|
1354
|
+
const path = input.file_path ?? input.notebook_path ?? null;
|
|
1355
|
+
const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
|
|
1356
|
+
denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
|
|
1357
|
+
id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
|
|
1358
|
+
who: lane ? `the ${lane} lane` : (e.agent_type ?? (e.agent_id ? 'an agent' : 'the lead')),
|
|
1359
|
+
tool: e.tool_name,
|
|
1360
|
+
command,
|
|
1361
|
+
what: command
|
|
1362
|
+
? `\`${commandItself(command)}\``
|
|
1363
|
+
: path
|
|
1364
|
+
? `${e.tool_name} on ${path}`
|
|
1365
|
+
: e.tool_name,
|
|
1366
|
+
reason:
|
|
1367
|
+
typeof e.reason === 'string'
|
|
1368
|
+
? e.reason.trim().replace(/\s+/g, ' ').slice(0, 200) || null
|
|
1369
|
+
: null,
|
|
1370
|
+
at: await $.clock.now(),
|
|
1371
|
+
});
|
|
1372
|
+
$.ui.invalidate('ui.render');
|
|
1373
|
+
return res;
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1208
1376
|
on('tool.call', async ($, e, next) => {
|
|
1209
1377
|
// LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
|
|
1210
1378
|
const calledAt = await $.clock.now();
|
|
@@ -1583,7 +1751,7 @@ export function register(on) {
|
|
|
1583
1751
|
const lines = linesNowRaw(now);
|
|
1584
1752
|
const cards = cardsNow(now, lines);
|
|
1585
1753
|
return lines.map((l) => {
|
|
1586
|
-
const card = cards.find((c) => c.
|
|
1754
|
+
const card = cards.find((c) => c.source === l);
|
|
1587
1755
|
return card ? { ...l, context: contextOf(card) } : l;
|
|
1588
1756
|
});
|
|
1589
1757
|
};
|
|
@@ -1601,6 +1769,7 @@ export function register(on) {
|
|
|
1601
1769
|
nextStep: loop.nextStep,
|
|
1602
1770
|
combined: combinedNow(),
|
|
1603
1771
|
team: model.team ?? null,
|
|
1772
|
+
denied: [...denied.values()],
|
|
1604
1773
|
});
|
|
1605
1774
|
|
|
1606
1775
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
@@ -1768,6 +1937,19 @@ export function register(on) {
|
|
|
1768
1937
|
});
|
|
1769
1938
|
});
|
|
1770
1939
|
|
|
1940
|
+
// ASK-2, ASK-4: the person's answer, in their own words.
|
|
1941
|
+
on('ui.render', { component: 'Pane', requestId: ANSWER_ID }, async ($, e) => {
|
|
1942
|
+
if (!answering) return resultPane(null, $.ui.resolve(e));
|
|
1943
|
+
return answerPane(answering, $.ui.resolve(e), {
|
|
1944
|
+
onInput: (value) => {
|
|
1945
|
+
answering = { ...answering, text: value };
|
|
1946
|
+
$.ui.invalidate('ui.render');
|
|
1947
|
+
},
|
|
1948
|
+
onSubmit: (value) => act.confirmAnswer(value),
|
|
1949
|
+
onCancel: () => act.cancelAnswer(),
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1771
1953
|
on('ui.render', { component: 'Pane', requestId: SETTINGS_ID }, async ($, e) =>
|
|
1772
1954
|
settingsView(settingRows, pendingSetting, $.ui.resolve(e), {
|
|
1773
1955
|
onChoose: (key, value) => act.chooseSetting(key, value),
|
|
@@ -157,6 +157,9 @@ function project() {
|
|
|
157
157
|
journal: { events: [] as any[] }, // `code-kit journal --json`
|
|
158
158
|
team: null as any, // `code-kit team --json`, for a team's project
|
|
159
159
|
teamSince: { people: {} } as any, // `code-kit team since --json`
|
|
160
|
+
questions: [] as any[], // `code-kit ask --json`
|
|
161
|
+
reports: [] as any[], // `code-kit report --json`
|
|
162
|
+
fills: [] as string[], // what the mod put in the prompt
|
|
160
163
|
holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
|
|
161
164
|
waiting: new Map<string, (answer: string) => void>(),
|
|
162
165
|
};
|
|
@@ -186,7 +189,30 @@ function stub(on: any, w: World) {
|
|
|
186
189
|
}),
|
|
187
190
|
);
|
|
188
191
|
if (sub === 'next') return ran(0, JSON.stringify(w.next));
|
|
192
|
+
if (sub === 'requests' && argv[3] === 'drop') {
|
|
193
|
+
w.acts.push([...argv.slice(2)]);
|
|
194
|
+
const drop = new Set(argv[4].split(',').map(Number));
|
|
195
|
+
w.requests = w.requests.filter((_, i) => !drop.has(i + 1));
|
|
196
|
+
return ran(0, 'Dropped.');
|
|
197
|
+
}
|
|
189
198
|
if (sub === 'requests') return ran(0, JSON.stringify({ open: w.requests, inForce: w.inForce }));
|
|
199
|
+
if (sub === 'ask' && argv[3] === 'answer') {
|
|
200
|
+
w.acts.push([...argv.slice(2)]);
|
|
201
|
+
w.questions = w.questions.map((q) => (q.id === argv[4] ? { ...q, state: 'answered' } : q));
|
|
202
|
+
return ran(0, 'Recorded.');
|
|
203
|
+
}
|
|
204
|
+
if (sub === 'ask') return ran(0, JSON.stringify(w.questions));
|
|
205
|
+
if (sub === 'report' && ['send', 'discard'].includes(argv[3])) {
|
|
206
|
+
w.acts.push([...argv.slice(2)]);
|
|
207
|
+
w.reports = w.reports.map((r) =>
|
|
208
|
+
r.id === argv[4] ? { ...r, state: argv[3] === 'send' ? 'sent' : 'discarded' } : r,
|
|
209
|
+
);
|
|
210
|
+
return ran(
|
|
211
|
+
0,
|
|
212
|
+
argv[3] === 'send' ? 'Filed https://github.com/o/code-kit/issues/9' : 'Discarded.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (sub === 'report') return ran(0, JSON.stringify(w.reports));
|
|
190
216
|
if (sub === 'verify')
|
|
191
217
|
return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
|
|
192
218
|
if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
|
|
@@ -321,6 +347,13 @@ function stub(on: any, w: World) {
|
|
|
321
347
|
return { value: { agent: `code-kit:${e.name}` } };
|
|
322
348
|
});
|
|
323
349
|
on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
|
|
350
|
+
// Claude Code's own settings hooks beneath: none answer a permission denial.
|
|
351
|
+
on('classic.PermissionDenied', () => ({}));
|
|
352
|
+
on('prompt.fill', ($: any, e: any) => {
|
|
353
|
+
w.fills.push(e.text);
|
|
354
|
+
w.draft = e.text;
|
|
355
|
+
return { isFilled: true };
|
|
356
|
+
});
|
|
324
357
|
on('session.send', ($: any, e: any) => {
|
|
325
358
|
w.sent.push({ to: e.to, text: e.text });
|
|
326
359
|
return { isDelivered: true };
|
|
@@ -513,10 +546,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
|
|
|
513
546
|
const before = w.runs;
|
|
514
547
|
await clock.advance(8000); // four quiet ticks: git only
|
|
515
548
|
expect(w.runs - before).toBe(4);
|
|
516
|
-
// The fifth reads it all (check, status, next, requests, stops, queue, timeline
|
|
517
|
-
// at git.
|
|
549
|
+
// The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions
|
|
550
|
+
// and the reports waiting) between two looks at git.
|
|
518
551
|
await clock.advance(2000);
|
|
519
|
-
expect(w.runs - before).toBe(
|
|
552
|
+
expect(w.runs - before).toBe(15);
|
|
520
553
|
});
|
|
521
554
|
|
|
522
555
|
test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
|
|
@@ -1311,6 +1344,254 @@ test('DECIDE-3 notes are labelled note, not you, and need no decision', async ($
|
|
|
1311
1344
|
await band.unmount();
|
|
1312
1345
|
});
|
|
1313
1346
|
|
|
1347
|
+
const question = (extra: any = {}) => ({
|
|
1348
|
+
id: 'Q-1',
|
|
1349
|
+
at: '2026-10-12T05:59:00.000Z',
|
|
1350
|
+
by: 'lead',
|
|
1351
|
+
question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
|
|
1352
|
+
why: null,
|
|
1353
|
+
story: 'ST-4',
|
|
1354
|
+
blocks: true,
|
|
1355
|
+
options: [
|
|
1356
|
+
{ label: 'Bureau of Meteorology', effect: 'Free, Australian data; we credit the Bureau.' },
|
|
1357
|
+
{ label: 'Commercial', effect: 'A paid key; global coverage.' },
|
|
1358
|
+
],
|
|
1359
|
+
state: 'open',
|
|
1360
|
+
answer: null,
|
|
1361
|
+
withdrawn: null,
|
|
1362
|
+
...extra,
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
test("ASK-2 the lead's question waits in the band and as a card; an option answers it and tells the lead", async ($, on) => {
|
|
1366
|
+
const w = project();
|
|
1367
|
+
w.questions = [
|
|
1368
|
+
question(),
|
|
1369
|
+
question({
|
|
1370
|
+
id: 'Q-2',
|
|
1371
|
+
question: 'May sessions show your city?',
|
|
1372
|
+
story: null,
|
|
1373
|
+
blocks: false,
|
|
1374
|
+
options: [],
|
|
1375
|
+
}),
|
|
1376
|
+
];
|
|
1377
|
+
await start($, on, w);
|
|
1378
|
+
const band = await bandUi($);
|
|
1379
|
+
expect(
|
|
1380
|
+
await band.find({ type: 'Text', text: /^2 questions from the lead: Which weather provider/ }),
|
|
1381
|
+
).toBeDefined();
|
|
1382
|
+
await press($, 'band-openDecision');
|
|
1383
|
+
await band.unmount();
|
|
1384
|
+
const ui = await pane($, PANE);
|
|
1385
|
+
// One card per question, not one for both.
|
|
1386
|
+
expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
|
|
1387
|
+
expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
|
|
1388
|
+
expect(await ui.find({ type: 'Text', text: 'ST-4 waits on your answer.' })).toBeDefined();
|
|
1389
|
+
expect(
|
|
1390
|
+
await ui.find({ type: 'Text', text: 'Free, Australian data; we credit the Bureau.' }),
|
|
1391
|
+
).toBeDefined();
|
|
1392
|
+
await press($, 'decide-answerWith-0', PANE);
|
|
1393
|
+
expect(w.acts).toContainEqual(['ask', 'answer', 'Q-1', 'Bureau of Meteorology', '--via', 'pane']);
|
|
1394
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1395
|
+
'The person answered Q-1 (Which weather provider: the Bureau of Meteorology or a commercial service?): Bureau of Meteorology. ST-4 no longer waits on them. Carry on with it.',
|
|
1396
|
+
);
|
|
1397
|
+
await ui.unmount();
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
test("ASK-2 a question with no options, or Something else…, is answered in the person's own words", async ($, on) => {
|
|
1401
|
+
const w = project();
|
|
1402
|
+
w.questions = [
|
|
1403
|
+
question({
|
|
1404
|
+
id: 'Q-2',
|
|
1405
|
+
question: 'May sessions show your city?',
|
|
1406
|
+
story: null,
|
|
1407
|
+
blocks: false,
|
|
1408
|
+
options: [],
|
|
1409
|
+
}),
|
|
1410
|
+
];
|
|
1411
|
+
await start($, on, w);
|
|
1412
|
+
const band = await bandUi($);
|
|
1413
|
+
await press($, 'band-openDecision');
|
|
1414
|
+
await band.unmount();
|
|
1415
|
+
const ui = await pane($, PANE);
|
|
1416
|
+
await press($, 'decide-answer', PANE);
|
|
1417
|
+
expect(w.opened).toContain('code-kit-answer');
|
|
1418
|
+
const box = await pane($, 'code-kit-answer');
|
|
1419
|
+
await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: '' });
|
|
1420
|
+
expect(
|
|
1421
|
+
await box.find({ type: 'Text', text: 'Type your answer: the lead builds on it.' }),
|
|
1422
|
+
).toBeDefined();
|
|
1423
|
+
await $.ui.input({
|
|
1424
|
+
plugin: 'code-kit',
|
|
1425
|
+
key: 'answer-text',
|
|
1426
|
+
text: 'Only the suburb, never the street',
|
|
1427
|
+
});
|
|
1428
|
+
expect(w.acts).toContainEqual([
|
|
1429
|
+
'ask',
|
|
1430
|
+
'answer',
|
|
1431
|
+
'Q-2',
|
|
1432
|
+
'Only the suburb, never the street',
|
|
1433
|
+
'--via',
|
|
1434
|
+
'pane',
|
|
1435
|
+
]);
|
|
1436
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1437
|
+
'The person answered Q-2 (May sessions show your city?): Only the suburb, never the street. Carry on with it.',
|
|
1438
|
+
);
|
|
1439
|
+
expect(w.closed).toContain('code-kit-answer');
|
|
1440
|
+
await box.unmount();
|
|
1441
|
+
await ui.unmount();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test("ASK-4 the brief's open questions are the person's decisions: answered to the lead, or put off for the session", async ($, on) => {
|
|
1445
|
+
const w = project();
|
|
1446
|
+
w.next = {
|
|
1447
|
+
...w.next,
|
|
1448
|
+
brief: [
|
|
1449
|
+
{ question: 'Accounts: AWS, Supabase, Fly', path: 'docs/brief.md' },
|
|
1450
|
+
{ question: 'The product domain', path: 'docs/brief.md' },
|
|
1451
|
+
],
|
|
1452
|
+
};
|
|
1453
|
+
await start($, on, w);
|
|
1454
|
+
const band = await bandUi($);
|
|
1455
|
+
expect(
|
|
1456
|
+
await band.find({
|
|
1457
|
+
type: 'Text',
|
|
1458
|
+
text: '2 open questions in your brief: Accounts: AWS, Supabase, Fly',
|
|
1459
|
+
}),
|
|
1460
|
+
).toBeDefined();
|
|
1461
|
+
await press($, 'band-openDecision');
|
|
1462
|
+
await band.unmount();
|
|
1463
|
+
const ui = await pane($, PANE);
|
|
1464
|
+
expect(
|
|
1465
|
+
await ui.find({ type: 'Text', text: 'From your brief: Accounts: AWS, Supabase, Fly' }),
|
|
1466
|
+
).toBeDefined();
|
|
1467
|
+
await press($, 'decide-laterBrief', PANE);
|
|
1468
|
+
expect(
|
|
1469
|
+
await ui.find({ type: 'Text', text: 'From your brief: The product domain' }),
|
|
1470
|
+
).toBeDefined();
|
|
1471
|
+
await press($, 'decide-answerBrief', PANE);
|
|
1472
|
+
const box = await pane($, 'code-kit-answer');
|
|
1473
|
+
await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
|
|
1474
|
+
await box.unmount();
|
|
1475
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1476
|
+
'The person answered an open question in docs/brief.md, "The product domain": myaila.com.au. Record it in the brief\'s decision log, take it out of the open questions, and carry on with it.',
|
|
1477
|
+
);
|
|
1478
|
+
await ui.unmount();
|
|
1479
|
+
const after = await bandUi($);
|
|
1480
|
+
expect(await after.find({ type: 'Text', text: /in your brief/ })).toBeUndefined();
|
|
1481
|
+
await after.unmount();
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
test("PERM-1 a call Claude Code's own permission check refused waits for the person, who runs it or says not now", async ($, on) => {
|
|
1485
|
+
const w = project();
|
|
1486
|
+
await start($, on, w);
|
|
1487
|
+
await $.classic.PermissionDenied({
|
|
1488
|
+
tool_name: 'Bash',
|
|
1489
|
+
tool_input: { command: 'pnpm db:generate && pnpm db:migrate' },
|
|
1490
|
+
tool_use_id: 'tu-1',
|
|
1491
|
+
reason: 'Permission for this action was denied by the Claude Code auto mode classifier.',
|
|
1492
|
+
});
|
|
1493
|
+
const band = await bandUi($);
|
|
1494
|
+
expect(
|
|
1495
|
+
await band.find({ type: 'Text', text: "Claude Code refused the lead's `pnpm db:generate`" }),
|
|
1496
|
+
).toBeDefined();
|
|
1497
|
+
await press($, 'band-runDenied');
|
|
1498
|
+
expect(w.fills).toEqual(['! pnpm db:generate && pnpm db:migrate']);
|
|
1499
|
+
await press($, 'band-decide');
|
|
1500
|
+
await band.unmount();
|
|
1501
|
+
const ui = await pane($, PANE);
|
|
1502
|
+
expect(
|
|
1503
|
+
await ui.find({
|
|
1504
|
+
type: 'Text',
|
|
1505
|
+
text: /^Claude Code's own permission check refused it \(Permission for this action/,
|
|
1506
|
+
}),
|
|
1507
|
+
).toBeDefined();
|
|
1508
|
+
await press($, 'decide-deniedDone', PANE);
|
|
1509
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
1510
|
+
/^The person ran it themselves, or allowed it: pnpm db:generate && pnpm db:migrate/,
|
|
1511
|
+
);
|
|
1512
|
+
await ui.unmount();
|
|
1513
|
+
const after = await bandUi($);
|
|
1514
|
+
expect(await after.find({ type: 'Text', text: /Claude Code refused/ })).toBeUndefined();
|
|
1515
|
+
await after.unmount();
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
test('CARD-8 two approvals waiting are two decisions, each approved or dropped on its own', async ($, on) => {
|
|
1519
|
+
const w = project();
|
|
1520
|
+
w.requests = [
|
|
1521
|
+
dayjs,
|
|
1522
|
+
{
|
|
1523
|
+
...dayjs,
|
|
1524
|
+
at: '2026-10-06T00:05:00.000Z',
|
|
1525
|
+
actor: 'api-engineer',
|
|
1526
|
+
lane: 'api',
|
|
1527
|
+
names: ['dep-zod'],
|
|
1528
|
+
what: 'npm install zod',
|
|
1529
|
+
},
|
|
1530
|
+
];
|
|
1531
|
+
await start($, on, w);
|
|
1532
|
+
const band = await bandUi($);
|
|
1533
|
+
expect(await band.find({ type: 'Text', text: '2 approvals waiting' })).toBeDefined();
|
|
1534
|
+
await press($, 'band-decide');
|
|
1535
|
+
await band.unmount();
|
|
1536
|
+
const ui = await pane($, PANE);
|
|
1537
|
+
expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
|
|
1538
|
+
expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
|
|
1539
|
+
await press($, 'decision-pick-1', PANE);
|
|
1540
|
+
await press($, 'decide-dropRequest', PANE);
|
|
1541
|
+
expect(w.acts).toContainEqual(['requests', 'drop', '2', '--via', 'pane']);
|
|
1542
|
+
await ui.unmount();
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test('REPORT-3 a bug report an agent drafted waits for the person, who reads it and sends it, or discards it', async ($, on) => {
|
|
1546
|
+
const w = project();
|
|
1547
|
+
w.reports = [
|
|
1548
|
+
{
|
|
1549
|
+
id: 'R-1',
|
|
1550
|
+
at: '2026-10-13T00:00:00Z',
|
|
1551
|
+
by: 'lead',
|
|
1552
|
+
title: 'Decisions tab misses a held call',
|
|
1553
|
+
fingerprint: 'ck-1',
|
|
1554
|
+
body: '**What happened:** the card never showed',
|
|
1555
|
+
state: 'draft',
|
|
1556
|
+
sent: null,
|
|
1557
|
+
},
|
|
1558
|
+
];
|
|
1559
|
+
await start($, on, w);
|
|
1560
|
+
const band = await bandUi($);
|
|
1561
|
+
expect(
|
|
1562
|
+
await band.find({
|
|
1563
|
+
type: 'Text',
|
|
1564
|
+
text: 'A bug report for code-kit to send: Decisions tab misses a held call',
|
|
1565
|
+
}),
|
|
1566
|
+
).toBeDefined();
|
|
1567
|
+
await press($, 'band-openDecision');
|
|
1568
|
+
await band.unmount();
|
|
1569
|
+
const ui = await pane($, PANE);
|
|
1570
|
+
expect(
|
|
1571
|
+
await ui.find({ type: 'Text', text: '**What happened:** the card never showed' }),
|
|
1572
|
+
).toBeDefined();
|
|
1573
|
+
await press($, 'decide-sendReport', PANE);
|
|
1574
|
+
expect(w.acts).toContainEqual(['report', 'send', 'R-1']);
|
|
1575
|
+
expect(w.opened).toContain('code-kit-result');
|
|
1576
|
+
await ui.unmount();
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
test('ASK-3 a lane whose story waits on the person reads waiting on you', async ($, on) => {
|
|
1580
|
+
const w = project();
|
|
1581
|
+
w.stories = w.stories.map((s: any) =>
|
|
1582
|
+
s.id === 'ST-4'
|
|
1583
|
+
? { ...s, state: 'waiting on you', waitingFor: { id: 'Q-1', question: 'x' } }
|
|
1584
|
+
: s,
|
|
1585
|
+
);
|
|
1586
|
+
await start($, on, w);
|
|
1587
|
+
await lanes($);
|
|
1588
|
+
const ui = await pane($, PANE);
|
|
1589
|
+
expect((await ui.findAll({ type: 'Text' })).some((t: any) => /waiting on you/.test(t.text))).toBe(
|
|
1590
|
+
true,
|
|
1591
|
+
);
|
|
1592
|
+
await ui.unmount();
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1314
1595
|
test('JOURNAL-1 the Journal tab tells the lineage, from the request to now, with the loop and your choices in it', async ($, on) => {
|
|
1315
1596
|
const w = project();
|
|
1316
1597
|
w.journal = {
|