@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -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 +242 -3
- package/hooks/mod/register.test.ts +344 -3
- package/hooks/mod/view.mjs +179 -4
- 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
|
@@ -157,6 +157,11 @@ 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
|
|
163
|
+
sideAsks: [] as any[], // side calls: { fork } or { model }, with their prompt
|
|
164
|
+
sideReply: { isAnswered: true, text: 'dayjs formats the booking dates.', usage: null } as any,
|
|
160
165
|
holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
|
|
161
166
|
waiting: new Map<string, (answer: string) => void>(),
|
|
162
167
|
};
|
|
@@ -186,7 +191,30 @@ function stub(on: any, w: World) {
|
|
|
186
191
|
}),
|
|
187
192
|
);
|
|
188
193
|
if (sub === 'next') return ran(0, JSON.stringify(w.next));
|
|
194
|
+
if (sub === 'requests' && argv[3] === 'drop') {
|
|
195
|
+
w.acts.push([...argv.slice(2)]);
|
|
196
|
+
const drop = new Set(argv[4].split(',').map(Number));
|
|
197
|
+
w.requests = w.requests.filter((_, i) => !drop.has(i + 1));
|
|
198
|
+
return ran(0, 'Dropped.');
|
|
199
|
+
}
|
|
189
200
|
if (sub === 'requests') return ran(0, JSON.stringify({ open: w.requests, inForce: w.inForce }));
|
|
201
|
+
if (sub === 'ask' && argv[3] === 'answer') {
|
|
202
|
+
w.acts.push([...argv.slice(2)]);
|
|
203
|
+
w.questions = w.questions.map((q) => (q.id === argv[4] ? { ...q, state: 'answered' } : q));
|
|
204
|
+
return ran(0, 'Recorded.');
|
|
205
|
+
}
|
|
206
|
+
if (sub === 'ask') return ran(0, JSON.stringify(w.questions));
|
|
207
|
+
if (sub === 'report' && ['send', 'discard'].includes(argv[3])) {
|
|
208
|
+
w.acts.push([...argv.slice(2)]);
|
|
209
|
+
w.reports = w.reports.map((r) =>
|
|
210
|
+
r.id === argv[4] ? { ...r, state: argv[3] === 'send' ? 'sent' : 'discarded' } : r,
|
|
211
|
+
);
|
|
212
|
+
return ran(
|
|
213
|
+
0,
|
|
214
|
+
argv[3] === 'send' ? 'Filed https://github.com/o/code-kit/issues/9' : 'Discarded.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (sub === 'report') return ran(0, JSON.stringify(w.reports));
|
|
190
218
|
if (sub === 'verify')
|
|
191
219
|
return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
|
|
192
220
|
if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
|
|
@@ -321,6 +349,21 @@ function stub(on: any, w: World) {
|
|
|
321
349
|
return { value: { agent: `code-kit:${e.name}` } };
|
|
322
350
|
});
|
|
323
351
|
on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
|
|
352
|
+
on('model.fork', ($: any, e: any) => {
|
|
353
|
+
w.sideAsks.push({ fork: true, prompt: e.prompt });
|
|
354
|
+
return { value: w.sideReply };
|
|
355
|
+
});
|
|
356
|
+
on('model.complete', ($: any, e: any) => {
|
|
357
|
+
w.sideAsks.push({ model: e.model, prompt: e.prompt });
|
|
358
|
+
return { value: { isAnswered: true, text: 'From the facts alone.', usage: null } };
|
|
359
|
+
});
|
|
360
|
+
// Claude Code's own settings hooks beneath: none answer a permission denial.
|
|
361
|
+
on('classic.PermissionDenied', () => ({}));
|
|
362
|
+
on('prompt.fill', ($: any, e: any) => {
|
|
363
|
+
w.fills.push(e.text);
|
|
364
|
+
w.draft = e.text;
|
|
365
|
+
return { isFilled: true };
|
|
366
|
+
});
|
|
324
367
|
on('session.send', ($: any, e: any) => {
|
|
325
368
|
w.sent.push({ to: e.to, text: e.text });
|
|
326
369
|
return { isDelivered: true };
|
|
@@ -513,10 +556,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
|
|
|
513
556
|
const before = w.runs;
|
|
514
557
|
await clock.advance(8000); // four quiet ticks: git only
|
|
515
558
|
expect(w.runs - before).toBe(4);
|
|
516
|
-
// The fifth reads it all (check, status, next, requests, stops, queue, timeline
|
|
517
|
-
// at git.
|
|
559
|
+
// The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions
|
|
560
|
+
// and the reports waiting) between two looks at git.
|
|
518
561
|
await clock.advance(2000);
|
|
519
|
-
expect(w.runs - before).toBe(
|
|
562
|
+
expect(w.runs - before).toBe(15);
|
|
520
563
|
});
|
|
521
564
|
|
|
522
565
|
test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
|
|
@@ -611,6 +654,56 @@ test('ACT-1 the person may type their own reason, and an empty one is not accept
|
|
|
611
654
|
await ui.unmount();
|
|
612
655
|
});
|
|
613
656
|
|
|
657
|
+
test("ACT-5 the person asks about an approval in its pane; the answer stays there, out of the lead's conversation", async ($, on) => {
|
|
658
|
+
const w = project();
|
|
659
|
+
w.requests = [dayjs];
|
|
660
|
+
await start($, on, w);
|
|
661
|
+
const ui = await bandUi($);
|
|
662
|
+
await press($, 'band-approve');
|
|
663
|
+
const confirm = await pane($, APPROVE);
|
|
664
|
+
await $.ui.input({
|
|
665
|
+
plugin: 'code-kit',
|
|
666
|
+
key: 'approve-ask',
|
|
667
|
+
text: 'Why does the booking form need this?',
|
|
668
|
+
});
|
|
669
|
+
expect(w.sideAsks).toHaveLength(1);
|
|
670
|
+
expect(w.sideAsks[0].fork).toBe(true);
|
|
671
|
+
expect(w.sideAsks[0].prompt).toMatch(/approvals asked for: dep-dayjs/);
|
|
672
|
+
expect(w.sideAsks[0].prompt).toMatch(/for: the web lane/);
|
|
673
|
+
expect(w.sideAsks[0].prompt).toMatch(/story: ST-4 Booking form/);
|
|
674
|
+
expect(w.sideAsks[0].prompt).toMatch(/Question: Why does the booking form need this\?$/);
|
|
675
|
+
expect(
|
|
676
|
+
await confirm.find({ type: 'Text', text: 'You: Why does the booking form need this?' }),
|
|
677
|
+
).toBeDefined();
|
|
678
|
+
expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(
|
|
679
|
+
/dayjs formats the booking dates/,
|
|
680
|
+
);
|
|
681
|
+
// A follow-up carries the aside so far; nothing went to the lead, and nothing was approved.
|
|
682
|
+
await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'Is it maintained?' });
|
|
683
|
+
expect(w.sideAsks[1].prompt).toMatch(
|
|
684
|
+
/Q: Why does the booking form need this\?\nA: dayjs formats the booking dates\./,
|
|
685
|
+
);
|
|
686
|
+
expect(w.prompts).toEqual([]);
|
|
687
|
+
expect(w.acts).toEqual([]);
|
|
688
|
+
await confirm.unmount();
|
|
689
|
+
await ui.unmount();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test('ACT-5 before the lead has replied there is nothing to fork: it answers from the facts alone', async ($, on) => {
|
|
693
|
+
const w = project();
|
|
694
|
+
w.requests = [dayjs];
|
|
695
|
+
w.sideReply = { isAnswered: false, reason: 'nothing-to-fork' };
|
|
696
|
+
await start($, on, w);
|
|
697
|
+
const ui = await bandUi($);
|
|
698
|
+
await press($, 'band-approve');
|
|
699
|
+
const confirm = await pane($, APPROVE);
|
|
700
|
+
await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'What is it?' });
|
|
701
|
+
expect(w.sideAsks.map((a) => a.model ?? 'fork')).toEqual(['fork', 'sonnet']);
|
|
702
|
+
expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(/From the facts alone/);
|
|
703
|
+
await confirm.unmount();
|
|
704
|
+
await ui.unmount();
|
|
705
|
+
});
|
|
706
|
+
|
|
614
707
|
test('ACT-1 cancelling writes nothing', async ($, on) => {
|
|
615
708
|
const w = project();
|
|
616
709
|
w.requests = [dayjs];
|
|
@@ -1311,6 +1404,254 @@ test('DECIDE-3 notes are labelled note, not you, and need no decision', async ($
|
|
|
1311
1404
|
await band.unmount();
|
|
1312
1405
|
});
|
|
1313
1406
|
|
|
1407
|
+
const question = (extra: any = {}) => ({
|
|
1408
|
+
id: 'Q-1',
|
|
1409
|
+
at: '2026-10-12T05:59:00.000Z',
|
|
1410
|
+
by: 'lead',
|
|
1411
|
+
question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
|
|
1412
|
+
why: null,
|
|
1413
|
+
story: 'ST-4',
|
|
1414
|
+
blocks: true,
|
|
1415
|
+
options: [
|
|
1416
|
+
{ label: 'Bureau of Meteorology', effect: 'Free, Australian data; we credit the Bureau.' },
|
|
1417
|
+
{ label: 'Commercial', effect: 'A paid key; global coverage.' },
|
|
1418
|
+
],
|
|
1419
|
+
state: 'open',
|
|
1420
|
+
answer: null,
|
|
1421
|
+
withdrawn: null,
|
|
1422
|
+
...extra,
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
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) => {
|
|
1426
|
+
const w = project();
|
|
1427
|
+
w.questions = [
|
|
1428
|
+
question(),
|
|
1429
|
+
question({
|
|
1430
|
+
id: 'Q-2',
|
|
1431
|
+
question: 'May sessions show your city?',
|
|
1432
|
+
story: null,
|
|
1433
|
+
blocks: false,
|
|
1434
|
+
options: [],
|
|
1435
|
+
}),
|
|
1436
|
+
];
|
|
1437
|
+
await start($, on, w);
|
|
1438
|
+
const band = await bandUi($);
|
|
1439
|
+
expect(
|
|
1440
|
+
await band.find({ type: 'Text', text: /^2 questions from the lead: Which weather provider/ }),
|
|
1441
|
+
).toBeDefined();
|
|
1442
|
+
await press($, 'band-openDecision');
|
|
1443
|
+
await band.unmount();
|
|
1444
|
+
const ui = await pane($, PANE);
|
|
1445
|
+
// One card per question, not one for both.
|
|
1446
|
+
expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
|
|
1447
|
+
expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
|
|
1448
|
+
expect(await ui.find({ type: 'Text', text: 'ST-4 waits on your answer.' })).toBeDefined();
|
|
1449
|
+
expect(
|
|
1450
|
+
await ui.find({ type: 'Text', text: 'Free, Australian data; we credit the Bureau.' }),
|
|
1451
|
+
).toBeDefined();
|
|
1452
|
+
await press($, 'decide-answerWith-0', PANE);
|
|
1453
|
+
expect(w.acts).toContainEqual(['ask', 'answer', 'Q-1', 'Bureau of Meteorology', '--via', 'pane']);
|
|
1454
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1455
|
+
'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.',
|
|
1456
|
+
);
|
|
1457
|
+
await ui.unmount();
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test("ASK-2 a question with no options, or Something else…, is answered in the person's own words", async ($, on) => {
|
|
1461
|
+
const w = project();
|
|
1462
|
+
w.questions = [
|
|
1463
|
+
question({
|
|
1464
|
+
id: 'Q-2',
|
|
1465
|
+
question: 'May sessions show your city?',
|
|
1466
|
+
story: null,
|
|
1467
|
+
blocks: false,
|
|
1468
|
+
options: [],
|
|
1469
|
+
}),
|
|
1470
|
+
];
|
|
1471
|
+
await start($, on, w);
|
|
1472
|
+
const band = await bandUi($);
|
|
1473
|
+
await press($, 'band-openDecision');
|
|
1474
|
+
await band.unmount();
|
|
1475
|
+
const ui = await pane($, PANE);
|
|
1476
|
+
await press($, 'decide-answer', PANE);
|
|
1477
|
+
expect(w.opened).toContain('code-kit-answer');
|
|
1478
|
+
const box = await pane($, 'code-kit-answer');
|
|
1479
|
+
await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: '' });
|
|
1480
|
+
expect(
|
|
1481
|
+
await box.find({ type: 'Text', text: 'Type your answer: the lead builds on it.' }),
|
|
1482
|
+
).toBeDefined();
|
|
1483
|
+
await $.ui.input({
|
|
1484
|
+
plugin: 'code-kit',
|
|
1485
|
+
key: 'answer-text',
|
|
1486
|
+
text: 'Only the suburb, never the street',
|
|
1487
|
+
});
|
|
1488
|
+
expect(w.acts).toContainEqual([
|
|
1489
|
+
'ask',
|
|
1490
|
+
'answer',
|
|
1491
|
+
'Q-2',
|
|
1492
|
+
'Only the suburb, never the street',
|
|
1493
|
+
'--via',
|
|
1494
|
+
'pane',
|
|
1495
|
+
]);
|
|
1496
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1497
|
+
'The person answered Q-2 (May sessions show your city?): Only the suburb, never the street. Carry on with it.',
|
|
1498
|
+
);
|
|
1499
|
+
expect(w.closed).toContain('code-kit-answer');
|
|
1500
|
+
await box.unmount();
|
|
1501
|
+
await ui.unmount();
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
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) => {
|
|
1505
|
+
const w = project();
|
|
1506
|
+
w.next = {
|
|
1507
|
+
...w.next,
|
|
1508
|
+
brief: [
|
|
1509
|
+
{ question: 'Accounts: AWS, Supabase, Fly', path: 'docs/brief.md' },
|
|
1510
|
+
{ question: 'The product domain', path: 'docs/brief.md' },
|
|
1511
|
+
],
|
|
1512
|
+
};
|
|
1513
|
+
await start($, on, w);
|
|
1514
|
+
const band = await bandUi($);
|
|
1515
|
+
expect(
|
|
1516
|
+
await band.find({
|
|
1517
|
+
type: 'Text',
|
|
1518
|
+
text: '2 open questions in your brief: Accounts: AWS, Supabase, Fly',
|
|
1519
|
+
}),
|
|
1520
|
+
).toBeDefined();
|
|
1521
|
+
await press($, 'band-openDecision');
|
|
1522
|
+
await band.unmount();
|
|
1523
|
+
const ui = await pane($, PANE);
|
|
1524
|
+
expect(
|
|
1525
|
+
await ui.find({ type: 'Text', text: 'From your brief: Accounts: AWS, Supabase, Fly' }),
|
|
1526
|
+
).toBeDefined();
|
|
1527
|
+
await press($, 'decide-laterBrief', PANE);
|
|
1528
|
+
expect(
|
|
1529
|
+
await ui.find({ type: 'Text', text: 'From your brief: The product domain' }),
|
|
1530
|
+
).toBeDefined();
|
|
1531
|
+
await press($, 'decide-answerBrief', PANE);
|
|
1532
|
+
const box = await pane($, 'code-kit-answer');
|
|
1533
|
+
await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
|
|
1534
|
+
await box.unmount();
|
|
1535
|
+
expect(w.prompts.at(-1)).toBe(
|
|
1536
|
+
'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.',
|
|
1537
|
+
);
|
|
1538
|
+
await ui.unmount();
|
|
1539
|
+
const after = await bandUi($);
|
|
1540
|
+
expect(await after.find({ type: 'Text', text: /in your brief/ })).toBeUndefined();
|
|
1541
|
+
await after.unmount();
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
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) => {
|
|
1545
|
+
const w = project();
|
|
1546
|
+
await start($, on, w);
|
|
1547
|
+
await $.classic.PermissionDenied({
|
|
1548
|
+
tool_name: 'Bash',
|
|
1549
|
+
tool_input: { command: 'pnpm db:generate && pnpm db:migrate' },
|
|
1550
|
+
tool_use_id: 'tu-1',
|
|
1551
|
+
reason: 'Permission for this action was denied by the Claude Code auto mode classifier.',
|
|
1552
|
+
});
|
|
1553
|
+
const band = await bandUi($);
|
|
1554
|
+
expect(
|
|
1555
|
+
await band.find({ type: 'Text', text: "Claude Code refused the lead's `pnpm db:generate`" }),
|
|
1556
|
+
).toBeDefined();
|
|
1557
|
+
await press($, 'band-runDenied');
|
|
1558
|
+
expect(w.fills).toEqual(['! pnpm db:generate && pnpm db:migrate']);
|
|
1559
|
+
await press($, 'band-decide');
|
|
1560
|
+
await band.unmount();
|
|
1561
|
+
const ui = await pane($, PANE);
|
|
1562
|
+
expect(
|
|
1563
|
+
await ui.find({
|
|
1564
|
+
type: 'Text',
|
|
1565
|
+
text: /^Claude Code's own permission check refused it \(Permission for this action/,
|
|
1566
|
+
}),
|
|
1567
|
+
).toBeDefined();
|
|
1568
|
+
await press($, 'decide-deniedDone', PANE);
|
|
1569
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
1570
|
+
/^The person ran it themselves, or allowed it: pnpm db:generate && pnpm db:migrate/,
|
|
1571
|
+
);
|
|
1572
|
+
await ui.unmount();
|
|
1573
|
+
const after = await bandUi($);
|
|
1574
|
+
expect(await after.find({ type: 'Text', text: /Claude Code refused/ })).toBeUndefined();
|
|
1575
|
+
await after.unmount();
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
test('CARD-8 two approvals waiting are two decisions, each approved or dropped on its own', async ($, on) => {
|
|
1579
|
+
const w = project();
|
|
1580
|
+
w.requests = [
|
|
1581
|
+
dayjs,
|
|
1582
|
+
{
|
|
1583
|
+
...dayjs,
|
|
1584
|
+
at: '2026-10-06T00:05:00.000Z',
|
|
1585
|
+
actor: 'api-engineer',
|
|
1586
|
+
lane: 'api',
|
|
1587
|
+
names: ['dep-zod'],
|
|
1588
|
+
what: 'npm install zod',
|
|
1589
|
+
},
|
|
1590
|
+
];
|
|
1591
|
+
await start($, on, w);
|
|
1592
|
+
const band = await bandUi($);
|
|
1593
|
+
expect(await band.find({ type: 'Text', text: '2 approvals waiting' })).toBeDefined();
|
|
1594
|
+
await press($, 'band-decide');
|
|
1595
|
+
await band.unmount();
|
|
1596
|
+
const ui = await pane($, PANE);
|
|
1597
|
+
expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
|
|
1598
|
+
expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
|
|
1599
|
+
await press($, 'decision-pick-1', PANE);
|
|
1600
|
+
await press($, 'decide-dropRequest', PANE);
|
|
1601
|
+
expect(w.acts).toContainEqual(['requests', 'drop', '2', '--via', 'pane']);
|
|
1602
|
+
await ui.unmount();
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
test('REPORT-3 a bug report an agent drafted waits for the person, who reads it and sends it, or discards it', async ($, on) => {
|
|
1606
|
+
const w = project();
|
|
1607
|
+
w.reports = [
|
|
1608
|
+
{
|
|
1609
|
+
id: 'R-1',
|
|
1610
|
+
at: '2026-10-13T00:00:00Z',
|
|
1611
|
+
by: 'lead',
|
|
1612
|
+
title: 'Decisions tab misses a held call',
|
|
1613
|
+
fingerprint: 'ck-1',
|
|
1614
|
+
body: '**What happened:** the card never showed',
|
|
1615
|
+
state: 'draft',
|
|
1616
|
+
sent: null,
|
|
1617
|
+
},
|
|
1618
|
+
];
|
|
1619
|
+
await start($, on, w);
|
|
1620
|
+
const band = await bandUi($);
|
|
1621
|
+
expect(
|
|
1622
|
+
await band.find({
|
|
1623
|
+
type: 'Text',
|
|
1624
|
+
text: 'A bug report for code-kit to send: Decisions tab misses a held call',
|
|
1625
|
+
}),
|
|
1626
|
+
).toBeDefined();
|
|
1627
|
+
await press($, 'band-openDecision');
|
|
1628
|
+
await band.unmount();
|
|
1629
|
+
const ui = await pane($, PANE);
|
|
1630
|
+
expect(
|
|
1631
|
+
await ui.find({ type: 'Text', text: '**What happened:** the card never showed' }),
|
|
1632
|
+
).toBeDefined();
|
|
1633
|
+
await press($, 'decide-sendReport', PANE);
|
|
1634
|
+
expect(w.acts).toContainEqual(['report', 'send', 'R-1']);
|
|
1635
|
+
expect(w.opened).toContain('code-kit-result');
|
|
1636
|
+
await ui.unmount();
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
test('ASK-3 a lane whose story waits on the person reads waiting on you', async ($, on) => {
|
|
1640
|
+
const w = project();
|
|
1641
|
+
w.stories = w.stories.map((s: any) =>
|
|
1642
|
+
s.id === 'ST-4'
|
|
1643
|
+
? { ...s, state: 'waiting on you', waitingFor: { id: 'Q-1', question: 'x' } }
|
|
1644
|
+
: s,
|
|
1645
|
+
);
|
|
1646
|
+
await start($, on, w);
|
|
1647
|
+
await lanes($);
|
|
1648
|
+
const ui = await pane($, PANE);
|
|
1649
|
+
expect((await ui.findAll({ type: 'Text' })).some((t: any) => /waiting on you/.test(t.text))).toBe(
|
|
1650
|
+
true,
|
|
1651
|
+
);
|
|
1652
|
+
await ui.unmount();
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1314
1655
|
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
1656
|
const w = project();
|
|
1316
1657
|
w.journal = {
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -39,6 +39,7 @@ export function readyFrom(next) {
|
|
|
39
39
|
* building a branch for the story and, if it has commits, the lane's agent still at work on it;
|
|
40
40
|
* in review commits on the branch (status's `review`) and the agent no longer at work;
|
|
41
41
|
* sent back a review sent the branch back, and the lane hasn't committed its fix yet;
|
|
42
|
+
* waiting on you the lane's story waits on the person's answer (ASK-3);
|
|
42
43
|
* blocked the lane's next story waits on another;
|
|
43
44
|
* idle none of these.
|
|
44
45
|
* `status` can't tell a finished branch from one still being built, so the agent at work decides.
|
|
@@ -46,7 +47,11 @@ export function readyFrom(next) {
|
|
|
46
47
|
function laneRow(name, agent, stories, atWork) {
|
|
47
48
|
const own = stories.filter((s) => s.lane === name);
|
|
48
49
|
const active = Boolean(agent && atWork.has(agent));
|
|
49
|
-
const story = own.find((s) =>
|
|
50
|
+
const story = own.find((s) =>
|
|
51
|
+
['in progress', 'review', 'sent back', 'waiting on you'].includes(s.state),
|
|
52
|
+
);
|
|
53
|
+
if (story?.state === 'waiting on you')
|
|
54
|
+
return { name, agent, active, story, branch: story.branch, state: 'waiting on you' };
|
|
50
55
|
if (story?.state === 'sent back')
|
|
51
56
|
return { name, agent, active, story, branch: story.branch, state: 'sent back' };
|
|
52
57
|
if (story) {
|
|
@@ -107,6 +112,7 @@ export const STATE_COLOR = {
|
|
|
107
112
|
orchestrating: 'yellow',
|
|
108
113
|
'in review': 'yellow',
|
|
109
114
|
'sent back': 'magenta',
|
|
115
|
+
'waiting on you': 'cyan',
|
|
110
116
|
blocked: 'red',
|
|
111
117
|
idle: undefined,
|
|
112
118
|
};
|
|
@@ -357,6 +363,7 @@ const CARD_STATE = {
|
|
|
357
363
|
review: { label: 'in review', color: 'yellow' },
|
|
358
364
|
'in progress': { label: 'building', color: 'blue' },
|
|
359
365
|
'sent back': { label: 'sent back', color: 'magenta' },
|
|
366
|
+
'waiting on you': { label: 'waiting on you', color: 'cyan' },
|
|
360
367
|
blocked: { label: 'blocked', color: 'red' },
|
|
361
368
|
ready: { label: 'ready', color: 'green' },
|
|
362
369
|
todo: { label: 'to do', color: 'gray' },
|
|
@@ -715,6 +722,10 @@ export function bandLines({
|
|
|
715
722
|
nextStep = null,
|
|
716
723
|
combined = null,
|
|
717
724
|
team = null,
|
|
725
|
+
questions = [],
|
|
726
|
+
brief = [],
|
|
727
|
+
denied = [],
|
|
728
|
+
reports = [],
|
|
718
729
|
}) {
|
|
719
730
|
const lines = [];
|
|
720
731
|
// MISSION-1: the goal, now, next and the agents at work, above what needs the person.
|
|
@@ -725,18 +736,39 @@ export function bandLines({
|
|
|
725
736
|
if (!state || state.kind !== 'ok') return lines;
|
|
726
737
|
// HOLD-2: calls held for the person, oldest first, with the time left on the oldest.
|
|
727
738
|
if (held.length) {
|
|
728
|
-
const
|
|
739
|
+
const calls = [...held].sort((a, b) => a.since - b.since);
|
|
740
|
+
const [call] = calls;
|
|
729
741
|
const left = timeLeft(call.since + call.minutes * 60000 - now);
|
|
730
742
|
lines.push({
|
|
731
743
|
kind: 'held',
|
|
732
744
|
text: `${held.length > 1 ? `${held.length} calls held: ` : ''}${call.who} wants to ${heldVerb(call)} · ${left}`,
|
|
733
745
|
held: call,
|
|
746
|
+
// CARD-8: each held call is its own decision in the Decisions tab.
|
|
747
|
+
calls: calls.map((c) => ({
|
|
748
|
+
held: c,
|
|
749
|
+
text: `${c.who} wants to ${heldVerb(c)} · ${timeLeft(c.since + c.minutes * 60000 - now)}`,
|
|
750
|
+
})),
|
|
734
751
|
actions: [
|
|
735
752
|
{ id: 'approveHeld', label: 'Approve' },
|
|
736
753
|
{ id: 'refuseHeld', label: 'Refuse' },
|
|
737
754
|
],
|
|
738
755
|
});
|
|
739
756
|
}
|
|
757
|
+
// PERM-1: Claude Code's own permission check refused a call only the person can let through.
|
|
758
|
+
if (denied.length) {
|
|
759
|
+
const [d] = denied;
|
|
760
|
+
lines.push({
|
|
761
|
+
kind: 'denied',
|
|
762
|
+
text: `${denied.length > 1 ? `${denied.length} calls refused by Claude Code: ` : 'Claude Code refused '}${d.who}'s ${d.what}`,
|
|
763
|
+
denial: d,
|
|
764
|
+
denials: denied,
|
|
765
|
+
actions: [
|
|
766
|
+
...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
|
|
767
|
+
{ id: 'deniedDone', label: 'Done it' },
|
|
768
|
+
{ id: 'deniedNo', label: 'Not now' },
|
|
769
|
+
],
|
|
770
|
+
});
|
|
771
|
+
}
|
|
740
772
|
// LOOP-3: a lane stalled past its restarts waits for the person.
|
|
741
773
|
for (const f of loop?.flagged ?? [])
|
|
742
774
|
lines.push({
|
|
@@ -778,8 +810,28 @@ export function bandLines({
|
|
|
778
810
|
text: `${plural(asks.length, 'approval', 'approvals')} waiting`,
|
|
779
811
|
request: asks[0].request,
|
|
780
812
|
files: asks[0].files,
|
|
813
|
+
// CARD-8: each approval is its own decision in the Decisions tab.
|
|
814
|
+
asks,
|
|
781
815
|
actions: [{ id: 'approve', label: 'Approve…' }],
|
|
782
816
|
});
|
|
817
|
+
// ASK-2: what the lead asked the person and couldn't settle itself.
|
|
818
|
+
if (questions.length)
|
|
819
|
+
lines.push({
|
|
820
|
+
kind: 'question',
|
|
821
|
+
text: `${questions.length > 1 ? `${questions.length} questions from the lead: ` : 'The lead asks: '}${questions[0].question}`,
|
|
822
|
+
question: questions[0],
|
|
823
|
+
questions,
|
|
824
|
+
actions: [{ id: 'openDecision', label: 'Answer…' }],
|
|
825
|
+
});
|
|
826
|
+
// ASK-4: the brief's open questions, the person's to answer.
|
|
827
|
+
if (brief.length)
|
|
828
|
+
lines.push({
|
|
829
|
+
kind: 'brief',
|
|
830
|
+
text: `${plural(brief.length, 'open question', 'open questions')} in your brief: ${brief[0].question}`,
|
|
831
|
+
brief: brief[0],
|
|
832
|
+
briefs: brief,
|
|
833
|
+
actions: [{ id: 'openDecision', label: 'Answer…' }],
|
|
834
|
+
});
|
|
783
835
|
// DECIDE-1: reviewing a lane's work is the lead's. Only with the loop off, where the person runs the
|
|
784
836
|
// build themselves, does a finished story wait in the band for their Review or Merge; otherwise the
|
|
785
837
|
// loop prompts the lead (or, under propose, offers the step with Go).
|
|
@@ -839,6 +891,18 @@ export function bandLines({
|
|
|
839
891
|
});
|
|
840
892
|
// TEAM-7, TEAM-9: what of the team needs this person.
|
|
841
893
|
lines.push(...teamLines(team, check?.lanes ?? {}));
|
|
894
|
+
// REPORT-3: a bug report for code-kit an agent drafted, for the person to read and send, or not.
|
|
895
|
+
if (reports.length)
|
|
896
|
+
lines.push({
|
|
897
|
+
kind: 'report',
|
|
898
|
+
text: `${reports.length > 1 ? `${reports.length} bug reports for code-kit to send: ` : 'A bug report for code-kit to send: '}${reports[0].title}`,
|
|
899
|
+
report: reports[0],
|
|
900
|
+
reports,
|
|
901
|
+
actions: [
|
|
902
|
+
{ id: 'openDecision', label: 'Read it' },
|
|
903
|
+
{ id: 'discardReport', label: 'Discard' },
|
|
904
|
+
],
|
|
905
|
+
});
|
|
842
906
|
// LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
|
|
843
907
|
if (loop?.state === 'done')
|
|
844
908
|
lines.push({
|
|
@@ -880,6 +944,10 @@ export function band(lines, { Box, Text, Button }, onAction) {
|
|
|
880
944
|
usage: 'yellow',
|
|
881
945
|
paused: 'yellow',
|
|
882
946
|
facts: 'yellow',
|
|
947
|
+
question: 'cyan',
|
|
948
|
+
brief: 'cyan',
|
|
949
|
+
denied: 'red',
|
|
950
|
+
report: 'magenta',
|
|
883
951
|
};
|
|
884
952
|
const LIVE = { quiet: 'yellow', stalled: 'red' };
|
|
885
953
|
const LABEL = 8;
|
|
@@ -989,10 +1057,11 @@ const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done', 'teamDown']);
|
|
|
989
1057
|
/** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
|
|
990
1058
|
export function approvePane(
|
|
991
1059
|
approving,
|
|
992
|
-
{ Box, Text, Input, Button },
|
|
993
|
-
{ onInput, onSubmit, onCancel },
|
|
1060
|
+
{ Box, Text, Input, Button, Markdown },
|
|
1061
|
+
{ onInput, onSubmit, onCancel, onAskInput, onAsk },
|
|
994
1062
|
) {
|
|
995
1063
|
const { request, reason, error } = approving;
|
|
1064
|
+
const aside = approving.aside ?? { thread: [], draft: '' };
|
|
996
1065
|
return Box({
|
|
997
1066
|
flexDirection: 'column',
|
|
998
1067
|
rowGap: 1,
|
|
@@ -1003,6 +1072,30 @@ export function approvePane(
|
|
|
1003
1072
|
`For ${request.lane ? `the ${request.lane} lane` : 'any agent'}, ${request.names.every((n) => n.startsWith('dep-')) ? 'until the install is committed (at most 7 days)' : 'for 60 minutes'}. Asked for: ${request.what}`,
|
|
1004
1073
|
],
|
|
1005
1074
|
}),
|
|
1075
|
+
// ACT-5: an aside about this approval, answered here and never in the lead's conversation.
|
|
1076
|
+
...aside.thread.flatMap((turn, i) => [
|
|
1077
|
+
Text({ key: `aside-q-${i}`, color: 'cyan', children: [`You: ${turn.question}`] }),
|
|
1078
|
+
turn.pending
|
|
1079
|
+
? Text({ key: `aside-a-${i}`, dimColor: true, children: ['Asking…'] })
|
|
1080
|
+
: turn.error
|
|
1081
|
+
? Text({ key: `aside-a-${i}`, color: 'red', children: [turn.error] })
|
|
1082
|
+
: Markdown
|
|
1083
|
+
? Markdown({ key: `aside-a-${i}`, text: turn.answer })
|
|
1084
|
+
: Text({ key: `aside-a-${i}`, children: [turn.answer] }),
|
|
1085
|
+
]),
|
|
1086
|
+
...(onAsk
|
|
1087
|
+
? [
|
|
1088
|
+
Input({
|
|
1089
|
+
key: 'approve-ask',
|
|
1090
|
+
label: 'Ask about it',
|
|
1091
|
+
value: aside.draft,
|
|
1092
|
+
placeholder: 'What is it for, and what does approving it let happen?',
|
|
1093
|
+
submitLabel: 'Ask',
|
|
1094
|
+
onInput: onAskInput,
|
|
1095
|
+
onSubmit: onAsk,
|
|
1096
|
+
}),
|
|
1097
|
+
]
|
|
1098
|
+
: []),
|
|
1006
1099
|
Input({
|
|
1007
1100
|
key: 'approve-reason',
|
|
1008
1101
|
label: 'Reason',
|
|
@@ -1025,6 +1118,88 @@ export function approvePane(
|
|
|
1025
1118
|
});
|
|
1026
1119
|
}
|
|
1027
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* ACT-5: the side call's prompt for a question about an approval: what code-kit knows of the request (the
|
|
1123
|
+
* names, the lane, what was refused and why, its story, the limits it missed), the aside so far, and the
|
|
1124
|
+
* question. It answers in the pane, never in the conversation, and acts on nothing.
|
|
1125
|
+
*/
|
|
1126
|
+
export function approvalAsidePrompt({
|
|
1127
|
+
request,
|
|
1128
|
+
story = null,
|
|
1129
|
+
outside = null,
|
|
1130
|
+
thread = [],
|
|
1131
|
+
question,
|
|
1132
|
+
}) {
|
|
1133
|
+
return [
|
|
1134
|
+
"The person is deciding whether to approve something code-kit stopped, and asks you about it in code-kit's approval pane. Answer in a few short sentences, in plain words: what it is, why it's needed for the work, what approving it lets happen, and anything risky about it. Use what you know from this conversation and the facts below. If you don't know, say so; don't guess. This answer goes to that pane, not into the conversation: don't act on it, and don't change your plans because of it.",
|
|
1135
|
+
'',
|
|
1136
|
+
'The approval:',
|
|
1137
|
+
`- approvals asked for: ${request.names.join(', ')}`,
|
|
1138
|
+
`- for: ${request.lane ? `the ${request.lane} lane` : (request.actor ?? 'an agent')}`,
|
|
1139
|
+
`- what was refused: ${request.what}`,
|
|
1140
|
+
...(request.why ? [`- why code-kit refused it: ${request.why}`] : []),
|
|
1141
|
+
...(story
|
|
1142
|
+
? [
|
|
1143
|
+
`- story: ${story.id} ${story.title ?? ''}${story.requirements?.length ? ` (${story.requirements.join(', ')})` : ''}`,
|
|
1144
|
+
]
|
|
1145
|
+
: []),
|
|
1146
|
+
...(outside ? [`- outside the limits the person set for the lead: ${outside}`] : []),
|
|
1147
|
+
...(thread.length
|
|
1148
|
+
? [
|
|
1149
|
+
'',
|
|
1150
|
+
'Asked so far:',
|
|
1151
|
+
...thread.flatMap((t) => [`Q: ${t.question}`, `A: ${t.answer ?? t.error ?? ''}`]),
|
|
1152
|
+
]
|
|
1153
|
+
: []),
|
|
1154
|
+
'',
|
|
1155
|
+
`Question: ${question}`,
|
|
1156
|
+
].join('\n');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
export const ANSWER_ID = 'code-kit-answer';
|
|
1160
|
+
|
|
1161
|
+
/** ASK-2, ASK-4: the person's answer to the lead's question, or to one in their brief, typed. */
|
|
1162
|
+
export function answerPane(
|
|
1163
|
+
answering,
|
|
1164
|
+
{ Box, Text, Input, Button },
|
|
1165
|
+
{ onInput, onSubmit, onCancel },
|
|
1166
|
+
) {
|
|
1167
|
+
const { kind, question, text, error } = answering;
|
|
1168
|
+
return Box({
|
|
1169
|
+
flexDirection: 'column',
|
|
1170
|
+
rowGap: 1,
|
|
1171
|
+
children: [
|
|
1172
|
+
Text({ bold: true, children: [question] }),
|
|
1173
|
+
Text({
|
|
1174
|
+
dimColor: true,
|
|
1175
|
+
children: [
|
|
1176
|
+
kind === 'brief'
|
|
1177
|
+
? `An open question in ${answering.path}. The lead records your answer in the brief's decisions and builds on it.`
|
|
1178
|
+
: `${answering.id}, from the lead${answering.story ? `, for ${answering.story}` : ''}. Your answer is recorded and sent to the lead.`,
|
|
1179
|
+
],
|
|
1180
|
+
}),
|
|
1181
|
+
Input({
|
|
1182
|
+
key: 'answer-text',
|
|
1183
|
+
label: 'Answer',
|
|
1184
|
+
value: text,
|
|
1185
|
+
submitLabel: 'Answer',
|
|
1186
|
+
autoFocus: true,
|
|
1187
|
+
onInput,
|
|
1188
|
+
onSubmit,
|
|
1189
|
+
}),
|
|
1190
|
+
...(error ? [Text({ color: 'red', children: [error] })] : []),
|
|
1191
|
+
Box({
|
|
1192
|
+
flexDirection: 'row',
|
|
1193
|
+
columnGap: 2,
|
|
1194
|
+
children: [
|
|
1195
|
+
Button({ key: 'answer-confirm', label: 'Answer', onPress: () => onSubmit(text) }),
|
|
1196
|
+
Button({ key: 'answer-cancel', label: 'Cancel', onPress: onCancel }),
|
|
1197
|
+
],
|
|
1198
|
+
}),
|
|
1199
|
+
],
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1028
1203
|
/** What a merge (or another act run through the CLI) reported. */
|
|
1029
1204
|
export function resultPane(result, { Box, Text }) {
|
|
1030
1205
|
if (!result) return Text({ dimColor: true, children: ['Nothing to show.'] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|