@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.
@@ -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
  ],
@@ -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,10 @@ import {
17
18
  agentsAtWork,
18
19
  shownState,
19
20
  attribution,
21
+ answerPane,
22
+ approvalAsidePrompt,
20
23
  approvePane,
24
+ commandItself,
21
25
  band,
22
26
  bandLines,
23
27
  finishedStories,
@@ -56,7 +60,7 @@ import {
56
60
  withoutInFlight,
57
61
  } from './loop.mjs';
58
62
  import { goalOf, nextText } from './mission.mjs';
59
- import { contextOf, decisionCards } from './decisions.mjs';
63
+ import { contextOf, decisionCards, limitsWhy } from './decisions.mjs';
60
64
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
61
65
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
62
66
  import { harnessSettings } from '../lib/harness.mjs';
@@ -89,6 +93,9 @@ let ledger = {}; // the session's tokens by agent and story (USE-1)
89
93
  let rateLimits = []; // the session's limits, as session.measure last gave them
90
94
  const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
91
95
  const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
96
+ const denied = new Map(); // tool_use_id → a call Claude Code's own permission check refused (PERM-1)
97
+ const briefLater = new Set(); // the brief's questions the person put off this session (ASK-4)
98
+ let answering = null; // the open Answer… box: { kind: 'question' | 'brief', question, id?, path?, text, error }
92
99
  let where = null; // { cli, cwd, interactive }, from session start
93
100
  // The lead loop (spec 05): what it did, and what it waits on.
94
101
  const loop = {
@@ -207,6 +214,8 @@ export function register(on) {
207
214
  const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
208
215
  return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
209
216
  };
217
+ // What a CLI run said, in a line: why it refused, for a notice.
218
+ const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
210
219
  // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
211
220
  if ((await json('loop').catch(() => null))?.paused)
212
221
  await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
@@ -246,6 +255,13 @@ export function register(on) {
246
255
  : [];
247
256
  // TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
248
257
  const team = valid && check.team ? await json('team') : null;
258
+ // ASK-2: the lead's questions waiting for the person; REPORT-3: bug reports waiting to be sent.
259
+ const questions = valid
260
+ ? ((await json('ask')) ?? []).filter((q) => q.state === 'open')
261
+ : [];
262
+ const reports = valid
263
+ ? ((await json('report')) ?? []).filter((r) => r.state === 'draft')
264
+ : [];
249
265
  // TEAM-12: the plan mirrored onto the team's Project board, at most every 10 minutes, in the
250
266
  // background: a slow GitHub never holds up the band.
251
267
  const at = await $.clock.now();
@@ -265,6 +281,10 @@ export function register(on) {
265
281
  adapters,
266
282
  check,
267
283
  team,
284
+ questions,
285
+ reports,
286
+ // ASK-4: the brief's open questions, less those put off this session.
287
+ brief: (nextStep?.brief ?? []).filter((b) => !briefLater.has(b.question)),
268
288
  base: status?.base ?? null,
269
289
  };
270
290
  if (model.state.kind === 'ok') await act.registerReviewer();
@@ -473,6 +493,8 @@ export function register(on) {
473
493
  $.ui.invalidate('ui.render');
474
494
  return {};
475
495
  },
496
+ // ASK-2, ASK-4, REPORT-3: a question's Answer… or a report's Read it opens its card.
497
+ openDecision: (line) => act.decide(line),
476
498
  // ACT-1: the confirmation, prefilled from the request.
477
499
  approve: async (line) => {
478
500
  approving = {
@@ -480,6 +502,8 @@ export function register(on) {
480
502
  reason: prefilledReason(line.request),
481
503
  error: null,
482
504
  heldId: line.heldId ?? null,
505
+ outside: line.outside ?? null,
506
+ aside: { thread: [], draft: '' },
483
507
  };
484
508
  await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
485
509
  $.ui.invalidate('ui.render');
@@ -521,12 +545,179 @@ export function register(on) {
521
545
  await $.ui.close({ id: APPROVE_ID });
522
546
  await act.reload();
523
547
  },
548
+ // CARD-7: a request nobody needs any more stops waiting; nothing is approved.
549
+ dropRequest: async (line) => {
550
+ const open = model.requests?.open ?? [];
551
+ const same = (r) =>
552
+ [...r.names].sort().join() === [...line.request.names].sort().join() &&
553
+ (r.lane ?? r.actor) === (line.request.lane ?? line.request.actor);
554
+ const numbers = open.flatMap((r, i) => (same(r) ? [i + 1] : []));
555
+ if (!numbers.length) return;
556
+ const ran = await $.process.run(
557
+ ['node', cli, 'requests', 'drop', numbers.join(','), '--via', 'pane'],
558
+ { cwd },
559
+ );
560
+ notice = ran.exitCode === 0 ? null : `Nothing was dropped: ${firstLine(ran)}`;
561
+ await act.reload();
562
+ },
563
+ // ASK-2: the lead's option, pressed: the answer is recorded and sent to the lead.
564
+ answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
565
+ // ASK-2, ASK-4: Answer… opens a box for the person's own words.
566
+ answer: async (line) => {
567
+ answering = { kind: 'question', ...line.question, text: '', error: null };
568
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
569
+ $.ui.invalidate('ui.render');
570
+ },
571
+ answerBrief: async (line) => {
572
+ answering = { kind: 'brief', ...line.brief, text: '', error: null };
573
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
574
+ $.ui.invalidate('ui.render');
575
+ },
576
+ laterBrief: (line) => {
577
+ briefLater.add(line.brief.question);
578
+ model = {
579
+ ...model,
580
+ brief: (model.brief ?? []).filter((b) => b.question !== line.brief.question),
581
+ };
582
+ $.ui.invalidate('ui.render');
583
+ },
584
+ confirmAnswer: async (text) => {
585
+ if (!answering) return;
586
+ if (!text.trim()) {
587
+ answering = { ...answering, error: 'Type your answer: the lead builds on it.' };
588
+ $.ui.invalidate('ui.render');
589
+ return;
590
+ }
591
+ const done = await act.sendAnswer(answering, text.trim());
592
+ if (done) {
593
+ answering = null;
594
+ await $.ui.close({ id: ANSWER_ID });
595
+ }
596
+ },
597
+ cancelAnswer: async () => {
598
+ answering = null;
599
+ await $.ui.close({ id: ANSWER_ID });
600
+ },
601
+ // The answer recorded (a question's, in code-kit) and told to the lead, which carries on with it.
602
+ sendAnswer: async (q, text) => {
603
+ if (q.kind === 'question') {
604
+ const ran = await $.process.run(
605
+ ['node', cli, 'ask', 'answer', q.id, text, '--via', 'pane'],
606
+ { cwd },
607
+ );
608
+ if (ran.exitCode !== 0) {
609
+ notice = `Your answer wasn't recorded: ${firstLine(ran)}`;
610
+ if (answering) answering = { ...answering, error: notice };
611
+ $.ui.invalidate('ui.render');
612
+ return false;
613
+ }
614
+ }
615
+ notice = null;
616
+ const said = /[.!?]$/.test(text) ? text : `${text}.`;
617
+ await $.prompt.submit({
618
+ text:
619
+ q.kind === 'question'
620
+ ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
621
+ : `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.`,
622
+ });
623
+ if (q.kind === 'brief') {
624
+ briefLater.add(q.question);
625
+ model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
626
+ }
627
+ await act.reload();
628
+ return true;
629
+ },
630
+ // PERM-1: a call Claude Code refused, which only the person can run or allow.
631
+ runDenied: async (line) => {
632
+ await $.prompt.fill({ text: `! ${line.denial.command}`, mode: 'replace' }).catch(() => {});
633
+ },
634
+ deniedDone: async (line) => {
635
+ denied.delete(line.denial.id);
636
+ $.ui.invalidate('ui.render');
637
+ await $.prompt.submit({
638
+ 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.`,
639
+ });
640
+ },
641
+ deniedNo: async (line) => {
642
+ denied.delete(line.denial.id);
643
+ $.ui.invalidate('ui.render');
644
+ await $.prompt.submit({
645
+ 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.`,
646
+ });
647
+ },
648
+ // REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
649
+ sendReport: async (line) => {
650
+ const ran = await $.process.run(['node', cli, 'report', 'send', line.report.id], {
651
+ cwd,
652
+ timeoutMs: 120000,
653
+ });
654
+ result = {
655
+ ok: ran.exitCode === 0,
656
+ title: ran.exitCode === 0 ? 'Report sent' : 'Report not sent',
657
+ text: `${ran.stdout}${ran.stderr}`.trim(),
658
+ };
659
+ await $.ui.open({ id: RESULT_ID, title: 'Report', focus: true, closeOnEscape: true });
660
+ await act.reload();
661
+ },
662
+ discardReport: async (line) => {
663
+ await $.process.run(['node', cli, 'report', 'discard', line.report.id], { cwd });
664
+ await act.reload();
665
+ },
524
666
  // HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
525
667
  approveHeld: (line) =>
526
668
  act.approve({
527
- request: { names: line.held.names, lane: line.held.lane, what: line.held.what },
669
+ request: {
670
+ names: line.held.names,
671
+ lane: line.held.lane,
672
+ what: line.held.what,
673
+ why: line.held.refusal ?? null,
674
+ },
528
675
  heldId: line.held.id,
676
+ outside: line.held.outside ?? null,
529
677
  }),
678
+ // ACT-5: a question about the approval, answered in its pane by a side call over the lead's own
679
+ // conversation (it shares the prompt cache, and nothing is added to the conversation); before the
680
+ // lead's first reply, from code-kit's facts alone.
681
+ askApproval: async (question) => {
682
+ const q = question.trim();
683
+ if (!approving || !q) return;
684
+ const asked = approving;
685
+ const thread = asked.aside.thread;
686
+ const turn = { question: q, pending: true };
687
+ approving = { ...asked, aside: { thread: [...thread, turn], draft: '' } };
688
+ $.ui.invalidate('ui.render');
689
+ const row = (model.state?.lanes ?? []).find((l) => l.name === asked.request.lane);
690
+ const prompt = approvalAsidePrompt({
691
+ request: asked.request,
692
+ story: row?.story ?? null,
693
+ outside: limitsWhy(asked.outside) ?? null,
694
+ thread,
695
+ question: q,
696
+ });
697
+ let reply = await $.model
698
+ .fork({ prompt })
699
+ .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
700
+ if (!reply.isAnswered && reply.reason === 'nothing-to-fork')
701
+ reply = await $.model
702
+ .complete({ model: 'sonnet', prompt, maxTokens: 800 })
703
+ .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
704
+ // Answered for this approval only: a pane closed or moved on meanwhile keeps what it has.
705
+ if (!approving || approving.request !== asked.request) return;
706
+ const done = reply.isAnswered
707
+ ? { question: q, answer: reply.text }
708
+ : {
709
+ question: q,
710
+ error: `No answer: ${reply.reason}${reply.status ? ` (${reply.status})` : ''}`,
711
+ };
712
+ approving = {
713
+ ...approving,
714
+ aside: {
715
+ ...approving.aside,
716
+ thread: approving.aside.thread.map((t) => (t === turn ? done : t)),
717
+ },
718
+ };
719
+ $.ui.invalidate('ui.render');
720
+ },
530
721
  refuseHeld: async (line) => {
531
722
  await answerHeld(line.held.id, 'refused');
532
723
  },
@@ -1205,6 +1396,35 @@ export function register(on) {
1205
1396
  // CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
1206
1397
  // HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
1207
1398
  // stay the judge: the call is run again once the approval is written, and refused as before otherwise.
1399
+ // PERM-1: Claude Code's own permission check refused a call (auto mode, a deny rule): code-kit can't
1400
+ // approve past it, so it waits in the band for the person, who can run it themselves or allow it.
1401
+ on('classic.PermissionDenied', async ($, e, next) => {
1402
+ const res = await next(e);
1403
+ const input = e.tool_input && typeof e.tool_input === 'object' ? e.tool_input : {};
1404
+ const command =
1405
+ e.tool_name === 'Bash' && typeof input.command === 'string' ? input.command : null;
1406
+ const path = input.file_path ?? input.notebook_path ?? null;
1407
+ const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
1408
+ denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
1409
+ id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
1410
+ who: lane ? `the ${lane} lane` : (e.agent_type ?? (e.agent_id ? 'an agent' : 'the lead')),
1411
+ tool: e.tool_name,
1412
+ command,
1413
+ what: command
1414
+ ? `\`${commandItself(command)}\``
1415
+ : path
1416
+ ? `${e.tool_name} on ${path}`
1417
+ : e.tool_name,
1418
+ reason:
1419
+ typeof e.reason === 'string'
1420
+ ? e.reason.trim().replace(/\s+/g, ' ').slice(0, 200) || null
1421
+ : null,
1422
+ at: await $.clock.now(),
1423
+ });
1424
+ $.ui.invalidate('ui.render');
1425
+ return res;
1426
+ });
1427
+
1208
1428
  on('tool.call', async ($, e, next) => {
1209
1429
  // LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
1210
1430
  const calledAt = await $.clock.now();
@@ -1583,7 +1803,7 @@ export function register(on) {
1583
1803
  const lines = linesNowRaw(now);
1584
1804
  const cards = cardsNow(now, lines);
1585
1805
  return lines.map((l) => {
1586
- const card = cards.find((c) => c.line === l);
1806
+ const card = cards.find((c) => c.source === l);
1587
1807
  return card ? { ...l, context: contextOf(card) } : l;
1588
1808
  });
1589
1809
  };
@@ -1601,6 +1821,7 @@ export function register(on) {
1601
1821
  nextStep: loop.nextStep,
1602
1822
  combined: combinedNow(),
1603
1823
  team: model.team ?? null,
1824
+ denied: [...denied.values()],
1604
1825
  });
1605
1826
 
1606
1827
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
@@ -1765,6 +1986,24 @@ export function register(on) {
1765
1986
  },
1766
1987
  onSubmit: (value) => act.confirmApproval(value),
1767
1988
  onCancel: () => act.cancelApproval(),
1989
+ onAskInput: (value) => {
1990
+ approving = { ...approving, aside: { ...approving.aside, draft: value } };
1991
+ $.ui.invalidate('ui.render');
1992
+ },
1993
+ onAsk: (value) => act.askApproval(value),
1994
+ });
1995
+ });
1996
+
1997
+ // ASK-2, ASK-4: the person's answer, in their own words.
1998
+ on('ui.render', { component: 'Pane', requestId: ANSWER_ID }, async ($, e) => {
1999
+ if (!answering) return resultPane(null, $.ui.resolve(e));
2000
+ return answerPane(answering, $.ui.resolve(e), {
2001
+ onInput: (value) => {
2002
+ answering = { ...answering, text: value };
2003
+ $.ui.invalidate('ui.render');
2004
+ },
2005
+ onSubmit: (value) => act.confirmAnswer(value),
2006
+ onCancel: () => act.cancelAnswer(),
1768
2007
  });
1769
2008
  });
1770
2009