@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +17 -8
- package/bin/code-kit.mjs +173 -5
- 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 +262 -15
- package/hooks/mod/art.mjs +2 -2
- package/hooks/mod/decisions.mjs +119 -4
- package/hooks/mod/loop.mjs +49 -0
- package/hooks/mod/panes.mjs +3 -1
- package/hooks/mod/register.mjs +201 -3
- package/hooks/mod/register.test.ts +312 -3
- package/hooks/mod/view.mjs +159 -11
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/init/SKILL.md +2 -1
- package/skills/next/SKILL.md +14 -0
- package/skills/review/SKILL.md +1 -1
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +3 -0
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,
|
|
@@ -45,7 +48,16 @@ import {
|
|
|
45
48
|
sendBackPane,
|
|
46
49
|
toolLine,
|
|
47
50
|
} from './panes.mjs';
|
|
48
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
leadPrompt,
|
|
53
|
+
nudgeText,
|
|
54
|
+
restartPrompt,
|
|
55
|
+
stallDue,
|
|
56
|
+
stepKey,
|
|
57
|
+
stepLabel,
|
|
58
|
+
storiesInFlight,
|
|
59
|
+
withoutInFlight,
|
|
60
|
+
} from './loop.mjs';
|
|
49
61
|
import { goalOf, nextText } from './mission.mjs';
|
|
50
62
|
import { contextOf, decisionCards } from './decisions.mjs';
|
|
51
63
|
import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
|
|
@@ -80,6 +92,9 @@ let ledger = {}; // the session's tokens by agent and story (USE-1)
|
|
|
80
92
|
let rateLimits = []; // the session's limits, as session.measure last gave them
|
|
81
93
|
const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
|
|
82
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 }
|
|
83
98
|
let where = null; // { cli, cwd, interactive }, from session start
|
|
84
99
|
// The lead loop (spec 05): what it did, and what it waits on.
|
|
85
100
|
const loop = {
|
|
@@ -198,6 +213,8 @@ export function register(on) {
|
|
|
198
213
|
const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
|
|
199
214
|
return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
200
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];
|
|
201
218
|
// A session starts with the loop running: a pause recorded by an earlier one no longer holds.
|
|
202
219
|
if ((await json('loop').catch(() => null))?.paused)
|
|
203
220
|
await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
|
|
@@ -237,6 +254,13 @@ export function register(on) {
|
|
|
237
254
|
: [];
|
|
238
255
|
// TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
|
|
239
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
|
+
: [];
|
|
240
264
|
// TEAM-12: the plan mirrored onto the team's Project board, at most every 10 minutes, in the
|
|
241
265
|
// background: a slow GitHub never holds up the band.
|
|
242
266
|
const at = await $.clock.now();
|
|
@@ -256,6 +280,10 @@ export function register(on) {
|
|
|
256
280
|
adapters,
|
|
257
281
|
check,
|
|
258
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)),
|
|
259
287
|
base: status?.base ?? null,
|
|
260
288
|
};
|
|
261
289
|
if (model.state.kind === 'ok') await act.registerReviewer();
|
|
@@ -464,6 +492,8 @@ export function register(on) {
|
|
|
464
492
|
$.ui.invalidate('ui.render');
|
|
465
493
|
return {};
|
|
466
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),
|
|
467
497
|
// ACT-1: the confirmation, prefilled from the request.
|
|
468
498
|
approve: async (line) => {
|
|
469
499
|
approving = {
|
|
@@ -512,6 +542,124 @@ export function register(on) {
|
|
|
512
542
|
await $.ui.close({ id: APPROVE_ID });
|
|
513
543
|
await act.reload();
|
|
514
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
|
+
},
|
|
515
663
|
// HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
|
|
516
664
|
approveHeld: (line) =>
|
|
517
665
|
act.approve({
|
|
@@ -790,7 +938,14 @@ export function register(on) {
|
|
|
790
938
|
return act.submit(prompt, 'restart', story);
|
|
791
939
|
}
|
|
792
940
|
const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
|
|
793
|
-
|
|
941
|
+
// LOOP-9: never a story an agent is already at work on, building or reviewing it.
|
|
942
|
+
const step =
|
|
943
|
+
ran.exitCode === 0
|
|
944
|
+
? withoutInFlight(
|
|
945
|
+
parseJson(ran.stdout),
|
|
946
|
+
storiesInFlight(await $.agent.list().catch(() => [])),
|
|
947
|
+
)
|
|
948
|
+
: null;
|
|
794
949
|
// MISSION-1: the band shows what the loop takes next.
|
|
795
950
|
loop.nextStep = step;
|
|
796
951
|
if (!step) {
|
|
@@ -1189,6 +1344,35 @@ export function register(on) {
|
|
|
1189
1344
|
// CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
|
|
1190
1345
|
// HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
|
|
1191
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
|
+
|
|
1192
1376
|
on('tool.call', async ($, e, next) => {
|
|
1193
1377
|
// LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
|
|
1194
1378
|
const calledAt = await $.clock.now();
|
|
@@ -1567,7 +1751,7 @@ export function register(on) {
|
|
|
1567
1751
|
const lines = linesNowRaw(now);
|
|
1568
1752
|
const cards = cardsNow(now, lines);
|
|
1569
1753
|
return lines.map((l) => {
|
|
1570
|
-
const card = cards.find((c) => c.
|
|
1754
|
+
const card = cards.find((c) => c.source === l);
|
|
1571
1755
|
return card ? { ...l, context: contextOf(card) } : l;
|
|
1572
1756
|
});
|
|
1573
1757
|
};
|
|
@@ -1585,6 +1769,7 @@ export function register(on) {
|
|
|
1585
1769
|
nextStep: loop.nextStep,
|
|
1586
1770
|
combined: combinedNow(),
|
|
1587
1771
|
team: model.team ?? null,
|
|
1772
|
+
denied: [...denied.values()],
|
|
1588
1773
|
});
|
|
1589
1774
|
|
|
1590
1775
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
@@ -1752,6 +1937,19 @@ export function register(on) {
|
|
|
1752
1937
|
});
|
|
1753
1938
|
});
|
|
1754
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
|
+
|
|
1755
1953
|
on('ui.render', { component: 'Pane', requestId: SETTINGS_ID }, async ($, e) =>
|
|
1756
1954
|
settingsView(settingRows, pendingSetting, $.ui.resolve(e), {
|
|
1757
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 = {
|
|
@@ -1527,6 +1808,34 @@ test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready stor
|
|
|
1527
1808
|
await ui.unmount();
|
|
1528
1809
|
});
|
|
1529
1810
|
|
|
1811
|
+
test('LOOP-9 the loop never asks for a story an agent is still on: it waits, and goes on once the agent finishes', async ($, on) => {
|
|
1812
|
+
const w = project();
|
|
1813
|
+
w.next = { step: 'dispatch', args: 'ST-9', then: [] };
|
|
1814
|
+
const clock = await start($, on, w);
|
|
1815
|
+
await leadTurn($);
|
|
1816
|
+
await settle(clock);
|
|
1817
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
1818
|
+
// Dispatched, and ST-4 still being built: next (reading only branches) offers both again.
|
|
1819
|
+
w.agents = [
|
|
1820
|
+
{ id: 'a1', type: 'web-engineer', status: 'running', description: 'ST-4 booking form' },
|
|
1821
|
+
{ id: 'a2', type: 'core-engineer', status: 'running', description: 'ST-9 pricing' },
|
|
1822
|
+
];
|
|
1823
|
+
w.next = { step: 'review', args: 'ST-4', then: [{ step: 'dispatch', args: 'ST-9' }] };
|
|
1824
|
+
await leadTurn($, 't-2');
|
|
1825
|
+
await settle(clock);
|
|
1826
|
+
expect(w.prompts).toHaveLength(1);
|
|
1827
|
+
const band = await bandUi($);
|
|
1828
|
+
expect(
|
|
1829
|
+
await band.find({ type: 'Text', text: /waiting: ST-4, ST-9 are still with their agents/ }),
|
|
1830
|
+
).toBeDefined();
|
|
1831
|
+
await band.unmount();
|
|
1832
|
+
// The web agent finishes: ST-4 is reviewed; ST-9 is still being built, so it isn't dispatched again.
|
|
1833
|
+
w.agents = [{ ...w.agents[0], status: 'completed' }, w.agents[1]];
|
|
1834
|
+
await clock.advance(2000);
|
|
1835
|
+
await settle(clock);
|
|
1836
|
+
expect(w.prompts.slice(1)).toEqual(['Review ST-4: run /code-kit:review ST-4.']);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1530
1839
|
test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or twice for the same step', async ($, on) => {
|
|
1531
1840
|
const w = project();
|
|
1532
1841
|
const clock = await start($, on, w);
|