@warren-dean/code-kit 0.16.1 → 0.18.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.
@@ -437,3 +437,32 @@ export function nativeChoice(card) {
437
437
  card.kind === 'question' ? 'answerTyped' : card.kind === 'waiting' ? 'answerLeadTyped' : null,
438
438
  };
439
439
  }
440
+
441
+ /** NOTIFY-1: a decision's identity across refreshes: its kind and what it asks. */
442
+ export const decisionKey = (card) => `${card.kind}|${card.headline}`;
443
+
444
+ /**
445
+ * NOTIFY-1: the decisions that weren't waiting at the last look, each as a system notification: what it
446
+ * asks, headed by what kind of decision it is. `seen` holds the keys from the last look.
447
+ */
448
+ export function newDecisions(cards, seen) {
449
+ return cards
450
+ .filter((c) => NOTIFY_TITLE[c.kind] && !seen.has(decisionKey(c)))
451
+ .map((c) => ({
452
+ key: decisionKey(c),
453
+ title: `code-kit: ${NOTIFY_TITLE[c.kind] ?? 'a decision for you'}`,
454
+ text: `${c.headline}${c.story ? ` (${c.story.id})` : ''}`,
455
+ }));
456
+ }
457
+
458
+ const NOTIFY_TITLE = {
459
+ held: 'an approval to give',
460
+ approvals: 'an approval to give',
461
+ question: 'the lead asks you',
462
+ brief: 'a question in your brief',
463
+ waiting: 'the lead waits on you',
464
+ stalled: 'a lane stalled',
465
+ denied: 'Claude Code refused a call',
466
+ proposal: 'a step to approve',
467
+ queue: 'a merge for you',
468
+ };
@@ -4,6 +4,7 @@
4
4
 
5
5
  /** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
6
6
  const LEAD_STEPS = new Set([
7
+ 'instructions',
7
8
  'decide',
8
9
  'dispatch',
9
10
  'review',
@@ -96,13 +97,16 @@ export function parkWatch(id, on = [], stories = []) {
96
97
  }
97
98
 
98
99
  /**
99
- * LOOP-13: where the watched stories stand, as one string: each one's state, its branch's commit and
100
- * whether an agent is at work on it. A parked story wakes when this changes.
100
+ * LOOP-13: where the watched stories stand, as one string. The parked story itself (the first) counts
101
+ * its state, its branch's commit and whether an agent is at work on it; each story it waits on counts its
102
+ * state alone, so the commits of a story still being built don't wake the one waiting for it to finish.
103
+ * A parked story wakes when this changes.
101
104
  */
