@warren-dean/code-kit 0.17.0 → 0.19.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.
@@ -0,0 +1,126 @@
1
+ // ONB-8: the parts of a project's instructions that are code-kit's own: the lead's section of CLAUDE.md
2
+ // and the "Always:" rules of each lane agent. Each is rendered from code-kit's templates and the
3
+ // project's config, and kept between markers, so a newer code-kit can tell they're out of date and bring
4
+ // them up to date exactly, leaving everything the project wrote around them as it is.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ /** The version of code-kit's instructions: raised whenever the templates' rules change. */
10
+ export const INSTRUCTIONS_VERSION = 5;
11
+
12
+ const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
13
+ const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
14
+ const END = '<!-- code-kit:end -->';
15
+ const MARKED = /<!-- code-kit:begin v(\d+) -->\n[\s\S]*?<!-- code-kit:end -->/;
16
+ const CI_WORKFLOW = '.github/workflows/code-kit.yml';
17
+
18
+ /** `{{if key}}…{{end}}` kept where `facts[key]`, dropped otherwise; `{{key}}` replaced by `facts[key]`. */
19
+ function fill(text, facts) {
20
+ return text
21
+ .replace(/^([ \t]*- )?\{\{if ([\w.]+)\}\}([\s\S]*?)\{\{end\}\}\n?/gm, (_, item, key, body) =>
22
+ facts[key] ? `${item ?? ''}${body}\n` : '',
23
+ )
24
+ .replace(/\{\{([\w.]+)\}\}/g, (whole, key) => (key in facts ? String(facts[key]) : whole))
25
+ .replace(/\n{3,}/g, '\n\n');
26
+ }
27
+
28
+ /** What the templates need from the project: everything the config and the repository say. */
29
+ function factsOf(root, config) {
30
+ const delegated = new Set(config.approvals?.delegate?.protected ?? []);
31
+ const kept = [];
32
+ for (const p of config.protected ?? []) {
33
+ if (p.approval === 'kit' || delegated.has(p.approval)) continue;
34
+ if (!kept.some((k) => k.approval === p.approval)) kept.push(p);
35
+ }
36
+ return {
37
+ layers: (config.layers ?? []).map((l) => l.name).join(', ') || 'none set',
38
+ designGate: config.designGate ? '; designs approved before screens' : '',
39
+ personKeeps: kept.length
40
+ ? kept.map((p) => `\`${p.approval}\` (${p.why})`).join(', ')
41
+ : 'changes to the rules themselves',
42
+ 'approvals.lead': Boolean(config.approvals?.lead),
43
+ 'harness.report.on': Boolean(config.harness?.report?.on),
44
+ ci: existsSync(join(root, CI_WORKFLOW)),
45
+ 'docs.specs': config.docs?.specs ?? 'the specs',
46
+ 'docs.plan': config.docs?.plan ?? 'the plan',
47
+ 'docs.principles': config.docs?.principles ?? "the project's principles",
48
+ };
49
+ }
50
+
51
+ const marked = (body) => `${BEGIN(INSTRUCTIONS_VERSION)}\n${body.trim()}\n${END}`;
52
+
53
+ /** The lead's section of CLAUDE.md, between its markers. */
54
+ export function leadSection(root, config) {
55
+ return marked(fill(readFileSync(join(TEMPLATES, 'claude-md.md'), 'utf8'), factsOf(root, config)));
56
+ }
57
+
58
+ /** A lane agent's "Always:" rules, between their markers. */
59
+ export function agentRules(root, config) {
60
+ const template = readFileSync(join(TEMPLATES, 'agent.md'), 'utf8');
61
+ return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config)));
62
+ }
63
+
64
+ /** Whether two renderings say the same, whatever their whitespace and list indentation. */
65
+ const same = (a, b) => a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim();
66
+
67
+ const LEAD_LEGACY = /^## Enforced, not advised\s*$/;
68
+ const AGENT_LEGACY = /^Always:\s*$/;
69
+
70
+ /**
71
+ * Where a file's code-kit part stands: `current`; `stale` (marked, from an older code-kit or an older
72
+ * config); `legacy` (written before the markers, often tailored by hand, so merged once by the lead, not
73
+ * overwritten); or `missing` (none yet).
74
+ */
75
+ function stateOf(text, block, legacy) {
76
+ const m = MARKED.exec(text);
77
+ // A formatter that rewraps the project's markdown doesn't make code-kit's part out of date.
78
+ if (m) return { state: same(m[0], block) ? 'current' : 'stale', version: Number(m[1]) };
79
+ if (text.split('\n').some((l) => legacy.test(l))) return { state: 'legacy', version: 0 };
80
+ return { state: 'missing', version: null };
81
+ }
82
+
83
+ /**
84
+ * Each file holding code-kit's instructions: `{ file, kind, state, version, block, next }`, `next` being
85
+ * the file brought up to date (null for a legacy one, which the lead merges). The lead's CLAUDE.md, and
86
+ * the agent file of every lane whose agent file exists.
87
+ */
88
+ export function instructionsState(root, config) {
89
+ const lead = leadSection(root, config);
90
+ const rules = agentRules(root, config);
91
+ const files = [
92
+ { file: 'CLAUDE.md', kind: 'lead', block: lead, legacy: LEAD_LEGACY },
93
+ ...[...new Set(Object.values(config.lanes ?? {}).map((l) => l.agent))]
94
+ .map((agent) => ({
95
+ file: `.claude/agents/${agent}.md`,
96
+ kind: 'lane',
97
+ block: rules,
98
+ legacy: AGENT_LEGACY,
99
+ }))
100
+ .filter((f) => existsSync(join(root, f.file))),
101
+ ];
102
+ return files.map(({ file, kind, block, legacy }) => {
103
+ const path = join(root, file);
104
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
105
+ const { state, version } = stateOf(text, block, legacy);
106
+ const next =
107
+ state === 'stale'
108
+ ? text.replace(MARKED, block)
109
+ : state === 'missing'
110
+ ? `${text.replace(/\s*$/, '')}${text.trim() ? '\n\n' : ''}${block}\n`
111
+ : null;
112
+ return { file, kind, state, version, block, next };
113
+ });
114
+ }
115
+
116
+ /** Brings every marked or missing part of code-kit's instructions up to date; the files it changed. */
117
+ export function writeInstructions(root, config) {
118
+ const changed = [];
119
+ for (const f of instructionsState(root, config)) {
120
+ if (!f.next) continue;
121
+ mkdirSync(dirname(join(root, f.file)), { recursive: true });
122
+ writeFileSync(join(root, f.file), f.next);
123
+ changed.push(f.file);
124
+ }
125
+ return changed;
126
+ }
@@ -1,7 +1,7 @@
1
1
  // What to do next, from the project's state alone: no docs → spec-design; a spec still being shaped →
