@warren-dean/code-kit 0.7.0 → 0.8.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.
@@ -46,6 +46,9 @@ import {
46
46
  toolLine,
47
47
  } from './panes.mjs';
48
48
  import { leadPrompt, nudgeText, restartPrompt, stallDue, stepKey, stepLabel } from './loop.mjs';
49
+ import { goalOf, nextText } from './mission.mjs';
50
+ import { contextOf, decisionCards } from './decisions.mjs';
51
+ import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
49
52
  import { harnessSettings } from '../lib/harness.mjs';
50
53
  import {
51
54
  REVIEW_WAIT_MS,
@@ -82,6 +85,7 @@ const loop = {
82
85
  asked: { key: null, times: 0 }, // the step the lead was last prompted with, and how often in a row
83
86
  proposal: null, // under autonomy propose, or a step asked too often: { prompt, label, kind, target }
84
87
  pending: [], // restarts waiting for the lead to be idle: { story, prompt }
88
+ nextStep: null, // what `code-kit next` last said, for the band's next row
85
89
  done: null, // { count } once every story is done
86
90
  doneDismissed: false,
87
91
  };
@@ -91,6 +95,19 @@ let leadTurn = null; // the lead's running turn, if any
91
95
  const activity = new Map(); // agent id → { at, inFlight, nudged, stopped, lane, story }
92
96
  const restarts = new Map(); // story → times the loop restarted it
93
97
  const flagged = new Map(); // story → { lane, story, count }: stalled past its restarts
98
+ /**
99
+ * LOOP-3: whether an agent's work is done, so it isn't stalled however quiet: it handed back, or its
100
+ * story is in review, waiting in the merge queue or merged.
101
+ */
102
+ const finishedWork = (a) => {
103
+ if (a.handedBack) return true;
104
+ const story = (model.state?.stories ?? []).find((s) => s.id === a.story);
105
+ if (!story) return false;
106
+ if (['review', 'done'].includes(story.state)) return true;
107
+ return (model.queue ?? []).some(
108
+ (q) => q.branch === story.branch && ['waiting', 'merging'].includes(q.state),
109
+ );
110
+ };
94
111
  /** An agent's activity record, made on first sight. */
95
112
  const activityOf = (agentId, now) => {
96
113
  let a = activity.get(agentId);
@@ -110,6 +127,11 @@ let frame = 0;
110
127
  let animation = null; // the timer repainting working characters, while one works
111
128
  let paneSurface = null;
112
129
  let paneColumns = 80;
130
+ let codeMap = null; // MAP-1: `code-kit map <story> --json` for the Code map tab's story
131
+ let trailData = null; // TRAIL-1: `code-kit trail --json` while the Trail tab shows
132
+ let journalData = null; // JOURNAL-1: `code-kit journal --json`, for the Journal tab and the decisions
133
+ const touches = []; // MAP-2: { path, verb, lane, at }, the files agents read and edited lately
134
+ const TOUCHES_KEPT = 200;
113
135
  // The reviewer (spec 07): each branch's review at its head, and the agent type registered for it.
114
136
  const reviews = new Map(); // branch → { branch, head, story, state, startedAt, findings, why?, error? }
115
137
  let reviewerAgent = null; // the registered agent's full name
@@ -162,6 +184,9 @@ export function register(on) {
162
184
  const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
163
185
  return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
164
186
  };
187
+ // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
188
+ if ((await json('loop').catch(() => null))?.paused)
189
+ await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
165
190
  const stamp = async (path) => {
166
191
  const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
167
192
  return s ? `${path}@${s.mtimeMs}` : `${path}-`;
@@ -210,8 +235,22 @@ export function register(on) {
210
235
  base: status?.base ?? null,
211
236
  };
212
237
  if (model.state.kind === 'ok') await act.registerReviewer();
238
+ await act.readMaps();
213
239
  $.ui.invalidate('ui.render');
214
240
  },
241
+ // MAP-1, TRAIL-1: what the Code map and Trail tabs draw, read while they show.
242
+ readMaps: async () => {
243
+ if (model.state?.kind !== 'ok') return;
244
+ if (paneUi.tab === 'code') {
245
+ const id = mapStoryOf(stateNow(), paneUi.selected, paneUi.mapStory);
246
+ codeMap = id ? ((await json('map', id)) ?? codeMap) : null;
247
+ }
248
+ if (paneUi.tab === 'map') trailData = (await json('trail')) ?? trailData;
249
+ // JOURNAL-1, DECIDE-2: the lineage, for the Journal tab and the decisions' cards.
250
+ const deciding = held.size > 0 || (model.requests?.open ?? []).length > 0;
251
+ if (model.check?.docs?.plan && (['journal', 'decisions'].includes(paneUi.tab) || deciding))
252
+ journalData = (await json('journal')) ?? journalData;
253
+ },
215
254
  // REVW-2: the reviewer, read-only by the hooks' rule for agents outside the lanes. Registered
216
255
  // again when its model setting changes.
217
256
  registerReviewer: async () => {
@@ -374,6 +413,21 @@ export function register(on) {
374
413
  if (!placed.isPlaced) return { text: `The Lanes pane is waiting: ${placed.reason}` };
375
414
  return {};
376
415
  },
416
+ // DECIDE-3: the Decisions tab, on the decision the person asked about.
417
+ decide: async (line) => {
418
+ const cards = cardsNow(await $.clock.now());
419
+ const at = Math.max(
420
+ 0,
421
+ cards.findIndex((c) => c.line.kind === line?.kind),
422
+ );
423
+ paneUi = { ...paneUi, tab: 'decisions', decision: at, story: null };
424
+ if (!(await $.ui.panes()).some((p) => p.id === PANE_ID))
425
+ await $.ui.open({ id: PANE_ID, title: 'Lanes', focus: true, closeOnEscape: true });
426
+ $.ui.invalidate('ui.render');
427
+ await act.readMaps();
428
+ $.ui.invalidate('ui.render');
429
+ return {};
430
+ },
377
431
  // ACT-1: the confirmation, prefilled from the request.
378
432
  approve: async (line) => {
379
433
  approving = {
@@ -594,10 +648,26 @@ export function register(on) {
594
648
  if (box?.text?.trim()) return;
595
649
  if (loop.pending.length) {
596
650
  const { story, prompt } = loop.pending.shift();
651
+ // Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
652
+ // like any other step, not taken.
653
+ const chosenYet = (model.check?.harnessSet ?? []).includes('autonomy');
654
+ if (!chosenYet || h.autonomy === 'propose') {
655
+ loop.proposal = {
656
+ prompt,
657
+ label: `Restart ${story}`,
658
+ kind: 'restart',
659
+ target: story,
660
+ first: !chosenYet,
661
+ };
662
+ $.ui.invalidate('ui.render');
663
+ return;
664
+ }
597
665
  return act.submit(prompt, 'restart', story);
598
666
  }
599
667
  const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
600
668
  const step = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
669
+ // MISSION-1: the band shows what the loop takes next.
670
+ loop.nextStep = step;
601
671
  if (!step) {
602
672
  notice = `The loop is waiting: code-kit next failed (${(ran.stderr || ran.stdout).trim().split('\n')[0]})`;
603
673
  $.ui.invalidate('ui.render');
@@ -701,11 +771,14 @@ export function register(on) {
701
771
  pause: async () => {
702
772
  loop.paused = true;
703
773
  loop.proposal = null;
774
+ // Recorded, so other tools' background work in this project pauses with the loop.
775
+ await $.process.run(['node', cli, 'loop', 'pause'], { cwd }).catch(() => {});
704
776
  await act.record('pause', 'the loop');
705
777
  $.ui.invalidate('ui.render');
706
778
  },
707
779
  resume: async () => {
708
780
  loop.paused = false;
781
+ await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
709
782
  loop.lastKey = null;
710
783
  await act.record('resume', 'the loop');
711
784
  $.ui.invalidate('ui.render');
@@ -744,6 +817,12 @@ export function register(on) {
744
817
  a.at = now;
745
818
  continue;
746
819
  }
820
+ // LOOP-3: an agent whose work is done isn't stalled: it handed back, or its story is in review,
821
+ // in the merge queue or merged. Nothing to nudge or restart.
822
+ if (finishedWork(a)) {
823
+ a.at = now;
824
+ continue;
825
+ }
747
826
  const due = stallDue(now - a.at, a, restarts.get(a.story) ?? 0, h.stall);
748
827
  if (due === 'nudge') {
749
828
  a.nudged = true;
@@ -990,10 +1069,26 @@ export function register(on) {
990
1069
  busy.inFlight += 1;
991
1070
  busy.nudged = false;
992
1071
  busy.stopped = false;
1072
+ busy.handedBack = false;
993
1073
  // VIEW-4: the agent's current or last tool call.
994
1074
  busy.tool = toolLine(e);
995
1075
  busy.at = calledAt;
996
1076
  } else if (!e.agentId) leadLast = { tool: toolLine(e), at: calledAt };
1077
+ // MAP-2: the file the call reads or edits glows in the code map, in its lane's colour. An agent
1078
+ // first seen on this call has its lane found by its type.
1079
+ const touch = touchOf(e, where?.cwd);
1080
+ if (touch && busy && !busy.lane) {
1081
+ const agent = ((await $.agent.list().catch(() => [])) ?? []).find((a) => a.id === e.agentId);
1082
+ busy.lane =
1083
+ Object.entries(model.check?.lanes ?? {}).find(([, l]) => l.agent === agent?.type)?.[0] ??
1084
+ busy.lane;
1085
+ }
1086
+ const touchLane = busy ? busy.lane : e.agentId ? null : 'lead';
1087
+ if (touch && touchLane) {
1088
+ touches.push({ ...touch, lane: touchLane, at: calledAt });
1089
+ if (touches.length > TOUCHES_KEPT) touches.shift();
1090
+ if (paneUi.tab === 'code') $.ui.invalidate('ui.render');
1091
+ }
997
1092
  try {
998
1093
  const res = await next(e);
999
1094
  const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
@@ -1007,6 +1102,7 @@ export function register(on) {
1007
1102
  // HOLD-6: nobody to ask (claude -p, the SDK), or hold switched off: refused as today.
1008
1103
  if (!ask || !where?.interactive || minutes <= 0 || model.state?.kind !== 'ok') return res;
1009
1104
  const id = e.tool_use_id;
1105
+ let outsideLimits = null;
1010
1106
  // HOLD-5: under autonomy, the project's delegated rules decide first, without asking.
1011
1107
  if ((settings?.autonomy ?? 'autonomous') === 'autonomous') {
1012
1108
  const delegated = await $.process.run(
@@ -1023,6 +1119,8 @@ export function register(on) {
1023
1119
  { cwd: where.cwd, timeoutMs: 60000 },
1024
1120
  );
1025
1121
  if (delegated.exitCode === 0) return next(e);
1122
+ // DECIDE-2: why it's outside the limits the person set, for the decision's card.
1123
+ outsideLimits = (delegated.stderr || delegated.stdout || '').trim();
1026
1124
  }
1027
1125
  const agent = e.agentId ? agentsSeen.get(e.agentId) : null;
1028
1126
  held.set(id, {
@@ -1035,6 +1133,10 @@ export function register(on) {
1035
1133
  what: ask.what,
1036
1134
  since: await $.clock.now(),
1037
1135
  minutes,
1136
+ // DECIDE-2: the call itself, the rule that refused it, and why the lead couldn't approve it.
1137
+ command: e.command ?? e.file_path ?? null,
1138
+ refusal: text.split('\n')[0],
1139
+ outside: outsideLimits,
1038
1140
  });
1039
1141
  $.ui.invalidate('ui.render');
1040
1142
  // The wait is a process the band's answer ends (`code-kit hold`), so it isn't the hook's own time.
@@ -1123,6 +1225,9 @@ export function register(on) {
1123
1225
  $.ui.invalidate('ui.render');
1124
1226
  }
1125
1227
  const res = await next(e);
1228
+ // LOOP-3: a lane agent that has handed back its work isn't stalled while it waits; a message that
1229
+ // starts it again shows as its next tool call.
1230
+ if (e.agentId) activityOf(e.agentId, await $.clock.now()).handedBack = true;
1126
1231
  // REVW-3: a subagent's last turn may be the reviewer's report.
1127
1232
  if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
1128
1233
  // LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,
@@ -1166,6 +1271,9 @@ export function register(on) {
1166
1271
  flagged: [...flagged.values()],
1167
1272
  doneCount: loop.done?.count ?? 0,
1168
1273
  steps: loop.steps,
1274
+ // LANES-1: the step the lead is taking now (the loop's last prompt, while its turn runs), and the next.
1275
+ current: leadTurn && loop.started ? (loop.steps.at(-1) ?? null) : null,
1276
+ nextText: nextText(loop.nextStep),
1169
1277
  laneNotes,
1170
1278
  now,
1171
1279
  reviews: [...reviews.values()].map((r) => ({
@@ -1284,7 +1392,58 @@ export function register(on) {
1284
1392
  };
1285
1393
  };
1286
1394
  // The band's lines, which the pane pins as what needs the person (VIEW-5).
1287
- const linesNow = (now) =>
1395
+ // JOURNAL-1: the lineage from the project's records, with this session's loop steps and your own acts
1396
+ // in it, in time order.
1397
+ const STEP_WORDS = {
1398
+ dispatch: (t) => `The loop asked the lead to hand out ${t}.`,
1399
+ review: (t) => `The loop asked the lead to review ${t}.`,
1400
+ lead: (t) => `The loop asked the lead to build ${t} itself.`,
1401
+ merge: (t) => `The loop asked the lead to merge ${t}.`,
1402
+ restart: (t) => `The loop asked the lead to restart ${t}, which had gone quiet.`,
1403
+ nudge: (t) => `The loop nudged ${t}, which had gone quiet.`,
1404
+ flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
1405
+ pause: () => 'You paused the loop.',
1406
+ resume: () => 'You resumed the loop.',
1407
+ done: (t) => `Every story was done (${t}).`,
1408
+ };
1409
+ const journalNow = () => {
1410
+ const recorded = journalData?.events ?? [];
1411
+ const steps = loop.steps
1412
+ .filter((x) => STEP_WORDS[x.kind])
1413
+ .map((x) => ({
1414
+ at: x.at,
1415
+ kind: 'loop',
1416
+ who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
1417
+ text: STEP_WORDS[x.kind](x.target),
1418
+ }));
1419
+ return [...recorded, ...steps].sort((a, b) => a.at - b.at);
1420
+ };
1421
+ // DECIDE-2: the decisions that need the person, each with its lineage.
1422
+ const cardsNow = (now, lines = linesNowRaw(now)) => {
1423
+ const events = journalData?.events ?? [];
1424
+ const goal = goalOf(model.state?.stories ?? []);
1425
+ const asked =
1426
+ events.find((e) => e.kind === 'request')?.text.replace(/^You asked for /, '') ?? null;
1427
+ return decisionCards(lines, {
1428
+ state: model.state,
1429
+ status: model.status,
1430
+ check: model.check,
1431
+ goal: goal ? goal.name : null,
1432
+ request: asked,
1433
+ journal: events,
1434
+ inForce: model.requests?.inForce ?? [],
1435
+ });
1436
+ };
1437
+ // The band's lines, each that needs the person carrying which story it's for.
1438
+ const linesNow = (now) => {
1439
+ const lines = linesNowRaw(now);
1440
+ const cards = cardsNow(now, lines);
1441
+ return lines.map((l) => {
1442
+ const card = cards.find((c) => c.line === l);
1443
+ return card ? { ...l, context: contextOf(card) } : l;
1444
+ });
1445
+ };
1446
+ const linesNowRaw = (now) =>
1288
1447
  bandLines({
1289
1448
  ...model,
1290
1449
  reviewing,
@@ -1293,6 +1452,10 @@ export function register(on) {
1293
1452
  held: [...held.values()],
1294
1453
  now,
1295
1454
  loop: loopNow(now),
1455
+ leadBusy: Boolean(leadTurn),
1456
+ live: liveNow(now),
1457
+ nextStep: loop.nextStep,
1458
+ combined: combinedNow(),
1296
1459
  });
1297
1460
 
1298
1461
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
@@ -1307,7 +1470,10 @@ export function register(on) {
1307
1470
  usage,
1308
1471
  plan,
1309
1472
  loop: { ...loopNow(now), queue: model.queue ?? [] },
1310
- needs: linesNow(now),
1473
+ needs: linesNow(now).filter((l) => !l.mission),
1474
+ cards: cardsNow(now),
1475
+ journal: journalNow(),
1476
+ now,
1311
1477
  live: liveNow(now),
1312
1478
  held: [...held.values()],
1313
1479
  requests: model.requests?.open ?? [],
@@ -1322,6 +1488,11 @@ export function register(on) {
1322
1488
  },
1323
1489
  art: artNow(now, liveNow(now)),
1324
1490
  combined: combinedNow(),
1491
+ codeMap,
1492
+ trail: trailData,
1493
+ glow: glowOf(touches, now),
1494
+ surface: paneSurface,
1495
+ columns: paneColumns,
1325
1496
  placement: e.props.placement,
1326
1497
  story: storyOpen ? storyNow(storyOpen, now, usage) : null,
1327
1498
  },
@@ -1330,6 +1501,29 @@ export function register(on) {
1330
1501
  onTab: (tab) => {
1331
1502
  paneUi = { ...paneUi, tab };
1332
1503
  $.ui.invalidate('ui.render');
1504
+ act?.readMaps().then(() => $.ui.invalidate('ui.render'));
1505
+ },
1506
+ onDecision: (i) => {
1507
+ paneUi = { ...paneUi, decision: i };
1508
+ $.ui.invalidate('ui.render');
1509
+ },
1510
+ onMapStory: (id) => {
1511
+ paneUi = { ...paneUi, mapStory: id, mapFile: null };
1512
+ $.ui.invalidate('ui.render');
1513
+ act?.readMaps().then(() => $.ui.invalidate('ui.render'));
1514
+ },
1515
+ onMapFile: (path) => {
1516
+ paneUi = { ...paneUi, mapFile: path };
1517
+ $.ui.invalidate('ui.render');
1518
+ },
1519
+ // MAP-3: the file in Context Graph's own view, through the command its adapter names.
1520
+ onOpenFile: (path) => {
1521
+ const command = codeMap?.open?.command;
1522
+ if (!command) return;
1523
+ $.command.run({ command, args: path }).catch((err) => {
1524
+ notice = `Couldn't open ${path}: ${err?.message ?? err}`;
1525
+ $.ui.invalidate('ui.render');
1526
+ });
1333
1527
  },
1334
1528
  onFilter: (state) => {
1335
1529
  paneUi = { ...paneUi, filter: paneUi.filter === state ? null : state };