102
105
  export function storiesSignature(ids, stories = [], heads = new Map(), inFlight = new Set()) {
103
106
  return ids
104
- .map((id) => {
107
+ .map((id, i) => {
105
108
  const s = stories.find((x) => x.id === id);
109
+ if (i > 0) return `${id}:${s?.state ?? '?'}`;
106
110
  return `${id}:${s?.state ?? '?'}:${s?.branch ? (heads.get(s.branch) ?? '') : ''}:${inFlight.has(id) ? 'busy' : ''}`;
107
111
  })
108
112
  .join('|');
@@ -134,6 +138,14 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
134
138
  // SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
135
139
  if (step.step === 'rules')
136
140
  return `Put code-kit's approved rules onto ${ids[0] ?? 'the base'}: run node "${cli}" queue rules. It commits the rules as committed on this branch, without switching branches; after it, merge the queue as usual.`;
141
+ // ONB-8: code-kit's instructions brought up to date, then committed like any change to the kit.
142
+ if (step.step === 'instructions') {
143
+ const how =
144
+ step.args === 'merge'
145
+ ? `Run node "${cli}" instructions to see which files, then merge each one: replace its old code-kit part with what node "${cli}" instructions show lead (CLAUDE.md) or show lane (an agent file) prints, and keep every line the project wrote itself outside the markers, under a heading of its own. Then run node "${cli}" instructions --write for the rest.`
146
+ : `Run node "${cli}" instructions --write.`;
147
+ return `${step.why ?? "code-kit's instructions are behind."} ${how} Commit the change on a branch and merge it like your own stories, so new lane agents start with the current rules.`;
148
+ }
137
149
  // HELM-4: a lane waits on the lead's decision; the decide skill takes each request on its merits.
138
150
  if (step.step === 'decide')
139
151
  return ids.length
@@ -175,6 +187,7 @@ export function stepLabel(step) {
175
187
  const verb =
176
188
  {
177
189
  decide: 'Decide requests',
190
+ instructions: 'Update the instructions',
178
191
  dispatch: 'Dispatch',
179
192
  review: 'Review',
180
193
  lead: 'Build',
@@ -310,3 +323,27 @@ export function laneModel({ type, model = null, lanes = {}, setting = null, file
310
323
  if (fileModel && fileModel !== 'inherit') return null;
311
324
  return setting;
312
325
  }
326
+
327
+ /** LEAN-3: compacting a context below this share of its window isn't worth the summary it costs. */
328
+ export const COMPACT_FLOOR = 25;
329
+
330
+ /**
331
+ * LEAN-3: the milestones every story of which is done, in the plan's order. A milestone with no stories
332
+ * isn't one.
333
+ */
334
+ export function milestonesDone(stories = []) {
335
+ const by = new Map();
336
+ for (const s of stories) {
337
+ if (!s.milestone) continue;
338
+ by.set(s.milestone, [...(by.get(s.milestone) ?? []), s]);
339
+ }
340
+ return [...by].filter(([, list]) => list.every((s) => s.state === 'done')).map(([m]) => m);
341
+ }
342
+
343
+ /** LEAN-3: what the lead's summary keeps when its context is compacted after a milestone. */
344
+ export const compactInstructions = (milestone) =>
345
+ [
346
+ `${milestone} is done; the build goes on with the next milestone.`,
347
+ 'Keep: the milestone just finished, in a line; every story in flight now, with its lane, its branch and where it stands (building, in review, in the merge queue, waiting and on what); the decisions and questions waiting on the person; what the person decided or asked for in this session that still applies; and the rules and preferences they set.',
348
+ "Drop: the finished stories' details, lane reports and hand-backs, command output, diffs and file contents. Each is in git, the plan or the lanes' report files, to read again when it's needed.",
349
+ ].join('\n');
@@ -120,6 +120,8 @@ export function laneActions(lane, { queue = [], held = [], requests = [] } = {})
120
120
  const head = queue.find((e) => ['waiting', 'merging'].includes(e.state));
121
121
  if (head && head.branch === lane.branch) acts.push({ id: 'merge', label: 'Merge', hotkey: 'm' });
122
122
  if (lane.active && lane.agent) {
123
+ // WATCH-1: the lane agent's own transcript, in the main view.
124
+ acts.push({ id: 'watch', label: 'Watch', hotkey: 'w' });
123
125
  acts.push({ id: 'nudge', label: 'Nudge', hotkey: 'n' });
124
126
  acts.push({ id: 'stop', label: 'Stop', hotkey: 'x' });
125
127
  }
@@ -58,7 +58,10 @@ import {
58
58
  leadPrompt,
59
59
  nudgeText,
60
60
  outcomePrompt,
61
+ COMPACT_FLOOR,
61
62
  PARK_MS,
63
+ compactInstructions,
64
+ milestonesDone,
62
65
  parkWatch,
63
66
  readOutcome,
64
67
  restartPrompt,
@@ -71,7 +74,7 @@ import {
71
74
  withoutInFlight,
72
75
  } from './loop.mjs';
73
76
  import { goalOf, nextText } from './mission.mjs';
74
- import { contextOf, decisionCards, nativeChoice } from './decisions.mjs';
77
+ import { contextOf, decisionCards, decisionKey, nativeChoice, newDecisions } from './decisions.mjs';
75
78
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
76
79
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
77
80
  import { harnessSettings } from '../lib/harness.mjs';
@@ -128,6 +131,8 @@ const loop = {
128
131
  handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
129
132
  // LOOP-13: stories the lead said wait on other work: id → { on, why, watch, sig, at }
130
133
  parked: new Map(),
134
+ // LEAN-3: the milestones already done when this session first looked, and those finished since.
135
+ milestones: null,
131
136
  };
132
137
  // LOOP-10: whether this session runs the loop, the session that does when another does, and when this
133
138
  // one last renewed its lease.
@@ -139,6 +144,8 @@ let turnQuestions = []; // the open questions as the lead's running turn started
139
144
  // DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
140
145
  const offered = new Set();
141
146
  let offering = false;
147
+ // NOTIFY-1: the decisions waiting at the last look, by key; null until the first look.
148
+ let notified = null;
142
149
  /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
143
150
  const ASK_LIMIT = 2;
144
151
  let leadTurn = null; // the lead's running turn, if any
@@ -1058,10 +1065,19 @@ export function register(on) {
1058
1065
  ) => {
1059
1066
  loop.started = true;
1060
1067
  loop.proposal = null;
1061
- loop.sent = { prompt, key, label, kind, target };
1068
+ const sent = { prompt, key, label, kind, target, before: new Map() };
1069
+ loop.sent = sent;
1062
1070
  // LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
1063
1071
  if (kind === 'design')
1064
1072
  for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
1073
+ // LOOP-13: where the step's stories stood when the lead was prompted, to tell after its turn
1074
+ // whether it moved them.
1075
+ // A design is made with the person over many turns (LOOP-12), so it's never counted as unmoved.
1076
+ const ids = kind === 'design' ? [] : stepStories({ kind, target });
1077
+ if (ids.length) {
1078
+ const seen = await act.storiesNow();
1079
+ for (const id of ids) sent.before.set(id, seen.sig([id]));
1080
+ }
1065
1081
  await act.record(kind, target, prompt);
1066
1082
  $.ui.invalidate('ui.render');
1067
1083
  $.prompt.submit({ text: prompt }).catch((err) => {
@@ -1136,6 +1152,16 @@ export function register(on) {
1136
1152
  }
1137
1153
  $.ui.invalidate('ui.render');
1138
1154
  },
1155
+ // LOOP-13: the stories of the step the lead was prompted with that its turn left where they stood.
1156
+ parkUnmoved: async (sent, reason) => {
1157
+ if (!sent?.before?.size) return;
1158
+ const seen = await act.storiesNow();
1159
+ await act.park(
1160
+ [...sent.before]
1161
+ .filter(([id, sig]) => !loop.parked.has(id) && seen.sig([id]) === sig)
1162
+ .map(([id]) => ({ id, on: [], why: reason ?? "the lead's turn didn't move it" })),
1163
+ );
1164
+ },
1139
1165
  // LOOP-13: a parked story wakes when anything it watches moves, or after PARK_MS with nothing moving.
1140
1166
  unpark: async () => {
1141
1167
  if (!loop.parked.size) return;
@@ -1144,6 +1170,55 @@ export function register(on) {
1144
1170
  for (const [id, p] of loop.parked)
1145
1171
  if (now - p.at >= PARK_MS || seen.sig(p.watch) !== p.sig) loop.parked.delete(id);
1146
1172
  },
1173
+ // LEAN-3: a milestone finished in this session: the lead's context is compacted before the next,
1174
+ // asked in Claude Code's dialog or done at once, as compact.milestone says. True when it took over
1175
+ // the turn's end, so the loop's step waits until the compaction is done or declined.
1176
+ compactAtMilestone: async () => {
1177
+ const done = milestonesDone(model.state?.stories ?? []);
1178
+ if (loop.milestones === null) {
1179
+ loop.milestones = new Set(done);
1180
+ return false;
1181
+ }
1182
+ const fresh = done.filter((m) => !loop.milestones.has(m));
1183
+ if (!fresh.length) return false;
1184
+ const mode = harness().compact?.milestone ?? 'ask';
1185
+ if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
1186
+ for (const m of fresh) loop.milestones.add(m);
1187
+ return false;
1188
+ }
1189
+ const box = await $.prompt.read().catch(() => null);
1190
+ if (box?.text?.trim()) return false;
1191
+ for (const m of fresh) loop.milestones.add(m);
1192
+ const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
1193
+ if (percent < COMPACT_FLOOR) return false;
1194
+ const milestone = fresh.at(-1);
1195
+ const compact = async () => {
1196
+ const r = await $.session
1197
+ .compact({ instructions: compactInstructions(milestone) })
1198
+ .catch((err) => ({ skip: String(err?.message ?? err) }));
1199
+ if (r?.skip) notice = `The lead's context wasn't compacted: ${r.skip}`;
1200
+ await act.record('compact', milestone);
1201
+ $.ui.invalidate('ui.render');
1202
+ await act.step('resume').catch(() => {});
1203
+ };
1204
+ if (mode === 'auto') {
1205
+ compact().catch(() => {});
1206
+ return true;
1207
+ }
1208
+ offering = true;
1209
+ $.ui
1210
+ .ask(
1211
+ `${milestone} is done, and the lead's context is ${Math.round(percent)}% full. Compact it before the next milestone? The summary keeps what's in flight and what waits on you, and drops the finished stories' detail.`,
1212
+ { options: ['Compact now', 'Not now'], header: 'Context' },
1213
+ )
1214
+ .catch(() => null)
1215
+ .then(async (answer) => {
1216
+ offering = false;
1217
+ if (answer === 'Compact now') await compact();
1218
+ else await act.step('resume').catch(() => {});
1219
+ });
1220
+ return true;
1221
+ },
1147
1222
  // LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
1148
1223
  // the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
1149
1224
  // holds that step while it's still next.
@@ -1182,7 +1257,7 @@ export function register(on) {
1182
1257
  $.ui.invalidate('ui.render');
1183
1258
  return;
1184
1259
  }
1185
- if (!reply.text.trim()) return;
1260
+ if (!reply.text.trim()) return act.parkUnmoved(sent, null);
1186
1261
  const said = await act.sideCall(
1187
1262
  outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
1188
1263
  );
@@ -1205,6 +1280,10 @@ export function register(on) {
1205
1280
  })),
1206
1281
  );
1207
1282
  }
1283
+ // LOOP-13: a story the lead's turn left exactly where it stood wasn't taken, whatever the reply
1284
+ // reads as; it's parked like one the lead said waits, so the loop doesn't prompt it again until
1285
+ // it moves.
1286
+ if (heard?.outcome !== 'needs-person') await act.parkUnmoved(sent, heard?.reason);
1208
1287
  if (heard?.outcome === 'needs-person')
1209
1288
  loop.waiting = {
1210
1289
  kind: 'person',
@@ -1256,6 +1335,28 @@ export function register(on) {
1256
1335
  // DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
1257
1336
  // dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
1258
1337
  // Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
1338
+ // NOTIFY-1: what newly needs the person, as a system notification, from the session that runs the
1339
+ // loop. What waited before this session looked isn't news. On a Claude Code without $.ui.notify,
1340
+ // the call fails and nothing is raised.
1341
+ notifyNew: async () => {
1342
+ if (model.state?.kind !== 'ok' || !where.interactive) return;
1343
+ const cards = cardsNow(await $.clock.now());
1344
+ const fresh = newDecisions(cards, notified ?? new Set());
1345
+ const first = notified === null;
1346
+ notified = new Set(cards.map(decisionKey));
1347
+ if (first || !fresh.length || harness().notify?.on === false) return;
1348
+ if (lease.other && !lease.mine) return;
1349
+ const one =
1350
+ fresh.length === 1
1351
+ ? fresh[0]
1352
+ : {
1353
+ title: `code-kit: ${fresh.length} things need you`,
1354
+ text: fresh.map((f) => f.text).join('; '),
1355
+ };
1356
+ await Promise.resolve()
1357
+ .then(() => $.ui.notify(one.text, { title: one.title }))
1358
+ .catch(() => {});
1359
+ },
1259
1360
  offer: async () => {
1260
1361
  if (offering || leadTurn || loop.sent || !where.interactive) return;
1261
1362
  if (lease.other && !lease.mine) return;
@@ -1577,6 +1678,24 @@ export function register(on) {
1577
1678
  ((await $.agent.list().catch(() => [])) ?? []).find(
1578
1679
  (a) => a.status === 'running' && a.type === lane.agent,
1579
1680
  ) ?? null,
1681
+ // WATCH-1: the person's press puts the lane agent's transcript in the main view, as the tasks list
1682
+ // does; the tasks list (or Escape there) brings the main conversation back.
1683
+ watchLane: async (lane) => {
1684
+ const agent = await act.runningAgentOf(lane);
1685
+ if (!agent) {
1686
+ notice = `No agent is at work in the ${lane.name} lane to watch.`;
1687
+ $.ui.invalidate('ui.render');
1688
+ return;
1689
+ }
1690
+ const r = await Promise.resolve()
1691
+ .then(() => $.ui.view({ agentId: agent.id }))
1692
+ .catch(() => ({
1693
+ deny: 'this Claude Code shows an agent only from its tasks list (2.1.301 or later shows it from here)',
1694
+ }));
1695
+ notice = r?.deny ? `Couldn't show the ${lane.name} lane's agent: ${r.deny}.` : null;
1696
+ if (!r?.deny) await act.record('watch', `${lane.name} · ${agent.type}`);
1697
+ $.ui.invalidate('ui.render');
1698
+ },
1580
1699
  nudgeLane: async (lane) => {
1581
1700
  const agent = await act.runningAgentOf(lane);
1582
1701
  if (!agent) return;
@@ -1683,6 +1802,7 @@ export function register(on) {
1683
1802
  return act.review({ story: { ...lane.story, branch: lane.branch } });
1684
1803
  if (id === 'merge') return act.mergeQueue({ branch: lane.branch });
1685
1804
  if (id === 'sendBack') return act.sendBack(lane);
1805
+ if (id === 'watch') return act.watchLane(lane);
1686
1806
  if (id === 'nudge') return act.nudgeLane(lane);
1687
1807
  if (id === 'stop') return act.stopLane(lane);
1688
1808
  },
@@ -1704,6 +1824,7 @@ export function register(on) {
1704
1824
  // LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
1705
1825
  if (lease.mine) await act.lead();
1706
1826
  if (!leadTurn) act.offer().catch(() => {});
1827
+ await act.notifyNew().catch(() => {});
1707
1828
  await act.stalls();
1708
1829
  await act.animate();
1709
1830
  const now = await act.fingerprint();
@@ -1981,6 +2102,8 @@ export function register(on) {
1981
2102
  $.prompt.submit({ text }).catch(() => {});
1982
2103
  return res;
1983
2104
  }
2105
+ // LEAN-3: a milestone just finished: its compaction comes before the next step.
2106
+ if (await act?.compactAtMilestone().catch(() => false)) return res;
1984
2107
  await act?.step('turn').catch(() => {});
1985
2108
  // DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
1986
2109
  act?.offer().catch(() => {});
@@ -74,6 +74,9 @@ function project() {
74
74
  refs: ' aaa refs/heads/main\n bbb refs/heads/web/st-4\n',
75
75
  agents: [] as { type: string; status: string; id?: string; description?: string }[],
76
76
  limits: [] as { kind: string; percentUsed: number }[],
77
+ contextPercent: 0, // LEAN-3: how full the lead's context is
78
+ notified: [] as { text: string; title?: string }[], // NOTIFY-1
79
+ viewed: [] as (string | null)[], // WATCH-1
77
80
  approveExit: 0,
78
81
  mergeExit: 0,
79
82
  answer: 'Merge',
@@ -403,8 +406,19 @@ function stub(on: any, w: World) {
403
406
  w.spawned.push(e);
404
407
  return { model: e.model ?? 'claude-opus-5-5', agentId: `a-${w.spawned.length}` };
405
408
  });
406
- on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
409
+ on('session.usage', () => ({
410
+ value: { startedAt: 0, context: { percent: w.contextPercent }, rateLimits: w.limits },
411
+ }));
407
412
  on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
413
+ // NOTIFY-1: the person's notification channel; WATCH-1: the main view.
414
+ on('ui.notify', ($: any, e: any) => {
415
+ w.notified.push({ text: e.text, title: e.title });
416
+ return { value: { isSent: true, channel: 'iterm2' } };
417
+ });
418
+ on('ui.view', ($: any, e: any) => {
419
+ w.viewed.push(e.agentId ?? null);
420
+ return { value: {} };
421
+ });
408
422
  // A model request, answered with the usage the test gave it.
409
423
  on('turn.step', async function* ($: any, e: any) {
410
424
  return {
@@ -2131,7 +2145,9 @@ test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or
2131
2145
  expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2132
2146
  await leadTurn($, 't-3');
2133
2147
  await settle(clock);
2134
- expect(w.prompts).toHaveLength(1);
2148
+ // LOOP-13: the turn left ST-9 where it stood, so it's parked, and the next step goes instead.
2149
+ expect(w.prompts.filter((p: string) => p.startsWith('Dispatch ST-9'))).toHaveLength(1);
2150
+ expect(w.prompts.at(-1)).toMatch(/^Build ST-6, ST-7 yourself/);
2135
2151
  });
2136
2152
 
2137
2153
  test("LOOP-1 review and the lead's own stories are prompted with their skill", async ($, on) => {
@@ -2510,6 +2526,92 @@ test('LOOP-11 dismissed, what the lead waits on stays in the band, and the loop
2510
2526
  expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
2511
2527
  });
2512
2528
 
2529
+ test("LEAN-3 when a milestone finishes, the person is asked to compact the lead's context first, and the loop goes on after", async ($, on) => {
2530
+ const w = project();
2531
+ w.stories = [
2532
+ {
2533
+ id: 'ST-1',
2534
+ title: 'Foundations',
2535
+ lane: 'web',
2536
+ state: 'in review',
2537
+ branch: 'web/st-1',
2538
+ milestone: 'M0',
2539
+ waitingOn: [],
2540
+ },
2541
+ ...w.stories.map((s) => ({ ...s, milestone: 'M1' })),
2542
+ ];
2543
+ const compacted: string[] = [];
2544
+ w.contextPercent = 62;
2545
+ on('session.compact', ($: any, e: any) => {
2546
+ compacted.push(e.instructions);
2547
+ // A compaction leaves the summary in the conversation's place.
2548
+ return {
2549
+ messages: [
2550
+ { role: 'user', text: 'Summary: M0 done; ST-4 in progress on web/st-4.', toolUses: [] },
2551
+ ],
2552
+ };
2553
+ });
2554
+ const clock = await start($, on, w);
2555
+ await leadTurn($);
2556
+ await settle(clock);
2557
+ expect(compacted).toEqual([]);
2558
+ const prompted = w.prompts.length;
2559
+ // ST-1 merges: M0 is done, and the next turn's end asks before the loop's next step.
2560
+ w.stories[0] = { ...w.stories[0], state: 'done' };
2561
+ w.refs += ' m1 refs/heads/main-moved\n';
2562
+ await clock.advance(2000);
2563
+ w.answer = 'Compact now';
2564
+ await leadTurn($, 't-2');
2565
+ await settle(clock);
2566
+ expect(w.asked.at(-1)?.question).toMatch(
2567
+ /^M0 is done, and the lead's context is 62% full\. Compact it/,
2568
+ );
2569
+ expect(w.asked.at(-1)?.options).toEqual(['Compact now', 'Not now']);
2570
+ expect(compacted).toHaveLength(1);
2571
+ expect(compacted[0]).toMatch(/^M0 is done/);
2572
+ expect(compacted[0]).toMatch(/every story in flight now/);
2573
+ // The loop's step follows the compaction.
2574
+ expect(w.prompts.length).toBeGreaterThan(prompted);
2575
+ // The same milestone isn't offered twice, and a context below the floor isn't worth compacting.
2576
+ w.contextPercent = 10;
2577
+ await leadTurn($, 't-3');
2578
+ await settle(clock);
2579
+ expect(compacted).toHaveLength(1);
2580
+ });
2581
+
2582
+ test("LOOP-13 a review the lead's turn didn't move isn't prompted again while other lanes commit, whatever the reply reads as", async ($, on) => {
2583
+ const w = project();
2584
+ w.stories.push({
2585
+ id: 'ST-160',
2586
+ title: 'Content security policy',
2587
+ lane: 'web',
2588
+ state: 'in review',
2589
+ branch: 'web/st-160',
2590
+ waitingOn: [],
2591
+ });
2592
+ w.refs += ' p1 refs/heads/web/st-160\n';
2593
+ w.next = { step: 'review', args: 'ST-160', then: [] };
2594
+ w.sideReply = unread;
2595
+ const clock = await start($, on, w);
2596
+ await leadTurn($);
2597
+ await settle(clock);
2598
+ expect(w.prompts).toEqual(['Review ST-160: run /code-kit:review ST-160.']);
2599
+ // The lead says it waits, the reading comes back done, and lanes keep committing.
2600
+ w.sideReply = outcome({ outcome: 'done' });
2601
+ for (const [i, turn] of ['t-2', 't-3', 't-4', 't-5'].entries()) {
2602
+ w.refs += ` x${i} refs/heads/core/st-${i}\n`;
2603
+ await leadSays($, turn, 'ST-160 waits on ST-165; nothing to review yet.');
2604
+ await settle(clock);
2605
+ w.sideReply = unread;
2606
+ }
2607
+ expect(w.prompts).toHaveLength(1);
2608
+ // Its own branch moves: it's prompted again.
2609
+ w.refs = w.refs.replace(' p1 refs/heads/web/st-160', ' p2 refs/heads/web/st-160');
2610
+ await leadTurn($, 't-6');
2611
+ await settle(clock);
2612
+ expect(w.prompts).toHaveLength(2);
2613
+ });
2614
+
2513
2615
  test('LOOP-13 stories the lead says wait on other work are parked, each until what it waits on moves', async ($, on) => {
2514
2616
  const w = project();
2515
2617
  w.stories.push(
@@ -2579,10 +2681,17 @@ test('LOOP-13 stories the lead says wait on other work are parked, each until wh
2579
2681
  await leadTurn($, 't-3');
2580
2682
  await settle(clock);
2581
2683
  expect(w.prompts).toHaveLength(asked);
2582
- // The story building on ST-158 commits: ST-158 wakes, and ST-172 still waits.
2684
+ // The story building on ST-158 commits: that alone doesn't wake it.
2583
2685
  w.refs = w.refs.replace(' s2 refs/heads/api/st-164', ' s9 refs/heads/api/st-164');
2584
2686
  await leadTurn($, 't-4');
2585
2687
  await settle(clock);
2688
+ expect(w.prompts).toHaveLength(asked);
2689
+ // It finishes: ST-158 wakes, and ST-172 still waits.
2690
+ w.stories = w.stories.map((s: any) => (s.id === 'ST-164' ? { ...s, state: 'in review' } : s));
2691
+ w.refs += ' s10 refs/heads/api/st-164-done\n';
2692
+ await clock.advance(2000);
2693
+ await leadTurn($, 't-5');
2694
+ await settle(clock);
2586
2695
  expect(w.prompts.at(-1)).toBe('Review ST-158: run /code-kit:review ST-158.');
2587
2696
  expect(w.prompts).toHaveLength(asked + 1);
2588
2697
  });
@@ -3295,6 +3404,10 @@ test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane h
3295
3404
  await press($, 'lane-next', PANE);
3296
3405
  await press($, 'lane-next', PANE);
3297
3406
  expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/^›/);
3407
+ // WATCH-1: w puts the lane agent's transcript in the main view.
3408
+ expect((await ui.find({ key: 'act-watch' }))?.props.hotkey).toBe('w');
3409
+ await press($, 'act-watch', PANE);
3410
+ expect(w.viewed).toEqual(['a1']);
3298
3411
  expect((await ui.find({ key: 'act-nudge' }))?.props.hotkey).toBe('n');
3299
3412
  await press($, 'act-nudge', PANE);
3300
3413
  expect(w.sent).toEqual([
@@ -3310,6 +3423,30 @@ test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane h
3310
3423
  await ui.unmount();
3311
3424
  });
3312
3425
 
3426
+ test('NOTIFY-1 a decision new since the last look raises one notification; what waited before, and notify.on false, raise none', async ($, on) => {
3427
+ const w = project();
3428
+ w.requests = [dayjs];
3429
+ const clock = await start($, on, w);
3430
+ await clock.advance(2000);
3431
+ // The approval waiting when the session first looked isn't news.
3432
+ expect(w.notified).toEqual([]);
3433
+ w.questions = [question()];
3434
+ w.refs += ' q1 refs/heads/web/st-4-q\n';
3435
+ await clock.advance(2000);
3436
+ await clock.advance(2000);
3437
+ expect(w.notified).toHaveLength(1);
3438
+ expect(w.notified[0].title).toBe('code-kit: the lead asks you');
3439
+ await clock.advance(2000);
3440
+ expect(w.notified).toHaveLength(1);
3441
+ // Turned off, a new decision raises nothing.
3442
+ (w.check as any).harness = { notify: { on: false } };
3443
+ w.questions = [question(), question({ id: 'Q-2', question: 'Which payment provider?' })];
3444
+ w.refs += ' q2 refs/heads/web/st-4-q2\n';
3445
+ await clock.advance(2000);
3446
+ await clock.advance(2000);
3447
+ expect(w.notified).toHaveLength(1);
3448
+ });
3449
+
3313
3450
  test('VIEW-6 s sends the selected story back, with the reason the person gives', async ($, on) => {
3314
3451
  const w = project();
3315
3452
  w.stories[0] = { ...w.stories[0], state: 'review' };
@@ -1513,6 +1513,7 @@ const CHOICES = {
1513
1513
  autonomy: ['autonomous', 'propose', 'off'],
1514
1514
  'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
1515
1515
  'agents.lanes.model': ['', 'haiku', 'sonnet', 'opus'],
1516
+ 'compact.milestone': ['ask', 'auto', 'off'],
1516
1517
  };
1517
1518
  const shown = (v) =>
1518
1519
  v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
@@ -8,7 +8,8 @@ import { loadBaseline, newProblems } from './lib/baseline.mjs';
8
8
  import { runChecks } from './lib/checks.mjs';
9
9
  import { block, start } from './lib/hook.mjs';
10
10
  import { layerProblems } from './lib/layers.mjs';
11
- import { actorFor, actorRoot } from './lib/rules.mjs';
11
+ import { actorFor, actorRoot, currentBranch, reportPath } from './lib/rules.mjs';
12
+ import { storyOfBranch } from './lib/teamfacts.mjs';
12
13
  import { auditProblems, changedFiles } from './lane-audit.mjs';
13
14
 
14
15
  const { input, project, config, error } = start();
@@ -18,6 +19,9 @@ const { input, project, config, error } = start();
18
19
  // reported MAX_REPEATS times in a row means no progress: say so and let the finish through rather than
19
20
  // loop. A different problem means progress, and counts from one again.
20
21
  const MAX_REPEATS = 3;
22
+ // LEAN-1: the most a lane's hand-back holds.
23
+ const HAND_BACK_LINES = 10;
24
+ const HAND_BACK_CHARS = 1200;
21
25
  const memory = join(
22
26
  project,
23
27
  '.claude/state/stop-blocks',
@@ -92,4 +96,21 @@ if (actor.kind === 'lane' && changed.length) {
92
96
  changed.join('\n'),
93
97
  );
94
98
  }
99
+
100
+ // LEAN-1: the lead's context gets a short hand-back; the detail waits in a file it reads when it reviews.
101
+ if (actor.kind === 'lane' && storyOfBranch(currentBranch(root))) {
102
+ const report = reportPath(root);
103
+ // Written in the lane's worktree, or by its absolute path in the main checkout: either is found.
104
+ if (!existsSync(join(root, report)) && !existsSync(join(project, report)))
105
+ blockCounted(
106
+ `Write your full report to ${report} before you finish: what you did and why, the spec section, the commands you ran with their output, what's left, and anything you need decided (with the choices, and any text you'd have pasted, such as a migration you can't write yet). Then finish with a short hand-back of at most ten lines: the story and branch, done or blocked, any decision the lead must make, and that report's path. Don't paste output or code into the hand-back; the lead reads the file when it reviews.`,
107
+ );
108
+ // The hand-back is what reaches the lead's context: a few lines, the detail left in the report.
109
+ const handBack = String(input.last_assistant_message ?? '').trim();
110
+ const lines = handBack.split('\n').filter((l) => l.trim()).length;
111
+ if (lines > HAND_BACK_LINES || handBack.length > HAND_BACK_CHARS)
112
+ blockCounted(
113
+ `Your hand-back is ${lines} lines and ${handBack.length} characters, and all of it goes into the lead's context. Finish again with at most ${HAND_BACK_LINES} short lines: the story and branch, done or blocked, any decision the lead must make, and ${report}. Don't restate the report: the lead reads it when it reviews.`,
114
+ );
115
+ }
95
116
  passed();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
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",
@@ -13,7 +13,7 @@ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`.
13
13
 
14
14
  `requests --json` lists each open request with its number (`n`), the lane, what it asked for (`names`, `what`), and whose decision it is (`by`). The argument, if given, names the numbers to take. Take every request `by: "lead"`. A request `by: "person"` is theirs: see step 4.
15
15
 
16
- For each, read the lane's change request (its last report, or ask the lane with SendMessage if it's still running), its story in the plan, and the requirements the story cites, in the specs.
16
+ For each, read the lane's change request (its report, in `.claude/state/reports/<branch>.md` in its worktree), its story in the plan, and the requirements the story cites, in the specs.
17
17
 
18
18
  ## 2. Judge it
19
19
 
@@ -89,4 +89,4 @@ Give them the same brief. Claude Code opened in that worktree then acts as the l
89
89
 
90
90
  - Set each dispatched story's `**Status:**` in the plan to `in progress`, and commit it on the lead's branch.
91
91
  - Tell the person, in a few lines: what was dispatched, to which lane and branch, what's still blocked and on what, and what's ready next.
92
- - As each agent finishes, read its report. If it came back with a change request or a question, decide it yourself with `/code-kit:decide`, and bring to the person only what crosses a guardrail. If it came back done, run the review skill on its branch.
92
+ - As each agent finishes, read its hand-back: a few lines saying done or blocked, and the path of its full report (`.claude/state/reports/<branch>.md`). Open the report only when you need its detail: in the review, or to decide what it asks. If it came back with a change request or a question, decide it yourself with `/code-kit:decide`, and bring to the person only what crosses a guardrail. If it came back done, run the review skill on its branch.
@@ -102,7 +102,8 @@ Draft, alongside the config:
102
102
  - its layers and what they may depend on;
103
103
  - the specs it reads;
104
104
  - the command that proves its work;
105
- - its lane rules.
105
+ - its lane rules: the rules the docs set for this lane, and anything the project adds to code-kit's own (its principles and architecture docs, its own words for what's banned). These are the project's, so they stay outside code-kit's part.
106
+ - Leave out the template's `Always:` list: it's code-kit's part, which `code-kit instructions --write` adds once the config is written, between its markers.
106
107
  - If an agent already exists, keep its content and add only what's missing.
107
108
  - Drop the spec-check lines if the config has no `docs.specs`.
108
109
  - **A security reviewer:** write `.claude/agents/security-reviewer.md` from `${CLAUDE_PLUGIN_ROOT}/templates/security-reviewer.md`, in the same approval as the lane agents, whenever the product handles sign-in, personal data, payments, input from outside (forms, APIs, webhooks, uploads) or secrets. That's almost every product; leave it out only for one with none of these, such as a static site or a local tool with no accounts, and say why. It is a reviewer, not a lane: no entry in `lanes`, no paths, and only read-only tools (Read, Grep, Glob, and Bash for commands that change nothing), so the hooks treat it as read-only too. Fill it in from the docs (or the code, for brownfield):
@@ -111,7 +112,7 @@ Draft, alongside the config:
111
112
  - the privacy and data residency targets in the principles;
112
113
  - the project's audit commands (for example `npm audit --omit=dev`, `pip-audit`), each run once now; leave out one that isn't installed, and say so.
113
114
  - If the agent already exists, keep its content and add only what's missing.
114
- - **CLAUDE.md:** add the section from `${CLAUDE_PLUGIN_ROOT}/templates/claude-md.md`.
115
+ - **CLAUDE.md:** code-kit's section is rendered from the config, not written by hand: once the config is written, `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" instructions --write` adds it between its markers (`<!-- code-kit:begin vN -->` … `<!-- code-kit:end -->`). Anything the project wants to say beyond it (its layer order in words, its docs, its own rules) goes in a section of its own, outside the markers, so a later code-kit never touches it.
115
116
  - **Project skills, with the agents** (recipes like "new screen", "new migration" or "new endpoint"): one per recipe in the architecture's `## Recipes`, or any other doc that describes one step by step. Draft them now, alongside the agents, and show them in the same approval; the agents and their skills are one piece of work. Brownfield code without recipes in its docs: propose recipes from the patterns the code already follows (how its existing screens, endpoints or migrations are built), as drafts the person approves. Never write an empty one.
116
117
  - Write each as `.claude/skills/<name>/SKILL.md`, saying in its description which recipe in which doc it comes from, so an update can find it again.
117
118
  - List it under **Skills to use** on every lane agent whose paths the recipe touches, not only one: a recipe that adds a migration and the endpoint that uses it belongs to both lanes. A recipe only the lead follows goes in the CLAUDE.md section instead.
@@ -154,6 +155,7 @@ Re-run whenever the build has moved on: new apps or packages, folders that moved
154
155
 
155
156
  1. **Re-read** the docs and the repository as in section 1, including the brownfield steps if the code has grown past the docs.
156
157
  2. **Draft** the whole config again into `.claude/code-kit.draft.json`, starting from the live one. Keep everything that's still right, so the diff shows only real changes. If the live config has no `harness.autonomy`, ask the person about it as in section 2.
158
+ Bring code-kit's own instructions up to date: run `code-kit instructions`. Parts between markers are rewritten with `--write`. A part written before the markers (CLAUDE.md's `## Enforced, not advised` section, an agent's `Always:` list) is merged once: replace it with what `code-kit instructions show lead` or `show lane` prints, and move every line the project wrote itself into a section of its own outside the markers. Show the person the difference with the rest.
157
159
  A config without `"setup": 3` predates what setup now includes; add whichever of these the project is missing, as section 2 and 3 describe, and set `"setup": 3`:
158
160
  - project skills for the recipes in the docs (or, without recipes, the patterns the code follows), listed on the lane agents;
159
161
  - the design gate for every lane that owns screens, with its register; and design stories in the plan for screens not yet built (ask the person before adding stories);