2
2
  // spec-design; a ready spec or existing code without the kit → init; then, from the plan and git:
3
3
  // review finished stories, dispatch ready ones, wait on running ones, or plan the next milestone.
4
- import { execFileSync } from 'node:child_process';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
6
  import { join, posix } from 'node:path';
7
7
  import { loadConfig, mergeDelegation } from './config.mjs';
@@ -10,6 +10,7 @@ import { pendingSetup } from './adapters/index.mjs';
10
10
  import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
11
11
  import { readQueue, waiting } from './queue.mjs';
12
12
  import { decidedBy, laneOf, openRequests } from './rules.mjs';
13
+ import { instructionsState } from './instructions.mjs';
13
14
  import { SECURITY_AGENT_FILE } from './security.mjs';
14
15
 
15
16
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
@@ -253,6 +254,30 @@ export function nextStep(root, base) {
253
254
  attention.push(
254
255
  `${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
255
256
  );
257
+ // LEAN-6: worktrees whose story is merged and which hold nothing uncommitted are only disk space.
258
+ const leftovers = mergedWorktrees(root, config);
259
+ if (leftovers)
260
+ attention.push(
261
+ `${leftovers} worktree${leftovers === 1 ? ' is' : 's are'} merged and clean; \`code-kit worktrees prune\` removes them, keeping their reports.`,
262
+ );
263
+ // ONB-8: code-kit's own instructions to the lead and the lanes, brought up to date before the next step:
264
+ // a newer code-kit's rules reach the agents only through them.
265
+ const instructions = instructionsState(root, config);
266
+ const behind = instructions.filter((f) => ['stale', 'legacy'].includes(f.state));
267
+ // A file with no code-kit part may be one the project keeps without it: said, not insisted on.
268
+ const none = instructions.filter((f) => f.state === 'missing');
269
+ if (none.length)
270
+ attention.push(
271
+ `${none.map((f) => f.file).join(', ')} ${none.length === 1 ? 'has' : 'have'} none of code-kit's instructions; \`code-kit instructions --write\` adds them.`,
272
+ );
273
+ if (behind.length) {
274
+ const legacy = behind.filter((f) => f.state === 'legacy');
275
+ return result(
276
+ 'instructions',
277
+ `code-kit's instructions in ${behind.map((f) => f.file).join(', ')} are behind this code-kit${legacy.length ? `; ${legacy.length === behind.length ? 'they were' : `${legacy.map((f) => f.file).join(', ')} were`} written before code-kit marked its part, so merge ${legacy.length === 1 ? 'it' : 'them'} once by hand, keeping the project's own lines` : ''}.`,
278
+ { args: legacy.length ? 'merge' : 'write' },
279
+ );
280
+ }
256
281
  // HELM-4: a lane waiting on something the lead decides goes first: the lane can't go on without it.
257
282
  const forLeadToDecide = openRequests(root).flatMap((r, i) =>
258
283
  r.lane && decidedBy(r.names, config) === 'lead' ? [{ ...r, n: i + 1 }] : [],
@@ -466,3 +491,23 @@ export function missingSkills(root) {
466
491
  }
467
492
  return out;
468
493
  }
494
+
495
+ /** LEAN-6: how many of the project's worktrees have a merged branch and nothing uncommitted. */
496
+ function mergedWorktrees(root, config) {
497
+ const list = git(root, 'worktree', 'list', '--porcelain');
498
+ if (!list) return 0;
499
+ const from = config.branches?.protected?.[0];
500
+ if (!from || !git(root, 'rev-parse', '--verify', '-q', from)) return 0;
501
+ let n = 0;
502
+ for (const block of list.split('\n\n').slice(1)) {
503
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
504
+ const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
505
+ if (!path || !branch) continue;
506
+ const merged =
507
+ spawnSync('git', ['merge-base', '--is-ancestor', branch, from], { cwd: root }).status === 0;
508
+ if (!merged) continue;
509
+ const status = spawnSync('git', ['-C', path, 'status', '--porcelain'], { encoding: 'utf8' });
510
+ if (status.status === 0 && !status.stdout.trim()) n++;
511
+ }
512
+ return n;
513
+ }
@@ -205,8 +205,9 @@ export function approvalsGiven(root) {
205
205
  export function realPath(path) {
206
206
  let dir = resolve(path);
207
207
  const rest = [];
208
+ // basename, not a slice after dirname: dirname('/x') is '/', so slicing past it cut the name's first letter.
208
209
  while (!existsSync(dir)) {
209
- rest.unshift(dir.slice(dirname(dir).length + 1));
210
+ rest.unshift(basename(dir));
210
211
  dir = dirname(dir);
211
212
  }
212
213
  return join(realpathSync(dir), ...rest);
@@ -775,9 +776,14 @@ export function writeProblem(actor, rel, root, config) {
775
776
  const readonlyProblem = (actor, rel) =>
776
777
  `${actor.label} is read-only; it may not write ${rel}. If a command you ran changed it (a build, a formatter or a code generator), put it back with git restore -- ${rel} (or delete it if it's new), and run such commands only in a throwaway worktree of the branch.`;
777
778
 
779
+ // Claude Code's own temporary folders (a session's scratchpad, its tool output): no repository's files.
780
+ const CLAUDE_TEMP = ['/private/tmp/claude-*/**', '/tmp/claude-*/**'];
781
+
778
782
  function outsideAllowed(actor, rel, root, config) {
779
- if (actor.kind !== 'lead') return false;
780
783
  const target = resolve(root, rel);
784
+ // A lane's scratch files go where Claude Code's own do, outside every repository.
785
+ if (actor.kind === 'lane') return CLAUDE_TEMP.some((g) => globToRegExp(g).test(target));
786
+ if (actor.kind !== 'lead') return false;
781
787
  return config.lead.outside.some((g) =>
782
788
  globToRegExp(resolve(root, g.replace(/^~(?=\/)/, homedir()))).test(target),
783
789
  );
@@ -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',
@@ -327,10 +340,27 @@ export function milestonesDone(stories = []) {
327
340
  return [...by].filter(([, list]) => list.every((s) => s.state === 'done')).map(([m]) => m);
328
341
  }
329
342
 
330
- /** LEAN-3: what the lead's summary keeps when its context is compacted after a milestone. */
331
- export const compactInstructions = (milestone) =>
343
+ /** LEAN-3, LEAN-4: what the lead's summary keeps when its context is compacted, after a milestone or not. */
344
+ export const compactInstructions = (milestone = null) =>
332
345
  [
333
- `${milestone} is done; the build goes on with the next milestone.`,
346
+ milestone
347
+ ? `${milestone} is done; the build goes on with the next milestone.`
348
+ : "The lead's context is compacted between turns; the build goes on.",
334
349
  '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.',
335
350
  "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.",
336
351
  ].join('\n');
352
+
353
+ /** LEAN-5: the context a model request carried: what it sent, read from the cache, and wrote to it. */
354
+ export const requestContext = (usage) =>
355
+ (usage?.input_tokens ?? 0) +
356
+ (usage?.cache_read_input_tokens ?? 0) +
357
+ (usage?.cache_creation_input_tokens ?? 0);
358
+
359
+ /** LEAN-5: what a lane agent past its context limit is told. */
360
+ export const wrapUpText = (limit) =>
361
+ `Your context is past ${Math.round(limit / 1000)}k tokens, and every step from here rereads all of it. Finish the step you're on, commit your work, write your report, and hand back now. The lead carries the story on with a fresh agent that reads your report.`;
362
+
363
+ /** LEAN-5, LEAN-6: the lead's prompt to carry a story on with a fresh agent once its agent handed back at the limit. */
364
+ export function continuePrompt({ type, story, branch, cli = 'code-kit' }) {
365
+ return `${type} handed ${story} back at its context limit, with its work committed${branch ? ` on ${branch}` : ''}. Carry the story on with a fresh agent: run node "${cli}" worktrees release ${branch ?? '<branch>'} (it keeps the commits and moves the report to the main checkout), then dispatch ${story} again with the same brief, adding "Read your earlier report first: .claude/state/reports/${(branch ?? '<branch>').replace(/[^\w.-]+/g, '_')}.md in the main checkout": /code-kit:dispatch ${story}`;
366
+ }
@@ -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
  }
@@ -61,6 +61,9 @@ import {
61
61
  COMPACT_FLOOR,
62
62
  PARK_MS,
63
63
  compactInstructions,
64
+ continuePrompt,
65
+ requestContext,
66
+ wrapUpText,
64
67
  milestonesDone,
65
68
  parkWatch,
66
69
  readOutcome,
@@ -74,7 +77,7 @@ import {
74
77
  withoutInFlight,
75
78
  } from './loop.mjs';
76
79
  import { goalOf, nextText } from './mission.mjs';
77
- import { contextOf, decisionCards, nativeChoice } from './decisions.mjs';
80
+ import { contextOf, decisionCards, decisionKey, nativeChoice, newDecisions } from './decisions.mjs';
78
81
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
79
82
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
80
83
  import { harnessSettings } from '../lib/harness.mjs';
@@ -144,6 +147,8 @@ let turnQuestions = []; // the open questions as the lead's running turn started
144
147
  // DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
145
148
  const offered = new Set();
146
149
  let offering = false;
150
+ // NOTIFY-1: the decisions waiting at the last look, by key; null until the first look.
151
+ let notified = null;
147
152
  /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
148
153
  const ASK_LIMIT = 2;
149
154
  let leadTurn = null; // the lead's running turn, if any
@@ -1047,6 +1052,23 @@ export function register(on) {
1047
1052
  const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
1048
1053
  ledger = withUsage(ledger, who, usage);
1049
1054
  $.ui.invalidate('ui.render');
1055
+ // LEAN-5: a lane agent past its context limit is told to wrap up; its story goes on fresh.
1056
+ const limit = harness().agents.lanes.contextLimit;
1057
+ const agent = agentId ? agentsSeen.get(agentId) : null;
1058
+ const lane = Object.entries(model.check?.lanes ?? {}).find(
1059
+ ([, l]) => l.agent === agent?.type,
1060
+ );
1061
+ if (!limit || !lane || requestContext(usage) < limit) return;
1062
+ const a = activityOf(agentId, await $.clock.now());
1063
+ if (a.overflow) return;
1064
+ a.overflow = true;
1065
+ a.lane = lane[0];
1066
+ a.story = who.story ?? a.story ?? lane[0];
1067
+ await act.record(
1068
+ 'wrap up',
1069
+ `${lane[0]} · ${a.story} at ${Math.round(requestContext(usage) / 1000)}k`,
1070
+ );
1071
+ await $.session.send({ to: { agentId }, text: wrapUpText(limit) }).catch(() => {});
1050
1072
  },
1051
1073
  // --- the lead loop (spec 05) ---------------------------------------------------------------
1052
1074
  record: async (kind, target, prompt) => {
@@ -1063,10 +1085,19 @@ export function register(on) {
1063
1085
  ) => {
1064
1086
  loop.started = true;
1065
1087
  loop.proposal = null;
1066
- loop.sent = { prompt, key, label, kind, target };
1088
+ const sent = { prompt, key, label, kind, target, before: new Map() };
1089
+ loop.sent = sent;
1067
1090
  // LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
1068
1091
  if (kind === 'design')
1069
1092
  for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
1093
+ // LOOP-13: where the step's stories stood when the lead was prompted, to tell after its turn
1094
+ // whether it moved them.
1095
+ // A design is made with the person over many turns (LOOP-12), so it's never counted as unmoved.
1096
+ const ids = kind === 'design' ? [] : stepStories({ kind, target });
1097
+ if (ids.length) {
1098
+ const seen = await act.storiesNow();
1099
+ for (const id of ids) sent.before.set(id, seen.sig([id]));
1100
+ }
1070
1101
  await act.record(kind, target, prompt);
1071
1102
  $.ui.invalidate('ui.render');
1072
1103
  $.prompt.submit({ text: prompt }).catch((err) => {
@@ -1141,6 +1172,16 @@ export function register(on) {
1141
1172
  }
1142
1173
  $.ui.invalidate('ui.render');
1143
1174
  },
1175
+ // LOOP-13: the stories of the step the lead was prompted with that its turn left where they stood.
1176
+ parkUnmoved: async (sent, reason) => {
1177
+ if (!sent?.before?.size) return;
1178
+ const seen = await act.storiesNow();
1179
+ await act.park(
1180
+ [...sent.before]
1181
+ .filter(([id, sig]) => !loop.parked.has(id) && seen.sig([id]) === sig)
1182
+ .map(([id]) => ({ id, on: [], why: reason ?? "the lead's turn didn't move it" })),
1183
+ );
1184
+ },
1144
1185
  // LOOP-13: a parked story wakes when anything it watches moves, or after PARK_MS with nothing moving.
1145
1186
  unpark: async () => {
1146
1187
  if (!loop.parked.size) return;
@@ -1156,14 +1197,15 @@ export function register(on) {
1156
1197
  const done = milestonesDone(model.state?.stories ?? []);
1157
1198
  if (loop.milestones === null) {
1158
1199
  loop.milestones = new Set(done);
1159
- return false;
1200
+ return act.compactAtThreshold();
1160
1201
  }
1161
1202
  const fresh = done.filter((m) => !loop.milestones.has(m));
1162
- if (!fresh.length) return false;
1203
+ // LEAN-4: past compact.at, the lead's context is compacted between turns, milestone or not.
1204
+ if (!fresh.length) return act.compactAtThreshold();
1163
1205
  const mode = harness().compact?.milestone ?? 'ask';
1164
1206
  if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
1165
1207
  for (const m of fresh) loop.milestones.add(m);
1166
- return false;
1208
+ return act.compactAtThreshold();
1167
1209
  }
1168
1210
  const box = await $.prompt.read().catch(() => null);
1169
1211
  if (box?.text?.trim()) return false;
@@ -1198,6 +1240,25 @@ export function register(on) {
1198
1240
  });
1199
1241
  return true;
1200
1242
  },
1243
+ // LEAN-4: the lead's context compacted at once when it's past compact.at; true when it took over.
1244
+ compactAtThreshold: async () => {
1245
+ const at = harness().compact?.at ?? 40;
1246
+ if (!at || offering || !where.interactive || (lease.other && !lease.mine)) return false;
1247
+ const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
1248
+ if (percent < Math.max(at, COMPACT_FLOOR)) return false;
1249
+ const box = await $.prompt.read().catch(() => null);
1250
+ if (box?.text?.trim()) return false;
1251
+ Promise.resolve()
1252
+ .then(() => $.session.compact({ instructions: compactInstructions() }))
1253
+ .catch((err) => ({ skip: String(err?.message ?? err) }))
1254
+ .then(async (r) => {
1255
+ if (r?.skip) notice = `The lead's context wasn't compacted: ${r.skip}`;
1256
+ await act.record('compact', `at ${Math.round(percent)}%`);
1257
+ $.ui.invalidate('ui.render');
1258
+ await act.step('resume').catch(() => {});
1259
+ });
1260
+ return true;
1261
+ },
1201
1262
  // LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
1202
1263
  // the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
1203
1264
  // holds that step while it's still next.
@@ -1236,10 +1297,16 @@ export function register(on) {
1236
1297
  $.ui.invalidate('ui.render');
1237
1298
  return;
1238
1299
  }
1239
- if (!reply.text.trim()) return;
1240
- const said = await act.sideCall(
1241
- outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
1242
- );
1300
+ if (!reply.text.trim()) return act.parkUnmoved(sent, null);
1301
+ // LEAN-6: the reply and the prompt it answered are enough to read it, so a small model reads
1302
+ // them, instead of a fork over the lead's whole conversation on its own model at every turn's end.
1303
+ const said = await $.model
1304
+ .complete({
1305
+ model: 'sonnet',
1306
+ prompt: outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
1307
+ maxTokens: 600,
1308
+ })
1309
+ .catch(() => ({ isAnswered: false }));
1243
1310
  const heard = said.isAnswered ? readOutcome(said.text) : null;
1244
1311
  // Spec 05's data: what came of the step, once known.
1245
1312
  const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
@@ -1259,6 +1326,10 @@ export function register(on) {
1259
1326
  })),
1260
1327
  );
1261
1328
  }
1329
+ // LOOP-13: a story the lead's turn left exactly where it stood wasn't taken, whatever the reply
1330
+ // reads as; it's parked like one the lead said waits, so the loop doesn't prompt it again until
1331
+ // it moves.
1332
+ if (heard?.outcome !== 'needs-person') await act.parkUnmoved(sent, heard?.reason);
1262
1333
  if (heard?.outcome === 'needs-person')
1263
1334
  loop.waiting = {
1264
1335
  kind: 'person',
@@ -1310,6 +1381,28 @@ export function register(on) {
1310
1381
  // DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
1311
1382
  // dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
1312
1383
  // Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
1384
+ // NOTIFY-1: what newly needs the person, as a system notification, from the session that runs the
1385
+ // loop. What waited before this session looked isn't news. On a Claude Code without $.ui.notify,
1386
+ // the call fails and nothing is raised.
1387
+ notifyNew: async () => {
1388
+ if (model.state?.kind !== 'ok' || !where.interactive) return;
1389
+ const cards = cardsNow(await $.clock.now());
1390
+ const fresh = newDecisions(cards, notified ?? new Set());
1391
+ const first = notified === null;
1392
+ notified = new Set(cards.map(decisionKey));
1393
+ if (first || !fresh.length || harness().notify?.on === false) return;
1394
+ if (lease.other && !lease.mine) return;
1395
+ const one =
1396
+ fresh.length === 1
1397
+ ? fresh[0]
1398
+ : {
1399
+ title: `code-kit: ${fresh.length} things need you`,
1400
+ text: fresh.map((f) => f.text).join('; '),
1401
+ };
1402
+ await Promise.resolve()
1403
+ .then(() => $.ui.notify(one.text, { title: one.title }))
1404
+ .catch(() => {});
1405
+ },
1313
1406
  offer: async () => {
1314
1407
  if (offering || leadTurn || loop.sent || !where.interactive) return;
1315
1408
  if (lease.other && !lease.mine) return;
@@ -1631,6 +1724,24 @@ export function register(on) {
1631
1724
  ((await $.agent.list().catch(() => [])) ?? []).find(
1632
1725
  (a) => a.status === 'running' && a.type === lane.agent,
1633
1726
  ) ?? null,
1727
+ // WATCH-1: the person's press puts the lane agent's transcript in the main view, as the tasks list
1728
+ // does; the tasks list (or Escape there) brings the main conversation back.
1729
+ watchLane: async (lane) => {
1730
+ const agent = await act.runningAgentOf(lane);
1731
+ if (!agent) {
1732
+ notice = `No agent is at work in the ${lane.name} lane to watch.`;
1733
+ $.ui.invalidate('ui.render');
1734
+ return;
1735
+ }
1736
+ const r = await Promise.resolve()
1737
+ .then(() => $.ui.view({ agentId: agent.id }))
1738
+ .catch(() => ({
1739
+ deny: 'this Claude Code shows an agent only from its tasks list (2.1.301 or later shows it from here)',
1740
+ }));
1741
+ notice = r?.deny ? `Couldn't show the ${lane.name} lane's agent: ${r.deny}.` : null;
1742
+ if (!r?.deny) await act.record('watch', `${lane.name} · ${agent.type}`);
1743
+ $.ui.invalidate('ui.render');
1744
+ },
1634
1745
  nudgeLane: async (lane) => {
1635
1746
  const agent = await act.runningAgentOf(lane);
1636
1747
  if (!agent) return;
@@ -1737,6 +1848,7 @@ export function register(on) {
1737
1848
  return act.review({ story: { ...lane.story, branch: lane.branch } });
1738
1849
  if (id === 'merge') return act.mergeQueue({ branch: lane.branch });
1739
1850
  if (id === 'sendBack') return act.sendBack(lane);
1851
+ if (id === 'watch') return act.watchLane(lane);
1740
1852
  if (id === 'nudge') return act.nudgeLane(lane);
1741
1853
  if (id === 'stop') return act.stopLane(lane);
1742
1854
  },
@@ -1758,6 +1870,7 @@ export function register(on) {
1758
1870
  // LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
1759
1871
  if (lease.mine) await act.lead();
1760
1872
  if (!leadTurn) act.offer().catch(() => {});
1873
+ await act.notifyNew().catch(() => {});
1761
1874
  await act.stalls();
1762
1875
  await act.animate();
1763
1876
  const now = await act.fingerprint();
@@ -1954,6 +2067,16 @@ export function register(on) {
1954
2067
  // SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
1955
2068
  // the lane's agent file sets its own.
1956
2069
  on('agent.spawn', async ($, e, next) => {
2070
+ // LEAN-7: the security reviewer runs on the reviewers' model, unless the call or its file names one.
2071
+ const reviewerModel = harness().agents.reviewer.model;
2072
+ if (e.subagentType === 'security-reviewer' && reviewerModel && !e.model) {
2073
+ const file = where
2074
+ ? await $.fs.read(`${where.cwd}/.claude/agents/security-reviewer.md`).catch(() => null)
2075
+ : null;
2076
+ const own = typeof file === 'string' ? agentFileModel(file) : null;
2077
+ if (!own || own === 'inherit') return next({ ...e, model: reviewerModel });
2078
+ return next(e);
2079
+ }
1957
2080
  const setting = harness().agents.lanes.model;
1958
2081
  const lanes = model.check?.lanes ?? {};
1959
2082
  if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
@@ -2011,7 +2134,25 @@ export function register(on) {
2011
2134
  const res = await next(e);
2012
2135
  // LOOP-3: a lane agent that has handed back its work isn't stalled while it waits; a message that
2013
2136
  // starts it again shows as its next tool call.
2014
- if (e.agentId) activityOf(e.agentId, await $.clock.now()).handedBack = true;
2137
+ if (e.agentId) {
2138
+ const a = activityOf(e.agentId, await $.clock.now());
2139
+ a.handedBack = true;
2140
+ // LEAN-5: an agent that handed back at its limit: the lead carries its story on with a fresh one.
2141
+ if (a.overflow && !a.continued && act) {
2142
+ a.continued = true;
2143
+ const agent = agentsSeen.get(e.agentId);
2144
+ const story = (model.state?.stories ?? []).find((s) => s.id === a.story);
2145
+ loop.pending.push({
2146
+ story: a.story,
2147
+ prompt: continuePrompt({
2148
+ type: agent?.type ?? 'The lane agent',
2149
+ story: a.story,
2150
+ branch: story?.branch ?? null,
2151
+ cli: where?.cli,
2152
+ }),
2153
+ });
2154
+ }
2155
+ }
2015
2156
  // REVW-3: a subagent's last turn may be the reviewer's report.
2016
2157
  if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
2017
2158
  // LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,