@warren-dean/code-kit 0.21.0 → 0.22.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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +46 -23
  5. package/bin/code-kit.mjs +271 -21
  6. package/hooks/guard-agent.mjs +36 -0
  7. package/hooks/guard-bash.mjs +23 -11
  8. package/hooks/guard-paths.mjs +11 -0
  9. package/hooks/hooks.json +20 -0
  10. package/hooks/lane-audit.mjs +38 -1
  11. package/hooks/lib/adapters/hub/client.mjs +177 -25
  12. package/hooks/lib/config.mjs +148 -5
  13. package/hooks/lib/decisionstore.mjs +89 -0
  14. package/hooks/lib/harness.mjs +7 -0
  15. package/hooks/lib/hook.mjs +7 -0
  16. package/hooks/lib/instructions.mjs +1 -1
  17. package/hooks/lib/laneclaims.mjs +89 -0
  18. package/hooks/lib/loop-state.mjs +20 -2
  19. package/hooks/lib/next.mjs +34 -1
  20. package/hooks/lib/plan.mjs +3 -1
  21. package/hooks/lib/questions.mjs +23 -5
  22. package/hooks/lib/regression.mjs +2 -1
  23. package/hooks/lib/rules.mjs +18 -12
  24. package/hooks/lib/schedule.mjs +86 -0
  25. package/hooks/lib/sessions.mjs +223 -0
  26. package/hooks/lib/snapshot.mjs +9 -3
  27. package/hooks/mod/decisions.mjs +1 -28
  28. package/hooks/mod/loop.mjs +8 -0
  29. package/hooks/mod/mission.mjs +21 -19
  30. package/hooks/mod/register.mjs +118 -41
  31. package/hooks/mod/register.test.ts +244 -73
  32. package/hooks/mod/view.mjs +98 -47
  33. package/hooks/post-edit-check.mjs +3 -0
  34. package/hooks/session.mjs +24 -0
  35. package/hooks/stop-check.mjs +12 -0
  36. package/package.json +1 -1
  37. package/skills/decide/SKILL.md +1 -1
  38. package/skills/dispatch/SKILL.md +2 -2
  39. package/skills/init/SKILL.md +1 -1
  40. package/skills/lead/SKILL.md +28 -0
  41. package/skills/next/SKILL.md +1 -1
  42. package/skills/spec-design/SKILL.md +2 -1
  43. package/skills/team-init/SKILL.md +1 -1
  44. package/templates/claude-md.md +3 -1
  45. package/templates/plan.md +2 -1
@@ -70,6 +70,7 @@ import {
70
70
  parkWatch,
71
71
  readOutcome,
72
72
  restartPrompt,
73
+ stalledPrompt,
73
74
  stallDue,
74
75
  stepKey,
75
76
  stepLabel,
@@ -143,6 +144,13 @@ const loop = {
143
144
  // LOOP-10: whether this session runs the loop, the session that does when another does, and when this
144
145
  // one last renewed its lease.
145
146
  const lease = { mine: false, other: null, renewed: -Infinity };
147
+ // SESS-2: this session's role in the project, `lead` or `working`, or null where code-kit has no record.
148
+ let role = null;
149
+ /** SESS-3: a working session, or one another session leads, stands by: no loop, dialogs or notifications. */
150
+ const standingBy = () => !lease.mine && (Boolean(lease.other) || role === 'working');
151
+ /** SESS-1: how often the mod says the session is still there. */
152
+ const BEAT_MS = 60000;
153
+ let beaten = -Infinity;
146
154
  const LEASE_RENEW_MS = 30000;
147
155
  // LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
148
156
  let lastReply = null;
@@ -152,8 +160,13 @@ const offered = new Set();
152
160
  let offering = false;
153
161
  // NOTIFY-1: the decisions waiting at the last look, by key; null until the first look.
154
162
  let notified = null;
155
- /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
156
- const ASK_LIMIT = 2;
163
+ /** LOOP-8, WHO-4: the times in a row the lead is prompted with one step before it shows as a note. */
164
+ const ASK_LIMIT = 3;
165
+ /** OWN-2: the calls Claude Code refused that the Lanes pane remembers. */
166
+ const DENIED_KEPT = 20;
167
+ /** SCHED-1: how often, and how long after its claim, a lane with no agent running is freed. */
168
+ const LANES_FREE_MS = 60000;
169
+ let lanesFreed = -Infinity;
157
170
  let leadTurn = null; // the lead's running turn, if any
158
171
  const activity = new Map(); // agent id → { at, inFlight, nudged, stopped, lane, story }
159
172
  const restarts = new Map(); // story → times the loop restarted it
@@ -259,7 +272,8 @@ export function register(on) {
259
272
  const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
260
273
  // A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
261
274
  // by a session that still runs the loop (LOOP-10) is that session's, and stays.
262
- const recorded = await json('loop').catch(() => null);
275
+ const recorded = await json('loop', '--session', session).catch(() => null);
276
+ role = recorded?.role ?? null;
263
277
  if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
264
278
  await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
265
279
  lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
@@ -288,9 +302,18 @@ export function register(on) {
288
302
  const status = valid ? await json('status') : null;
289
303
  const nextStep = valid ? await json('next') : null;
290
304
  const all = valid ? await json('requests') : null;
305
+ // OWN-1: this session's decisions, and how many wait in each other session; null from a code-kit
306
+ // that can't say, where every open decision shows.
307
+ const owned = valid
308
+ ? await json('decisions', '--session', session).catch(() => null)
309
+ : null;
310
+ const mine = (kind) =>
311
+ (owned?.mine ?? []).filter((d) => d.kind === kind).map((d) => d.source);
312
+ const ownRequest = (r) =>
313
+ !owned || mine('approve').some((o) => o.at === r.at && o.what === r.what);
291
314
  // HELM-6: the person sees only their own decisions; the lead decides the rest.
292
315
  const requests = all
293
- ? { ...all, open: (all.open ?? []).filter((r) => r.by !== 'lead') }
316
+ ? { ...all, open: (all.open ?? []).filter((r) => r.by !== 'lead' && ownRequest(r)) }
294
317
  : null;
295
318
  const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
296
319
  const queue = valid ? ((await json('queue')) ?? []) : [];
@@ -302,11 +325,13 @@ export function register(on) {
302
325
  ? ((await json('adapters', '--session', session)) ?? [])
303
326
  : [];
304
327
  // TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
305
- const team = valid && check.team ? await json('team') : null;
328
+ const team = valid && check.team ? await json('team', '--session', session) : null;
306
329
  // ASK-2: the lead's questions waiting for the person; REPORT-3: bug reports waiting to be sent.
307
- const questions = valid
308
- ? ((await json('ask')) ?? []).filter((q) => q.state === 'open')
309
- : [];
330
+ const questions = !valid
331
+ ? []
332
+ : owned
333
+ ? mine('answer')
334
+ : ((await json('ask')) ?? []).filter((q) => q.state === 'open');
310
335
  const reports = valid
311
336
  ? ((await json('report')) ?? []).filter((r) => r.state === 'draft')
312
337
  : [];
@@ -319,9 +344,16 @@ export function register(on) {
319
344
  }
320
345
  // LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
321
346
  if (!lease.mine) {
322
- const lead = (await json('loop').catch(() => null))?.lead ?? null;
347
+ const recorded = await json('loop', '--session', session).catch(() => null);
348
+ const lead = recorded?.lead ?? null;
323
349
  lease.other = lead && lead.session !== session ? lead : null;
350
+ role = recorded?.role ?? role;
324
351
  }
352
+ // SESS-4: a working session's own task, for its band.
353
+ const task =
354
+ valid && role === 'working'
355
+ ? await json('task', '--session', session).catch(() => null)
356
+ : null;
325
357
  // The agents at work; none where the session has none bound (claude -p before it mounts).
326
358
  const atWork = agentsAtWork(await $.agent.list().catch(() => []));
327
359
  model = {
@@ -339,6 +371,11 @@ export function register(on) {
339
371
  // ASK-4: the brief's open questions, less those put off this session.
340
372
  brief: (nextStep?.brief ?? []).filter((b) => !briefLater.has(b.question)),
341
373
  base: status?.base ?? null,
374
+ // SESS-3, SESS-4: a working session's band holds its own, and its task.
375
+ working: role === 'working',
376
+ task,
377
+ // OWN-1: what waits in the other sessions, as counts.
378
+ elsewhere: owned?.elsewhere ?? [],
342
379
  };
343
380
  if (model.state.kind === 'ok') await act.registerReviewer();
344
381
  await act.readMaps();
@@ -706,28 +743,6 @@ export function register(on) {
706
743
  await act.reload();
707
744
  return true;
708
745
  },
709
- // PERM-1: a call Claude Code refused, which only the person can run or allow.
710
- runDenied: async (line) => {
711
- await $.prompt.fill({ text: `! ${line.denial.command}`, mode: 'replace' }).catch(() => {});
712
- },
713
- deniedDone: async (line) => {
714
- denied.delete(line.denial.id);
715
- $.ui.invalidate('ui.render');
716
- $.prompt
717
- .submit({
718
- 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.`,
719
- })
720
- .catch(() => {});
721
- },
722
- deniedNo: async (line) => {
723
- denied.delete(line.denial.id);
724
- $.ui.invalidate('ui.render');
725
- $.prompt
726
- .submit({
727
- 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.`,
728
- })
729
- .catch(() => {});
730
- },
731
746
  // REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
732
747
  sendReport: async (line) => {
733
748
  const ran = await $.process.run(['node', cli, 'report', 'send', line.report.id], {
@@ -1206,7 +1221,7 @@ export function register(on) {
1206
1221
  // LEAN-4: past compact.at, the lead's context is compacted between turns, milestone or not.
1207
1222
  if (!fresh.length) return act.compactAtThreshold();
1208
1223
  const mode = harness().compact?.milestone ?? 'ask';
1209
- if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
1224
+ if (mode === 'off' || offering || !where.interactive || standingBy()) {
1210
1225
  for (const m of fresh) loop.milestones.add(m);
1211
1226
  return act.compactAtThreshold();
1212
1227
  }
@@ -1246,7 +1261,7 @@ export function register(on) {
1246
1261
  // LEAN-4: the lead's context compacted at once when it's past compact.at; true when it took over.
1247
1262
  compactAtThreshold: async () => {
1248
1263
  const at = harness().compact?.at ?? 40;
1249
- if (!at || offering || !where.interactive || (lease.other && !lease.mine)) return false;
1264
+ if (!at || offering || !where.interactive || standingBy()) return false;
1250
1265
  const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
1251
1266
  if (percent < Math.max(at, COMPACT_FLOOR)) return false;
1252
1267
  const box = await $.prompt.read().catch(() => null);
@@ -1394,7 +1409,9 @@ export function register(on) {
1394
1409
  const first = notified === null;
1395
1410
  notified = new Set(cards.map(decisionKey));
1396
1411
  if (first || !fresh.length || harness().notify?.on === false) return;
1397
- if (lease.other && !lease.mine) return;
1412
+ // OWN-5: a working session raises its own decisions, which are all its band holds; a session
1413
+ // another one leads raises none of the project's.
1414
+ if (standingBy() && role !== 'working') return;
1398
1415
  const one =
1399
1416
  fresh.length === 1
1400
1417
  ? fresh[0]
@@ -1408,7 +1425,7 @@ export function register(on) {
1408
1425
  },
1409
1426
  offer: async () => {
1410
1427
  if (offering || leadTurn || loop.sent || !where.interactive) return;
1411
- if (lease.other && !lease.mine) return;
1428
+ if (standingBy() && role !== 'working') return;
1412
1429
  const now = await $.clock.now();
1413
1430
  const choice = cardsNow(now)
1414
1431
  .map((card) => ({ card, choice: nativeChoice(card) }))
@@ -1532,8 +1549,9 @@ export function register(on) {
1532
1549
  if (key === loop.lastKey && print === loop.lastPrint) return;
1533
1550
  loop.lastKey = key;
1534
1551
  loop.lastPrint = print;
1535
- // LOOP-8: a step the lead was asked for twice and that is still next didn't take: the project
1536
- // moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
1552
+ // LOOP-8, WHO-4: a step the lead was asked for three times and that is still next didn't take:
1553
+ // the project moving elsewhere (lanes committing) doesn't make a fourth prompt useful. It shows
1554
+ // as a note; the person may ask again.
1537
1555
  if (loop.asked.times >= ASK_LIMIT) {
1538
1556
  if (loop.proposal?.label !== label) {
1539
1557
  loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
@@ -1627,6 +1645,36 @@ export function register(on) {
1627
1645
  await act.record('resume', story);
1628
1646
  $.ui.invalidate('ui.render');
1629
1647
  },
1648
+ // SESS-1: the session is still there, while it's open: the other sessions see it live.
1649
+ beat: async () => {
1650
+ const at = await $.clock.now();
1651
+ if (beaten === -Infinity) beaten = at;
1652
+ if (at - beaten < BEAT_MS || model.state?.kind !== 'ok') return;
1653
+ beaten = at;
1654
+ await $.process.run(['node', cli, 'sessions', 'beat', '--session', session], { cwd });
1655
+ },
1656
+ // SCHED-1: a lane this session's agent claimed, with no agent of that lane still running here for a
1657
+ // minute (stopped with TaskStop, or gone without its finish), is released for its next story.
1658
+ freeLanes: async () => {
1659
+ const at = await $.clock.now();
1660
+ // The first look only starts the clock: a session's first minute frees nothing.
1661
+ if (lanesFreed === -Infinity) lanesFreed = at;
1662
+ if (at - lanesFreed < LANES_FREE_MS || model.state?.kind !== 'ok') return;
1663
+ lanesFreed = at;
1664
+ const claims = ((await json('lanes')) ?? []).filter((c) => c.session === session);
1665
+ if (!claims.length) return;
1666
+ const running = new Set(
1667
+ ((await $.agent.list().catch(() => [])) ?? [])
1668
+ .filter((a) => a.status === 'running')
1669
+ .map((a) => a.type),
1670
+ );
1671
+ for (const c of claims) {
1672
+ const type = model.check?.lanes?.[c.lane]?.agent;
1673
+ if (!type || running.has(type) || at - Date.parse(c.since) < LANES_FREE_MS) continue;
1674
+ await $.process.run(['node', cli, 'lanes', 'release', c.lane], { cwd }).catch(() => {});
1675
+ await act.record('free', `${c.lane}${c.story ? ` · ${c.story}` : ''}`);
1676
+ }
1677
+ },
1630
1678
  // LOOP-3: each lane agent at work, by the time since its last tool call.
1631
1679
  stalls: async () => {
1632
1680
  const h = harness();
@@ -1642,6 +1690,12 @@ export function register(on) {
1642
1690
  const a = activityOf(agent.id, now);
1643
1691
  a.lane = lane;
1644
1692
  a.story = attribution(agent.id, agent, model.check, model.state).story ?? lane;
1693
+ // WHO-4: the lead dispatched the stalled story again: a new agent on it ends the note.
1694
+ const flag = flagged.get(a.story);
1695
+ if (flag && flag.agentId !== agent.id) {
1696
+ flagged.delete(a.story);
1697
+ restarts.set(a.story, 0);
1698
+ }
1645
1699
  // A held call, or a long command still running, isn't quiet.
1646
1700
  if (a.inFlight > 0 || waitingOnPerson.has(agent.id)) {
1647
1701
  a.at = now;
@@ -1677,7 +1731,20 @@ export function register(on) {
1677
1731
  await act.record('restart', `${lane} · ${a.story} (${n}/${h.stall.maxRestarts})`);
1678
1732
  } else if (due === 'flag') {
1679
1733
  a.stopped = true;
1680
- flagged.set(a.story, { lane, story: a.story, count: (restarts.get(a.story) ?? 0) + 1 });
1734
+ const count = (restarts.get(a.story) ?? 0) + 1;
1735
+ flagged.set(a.story, { lane, story: a.story, count, agentId: agent.id });
1736
+ // WHO-4: the lead sorts it out; the person sees a note.
1737
+ loop.pending.push({
1738
+ story: a.story,
1739
+ prompt: stalledPrompt({
1740
+ type: agent.type,
1741
+ id: agent.id,
1742
+ lane,
1743
+ story: a.story,
1744
+ restarts: restarts.get(a.story) ?? 0,
1745
+ last: a.tool ?? null,
1746
+ }),
1747
+ });
1681
1748
  await act.record('flag', `${lane} · ${a.story}`);
1682
1749
  }
1683
1750
  if (due) $.ui.invalidate('ui.render');
@@ -1874,7 +1941,9 @@ export function register(on) {
1874
1941
  if (lease.mine) await act.lead();
1875
1942
  if (!leadTurn) act.offer().catch(() => {});
1876
1943
  await act.notifyNew().catch(() => {});
1944
+ await act.beat().catch(() => {});
1877
1945
  await act.stalls();
1946
+ await act.freeLanes().catch(() => {});
1878
1947
  await act.animate();
1879
1948
  const now = await act.fingerprint();
1880
1949
  const paneOpen = (await $.ui.panes()).some((p) => [PANE_ID, TEAM_ID].includes(p.id));
@@ -1946,6 +2015,8 @@ export function register(on) {
1946
2015
  e.tool_name === 'Bash' && typeof input.command === 'string' ? input.command : null;
1947
2016
  const path = input.file_path ?? input.notebook_path ?? null;
1948
2017
  const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
2018
+ // OWN-2: only the latest few are kept: each is activity, not something waiting on the person.
2019
+ if (denied.size >= DENIED_KEPT) denied.delete(denied.keys().next().value);
1949
2020
  denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
1950
2021
  id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
1951
2022
  agentId: e.agent_id ?? null,
@@ -2248,14 +2319,21 @@ export function register(on) {
2248
2319
  const state =
2249
2320
  h.autonomy === 'off'
2250
2321
  ? 'off'
2251
- : lease.other && !lease.mine
2252
- ? 'elsewhere'
2322
+ : standingBy()
2323
+ ? lease.other
2324
+ ? 'elsewhere'
2325
+ : 'unled'
2253
2326
  : loop.done
2254
2327
  ? 'done'
2255
2328
  : loop.paused
2256
2329
  ? 'paused'
2257
2330
  : 'running';
2258
2331
  const laneNotes = {};
2332
+ // OWN-2: the latest call Claude Code refused in each lane, as its activity.
2333
+ for (const d of denied.values()) {
2334
+ const lane = d.agentId ? (activity.get(d.agentId)?.lane ?? null) : 'lead';
2335
+ if (lane) laneNotes[lane] = `Claude Code refused ${d.what}`;
2336
+ }
2259
2337
  for (const a of activity.values()) {
2260
2338
  if (!a.lane) continue;
2261
2339
  const n = restarts.get(a.story) ?? 0;
@@ -2463,7 +2541,6 @@ export function register(on) {
2463
2541
  nextStep: loop.nextStep,
2464
2542
  combined: combinedNow(),
2465
2543
  team: model.team ?? null,
2466
- denied: [...denied.values()],
2467
2544
  });
2468
2545
 
2469
2546
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {