@warren-dean/code-kit 0.20.0 → 0.22.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +24 -0
  4. package/README.md +75 -50
  5. package/bin/code-kit.mjs +364 -25
  6. package/hooks/guard-agent.mjs +36 -0
  7. package/hooks/guard-bash.mjs +67 -13
  8. package/hooks/guard-paths.mjs +14 -2
  9. package/hooks/hooks.json +20 -0
  10. package/hooks/lane-audit.mjs +38 -1
  11. package/hooks/lib/adapters/hub/client.mjs +15 -0
  12. package/hooks/lib/checks.mjs +5 -1
  13. package/hooks/lib/config.mjs +149 -6
  14. package/hooks/lib/decisionstore.mjs +89 -0
  15. package/hooks/lib/harness.mjs +7 -0
  16. package/hooks/lib/hook.mjs +7 -0
  17. package/hooks/lib/instructions.mjs +46 -13
  18. package/hooks/lib/laneclaims.mjs +89 -0
  19. package/hooks/lib/layers.mjs +11 -0
  20. package/hooks/lib/loop-state.mjs +20 -2
  21. package/hooks/lib/next.mjs +34 -1
  22. package/hooks/lib/plan.mjs +3 -1
  23. package/hooks/lib/questions.mjs +23 -5
  24. package/hooks/lib/regression.mjs +2 -1
  25. package/hooks/lib/rules.mjs +20 -13
  26. package/hooks/lib/schedule.mjs +86 -0
  27. package/hooks/lib/sessions.mjs +223 -0
  28. package/hooks/lib/snapshot.mjs +80 -2
  29. package/hooks/lib/standards.mjs +450 -0
  30. package/hooks/lib/team.mjs +9 -2
  31. package/hooks/lib/verify.mjs +23 -1
  32. package/hooks/mod/decisions.mjs +1 -28
  33. package/hooks/mod/loop.mjs +8 -0
  34. package/hooks/mod/mission.mjs +21 -19
  35. package/hooks/mod/register.mjs +117 -40
  36. package/hooks/mod/register.test.ts +248 -73
  37. package/hooks/mod/review.mjs +1 -0
  38. package/hooks/mod/view.mjs +98 -47
  39. package/hooks/post-edit-check.mjs +8 -1
  40. package/hooks/session.mjs +24 -0
  41. package/hooks/stop-check.mjs +23 -3
  42. package/package.json +1 -1
  43. package/skills/decide/SKILL.md +1 -1
  44. package/skills/dispatch/SKILL.md +2 -2
  45. package/skills/init/SKILL.md +17 -3
  46. package/skills/lead/SKILL.md +28 -0
  47. package/skills/next/SKILL.md +2 -2
  48. package/skills/review/SKILL.md +2 -1
  49. package/skills/spec-design/SKILL.md +10 -7
  50. package/templates/agent.md +1 -0
  51. package/templates/claude-md.md +5 -1
  52. package/templates/plan.md +2 -1
@@ -71,6 +71,13 @@ export const SETTINGS = {
71
71
  about:
72
72
  "The context a lane agent may reach before it's asked to wrap up and hand its story to a fresh agent; 0 for no limit",
73
73
  },
74
+ 'slices.open': {
75
+ default: 1,
76
+ problem: (v) =>
77
+ Number.isInteger(v) && v >= 1 && v <= 10 ? null : 'must be a whole number from 1 to 10',
78
+ about:
79
+ "How many slices are in focus at once: a free lane builds an open slice's stories first, and may start the next slice only while fewer are open",
80
+ },
74
81
  'background.pauseAtPercent': {
75
82
  default: 80,
76
83
  problem: (v) =>
@@ -2,6 +2,7 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { loadConfig } from './config.mjs';
4
4
  import { projectDir } from './rules.mjs';
5
+ import { seeSession } from './sessions.mjs';
5
6
 
6
7
  /** Exit 2 with a message: Claude Code blocks the call and shows Claude the reason. */
7
8
  export function block(message) {
@@ -15,5 +16,11 @@ export function start() {
15
16
  const project = projectDir();
16
17
  const { config, error } = loadConfig(project);
17
18
  if (!config && !error) process.exit(0);
19
+ // SESS-1: the session acting is still there; one code-kit hadn't seen starts here.
20
+ try {
21
+ seeSession(project, input.session_id);
22
+ } catch {
23
+ // a read-only checkout: the session goes unrecorded
24
+ }
18
25
  return { input, project, config, error };
19
26
  }
@@ -5,9 +5,16 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import {
9
+ appliesTo,
10
+ laneLayers,
11
+ projectStandards,
12
+ standardLine,
13
+ standardsFile,
14
+ } from './standards.mjs';
8
15
 
9
16
  /** The version of code-kit's instructions: raised whenever the templates' rules change. */
10
- export const INSTRUCTIONS_VERSION = 5;
17
+ export const INSTRUCTIONS_VERSION = 7;
11
18
 
12
19
  const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
13
20
  const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
@@ -25,8 +32,20 @@ function fill(text, facts) {
25
32
  .replace(/\n{3,}/g, '\n\n');
26
33
  }
27
34
 
28
- /** What the templates need from the project: everything the config and the repository say. */
29
- function factsOf(root, config) {
35
+ /** The standards that apply (spec 22), or none when the file can't be read. */
36
+ function standardsOf(root, config) {
37
+ try {
38
+ return projectStandards(root, config).standards;
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What the templates need from the project: everything the config and the repository say. With `lane`,
46
+ * the standards are the ones for that lane's layers (STD-3).
47
+ */
48
+ function factsOf(root, config, lane = null) {
30
49
  const delegated = new Set(config.approvals?.delegate?.protected ?? []);
31
50
  const kept = [];
32
51
  for (const p of config.protected ?? []) {
@@ -45,9 +64,24 @@ function factsOf(root, config) {
45
64
  'docs.specs': config.docs?.specs ?? 'the specs',
46
65
  'docs.plan': config.docs?.plan ?? 'the plan',
47
66
  'docs.principles': config.docs?.principles ?? "the project's principles",
67
+ 'docs.standards': standardsFile(config),
68
+ standards: laneStandards(root, config, lane),
69
+ 'standards.all': standardsOf(root, config)
70
+ .map((s) => `${s.id} ${s.title}`)
71
+ .join('; '),
48
72
  };
49
73
  }
50
74
 
75
+ /** A lane's standards as the lines of a nested list, or '' when none apply. */
76
+ function laneStandards(root, config, lane) {
77
+ if (!lane) return '';
78
+ const layers = (config.layers ?? []).length ? laneLayers(config.lanes?.[lane], config) : null;
79
+ const lines = standardsOf(root, config)
80
+ .filter((s) => appliesTo(s, layers))
81
+ .map((s) => `\n - ${standardLine(s)}`);
82
+ return lines.join('');
83
+ }
84
+
51
85
  const marked = (body) => `${BEGIN(INSTRUCTIONS_VERSION)}\n${body.trim()}\n${END}`;
52
86
 
53
87
  /** The lead's section of CLAUDE.md, between its markers. */
@@ -55,10 +89,10 @@ export function leadSection(root, config) {
55
89
  return marked(fill(readFileSync(join(TEMPLATES, 'claude-md.md'), 'utf8'), factsOf(root, config)));
56
90
  }
57
91
 
58
- /** A lane agent's "Always:" rules, between their markers. */
59
- export function agentRules(root, config) {
92
+ /** A lane agent's "Always:" rules, between their markers: with `lane`, its layers' standards too. */
93
+ export function agentRules(root, config, lane = null) {
60
94
  const template = readFileSync(join(TEMPLATES, 'agent.md'), 'utf8');
61
- return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config)));
95
+ return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config, lane)));
62
96
  }
63
97
 
64
98
  /** Whether two renderings say the same, whatever their whitespace and list indentation. */
@@ -87,17 +121,16 @@ function stateOf(text, block, legacy) {
87
121
  */
88
122
  export function instructionsState(root, config) {
89
123
  const lead = leadSection(root, config);
90
- const rules = agentRules(root, config);
91
124
  const files = [
92
125
  { 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`,
126
+ ...Object.entries(config.lanes ?? {})
127
+ .filter(([, l]) => existsSync(join(root, `.claude/agents/${l.agent}.md`)))
128
+ .map(([name, l]) => ({
129
+ file: `.claude/agents/${l.agent}.md`,
96
130
  kind: 'lane',
97
- block: rules,
131
+ block: agentRules(root, config, name),
98
132
  legacy: AGENT_LEGACY,
99
- }))
100
- .filter((f) => existsSync(join(root, f.file))),
133
+ })),
101
134
  ];
102
135
  return files.map(({ file, kind, block, legacy }) => {
103
136
  const path = join(root, file);
@@ -0,0 +1,89 @@
1
+ // One agent per lane, across every session in the project (docs/specs/23-the-team.md, SCHED-1). Two
2
+ // agents in one lane build on overlapping files in their worktrees and conflict at merge; the plan's
3
+ // graph is for running different lanes at once. Starting a lane's agent claims the lane, and the claim
4
+ // ends when the agent finishes, its session ends, or the lead releases a lane whose agent has stopped.
5
+ // Kept in the main checkout, one file per lane, so every session and worktree on the machine sees them,
6
+ // and two agents started in the same moment can't both take a lane: the file is created exclusively.
7
+ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { projectWorktrees } from './rules.mjs';
10
+
11
+ export const LANE_CLAIMS_DIR = '.claude/state/lanes';
12
+ /** A claim this old is taken to be left over from an agent nobody saw stop. */
13
+ export const LANE_CLAIM_STALE_MS = 12 * 3600_000;
14
+
15
+ const dirOf = (root) => join(projectWorktrees(root)[0] ?? root, LANE_CLAIMS_DIR);
16
+ const fileOf = (root, lane) => join(dirOf(root), `${lane.replace(/[^\w.-]/g, '_')}.json`);
17
+
18
+ /** A session's id as people read it: its first 8 characters. */
19
+ export const shortSession = (session) => (session ? String(session).slice(0, 8) : null);
20
+
21
+ function read(root, lane) {
22
+ try {
23
+ return JSON.parse(readFileSync(fileOf(root, lane), 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ const withAge = (claim, now) =>
30
+ claim && { ...claim, stale: now - Date.parse(claim.since) >= LANE_CLAIM_STALE_MS };
31
+
32
+ /** Every lane claimed now: [{ lane, story, session, since, stale }], by lane. */
33
+ export function laneClaims(root, now = Date.now()) {
34
+ let files = [];
35
+ try {
36
+ files = readdirSync(dirOf(root)).filter((f) => f.endsWith('.json'));
37
+ } catch {
38
+ return [];
39
+ }
40
+ return files
41
+ .map((f) => {
42
+ try {
43
+ return withAge(JSON.parse(readFileSync(join(dirOf(root), f), 'utf8')), now);
44
+ } catch {
45
+ return null;
46
+ }
47
+ })
48
+ .filter((c) => c?.lane)
49
+ .sort((a, b) => a.lane.localeCompare(b.lane));
50
+ }
51
+
52
+ /** The lane's claim, or null. */
53
+ export const laneClaim = (root, lane, now = Date.now()) => withAge(read(root, lane), now);
54
+
55
+ /**
56
+ * SCHED-1: claims `lane` for `story` in `session`: { ok: true } or { ok: false, claim } naming who has it.
57
+ * The session already holding it for the same story (a restart, a send-back) takes it again; so does
58
+ * anyone, from a stale claim.
59
+ */
60
+ export function claimLane(root, { lane, story = null, session = null }, now = Date.now()) {
61
+ const record = { lane, story, session, since: new Date(now).toISOString() };
62
+ mkdirSync(dirOf(root), { recursive: true });
63
+ try {
64
+ writeFileSync(fileOf(root, lane), `${JSON.stringify(record)}\n`, { flag: 'wx' });
65
+ return { ok: true };
66
+ } catch {
67
+ // held already: by whom, and whether it may be taken over
68
+ }
69
+ const held = laneClaim(root, lane, now);
70
+ const same = held && held.session === session && (held.story === story || !held.story);
71
+ if (held && !same && !held.stale) return { ok: false, claim: held };
72
+ writeFileSync(fileOf(root, lane), `${JSON.stringify(record)}\n`);
73
+ return { ok: true };
74
+ }
75
+
76
+ /** Ends the lane's claim; with `session`, only that session's. True when one ended. */
77
+ export function releaseLane(root, lane, { session = null } = {}) {
78
+ const held = read(root, lane);
79
+ if (!held || (session && held.session !== session)) return false;
80
+ rmSync(fileOf(root, lane), { force: true });
81
+ return true;
82
+ }
83
+
84
+ /** Ends every claim a session holds, as it ends: the lanes it named. */
85
+ export function releaseSession(root, session, now = Date.now()) {
86
+ const ended = laneClaims(root, now).filter((c) => c.session === session);
87
+ for (const c of ended) releaseLane(root, c.lane, { session });
88
+ return ended.map((c) => c.lane);
89
+ }
@@ -55,3 +55,14 @@ export function layerProblems(rel, source, config) {
55
55
  }
56
56
  return problems;
57
57
  }
58
+
59
+ /**
60
+ * The rule a layer problem broke, read back from its sentence (as layerProblems words it):
61
+ * { kind: 'layers', from, to } or { kind: 'layers', from, package }. Null for any other text.
62
+ */
63
+ export function brokenLayerRule(problem) {
64
+ const pkg = String(problem).match(/ \(([^()]+)\) may not import the package (\S+)\.$/);
65
+ if (pkg) return { kind: 'layers', from: pkg[1], package: pkg[2] };
66
+ const layer = String(problem).match(/ \(([^()]+)\) imports \S+ \(([^()]+)\); /);
67
+ return layer ? { kind: 'layers', from: layer[1], to: layer[2] } : null;
68
+ }
@@ -5,6 +5,7 @@
5
5
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { projectWorktrees } from './rules.mjs';
8
+ import { leadSession, makeLead, readSession } from './sessions.mjs';
8
9
 
9
10
  export const LOOP_FILE = '.claude/state/loop.json';
10
11
  /** LOOP-10: a lead session renews its lease well within this; one not renewed for this long has ended. */
@@ -34,10 +35,19 @@ const liveLead = (state, now) => {
34
35
  return now - Date.parse(lead.at) < LEAD_LEASE_MS ? { session: lead.session, at: lead.at } : null;
35
36
  };
36
37
 
37
- /** { paused, at, lead }: not paused when nothing was recorded; `lead` is the live lead session or null. */
38
+ /**
39
+ * { paused, at, lead }: not paused when nothing was recorded; `lead` is the live lead session or null.
40
+ * SESS-2: where sessions are recorded, the lead session is the lead; the lease is for a session code-kit's
41
+ * session hook never saw.
42
+ */
38
43
  export function readLoop(root, now = Date.now()) {
39
44
  const state = readRaw(root);
40
- return { paused: state.paused === true, at: state.at ?? null, lead: liveLead(state, now) };
45
+ const lead = leadSession(root, now);
46
+ return {
47
+ paused: state.paused === true,
48
+ at: state.at ?? null,
49
+ lead: lead ? { session: lead.id, at: lead.seen } : liveLead(state, now),
50
+ };
41
51
  }
42
52
 
43
53
  export function writeLoop(root, paused, now = Date.now()) {
@@ -49,6 +59,14 @@ export function writeLoop(root, paused, now = Date.now()) {
49
59
  * person moved the loop here): { ok: true } or { ok: false, lead } naming the session that has it.
50
60
  */
51
61
  export function claimLead(root, session, { take = false } = {}, now = Date.now()) {
62
+ // SESS-2: the loop runs in the lead session; Lead from here (`take`) makes this session the lead.
63
+ if (readSession(root, session)) {
64
+ if (take) makeLead(root, session, now);
65
+ const lead = leadSession(root, now);
66
+ return lead?.id === session
67
+ ? { ok: true, lead: null }
68
+ : { ok: false, lead: lead ? { session: lead.id, at: lead.seen } : null };
69
+ }
52
70
  const state = readRaw(root);
53
71
  const lead = liveLead(state, now);
54
72
  if (lead && lead.session !== session && !take) return { ok: false, lead };
@@ -12,6 +12,8 @@ import { readQueue, waiting } from './queue.mjs';
12
12
  import { decidedBy, laneOf, openRequests } from './rules.mjs';
13
13
  import { instructionsState } from './instructions.mjs';
14
14
  import { SECURITY_AGENT_FILE } from './security.mjs';
15
+ import { laneClaims } from './laneclaims.mjs';
16
+ import { pickPerLane } from './schedule.mjs';
15
17
 
16
18
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
17
19
  export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
@@ -280,7 +282,9 @@ export function nextStep(root, base) {
280
282
  }
281
283
  // HELM-4: a lane waiting on something the lead decides goes first: the lane can't go on without it.
282
284
  const forLeadToDecide = openRequests(root).flatMap((r, i) =>
283
- r.lane && decidedBy(r.names, config) === 'lead' ? [{ ...r, n: i + 1 }] : [],
285
+ r.lane && decidedBy(r.names, config, { kind: 'lane', lane: r.lane }) === 'lead'
286
+ ? [{ ...r, n: i + 1 }]
287
+ : [],
284
288
  );
285
289
  if (forLeadToDecide.length)
286
290
  return result(
@@ -311,6 +315,29 @@ export function nextStep(root, base) {
311
315
  `${queued[0]} passed review and is next in the merge queue${queued.length > 1 ? ` (then ${queued.slice(1).join(', ')})` : ''}.`,
312
316
  { args: queued[0] },
313
317
  );
318
+ // SCHED-1, SCHED-2: one story for each free lane, across every session; the open slice's first.
319
+ const busy = new Set([
320
+ ...laneClaims(root)
321
+ .filter((c) => !c.stale)
322
+ .map((c) => c.lane),
323
+ ...status.stories.filter((s) => s.state === 'in progress' && s.lane).map((s) => s.lane),
324
+ ]);
325
+ const sched = pickPerLane(status.stories, {
326
+ fixes,
327
+ ready,
328
+ busy,
329
+ open: config.harness.slices.open,
330
+ });
331
+ for (const list of [fixes, ready])
332
+ list.splice(0, list.length, ...list.filter((id) => sched.pick.includes(id)));
333
+ for (const a of sched.ahead)
334
+ attention.push(
335
+ `${a.id} is ahead of its slice (${a.slice}): its lane has nothing ready in the open slice.`,
336
+ );
337
+ if (sched.waiting.length)
338
+ attention.push(
339
+ `${sched.waiting.join(', ')} ${sched.waiting.length === 1 ? 'waits' : 'wait'} for ${sched.waiting.length === 1 ? 'its lane' : 'their lanes'}: a lane runs one story at a time.`,
340
+ );
314
341
  const then = [];
315
342
  // A sent-back story goes back to its lane with the review's findings, before new work starts.
316
343
  const toDispatch = [...fixes, ...ready];
@@ -358,6 +385,12 @@ export function nextStep(root, base) {
358
385
  'update',
359
386
  `${missingWords} Re-run /code-kit:init before the next story: it shows the person the difference before anything changes.`,
360
387
  );
388
+ // SCHED-1: ready stories whose lanes are all running one.
389
+ if (!building.length && sched.waiting.length)
390
+ return result(
391
+ 'wait',
392
+ `${sched.waiting.join(', ')} ${sched.waiting.length === 1 ? 'waits' : 'wait'} for ${sched.waiting.length === 1 ? 'its lane' : 'their lanes'}, running another story; a lane runs one story at a time.`,
393
+ );
361
394
  if (building.length)
362
395
  return result(
363
396
  'wait',
@@ -52,7 +52,7 @@ const field = (lines, name) =>
52
52
  .trim() ?? '';
53
53
 
54
54
  /**
55
- * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. A story's
55
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone, slice }]. A story's
56
56
  * milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
57
57
  */
58
58
  export function readStories(root, planPath) {
@@ -80,6 +80,8 @@ export function readStories(root, planPath) {
80
80
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
81
81
  status: STATES.includes(status) ? status : 'todo',
82
82
  milestone,
83
+ // SCHED-2: the slice it belongs to, a feature built across lanes, or null.
84
+ slice: field(body, 'Slice') || null,
83
85
  });
84
86
  });
85
87
  }
@@ -35,16 +35,25 @@ function append(root, record) {
35
35
  }
36
36
 
37
37
  /**
38
- * Every question, oldest first: { id, at, by, question, why, story, blocks, options: [{ label, effect }],
38
+ * Every question, oldest first: { id, at, by, session, count, question, why, story, blocks, options: [{ label, effect }],
39
39
  * state: 'open' | 'answered' | 'withdrawn', answer: { text, at, by, via } | null, withdrawn: { at, reason } | null }.
40
40
  */
41
41
  export function readQuestions(root) {
42
42
  const byId = new Map();
43
43
  for (const r of records(root)) {
44
44
  if (r.kind === 'ask')
45
- byId.set(r.id, { ...r, kind: undefined, state: 'open', answer: null, withdrawn: null });
45
+ byId.set(r.id, {
46
+ ...r,
47
+ kind: undefined,
48
+ session: r.session ?? null,
49
+ count: 1,
50
+ state: 'open',
51
+ answer: null,
52
+ withdrawn: null,
53
+ });
46
54
  const q = byId.get(r.id);
47
55
  if (!q || q.state !== 'open') continue;
56
+ if (r.kind === 'again') q.count += 1;
48
57
  if (r.kind === 'answer') {
49
58
  q.state = 'answered';
50
59
  q.answer = { text: r.answer, at: r.at, by: r.by, via: r.via };
@@ -73,20 +82,29 @@ export function parseOption(text) {
73
82
  */
74
83
  export function askQuestion(
75
84
  root,
76
- { by, question, why = null, story = null, blocks = false, options = [] },
85
+ { by, question, why = null, story = null, blocks = false, options = [], session = null },
77
86
  now = Date.now(),
78
87
  ) {
79
88
  const text = String(question).trim();
89
+ // OWN-3: the same question still open in the same session is one decision, asked again.
80
90
  const open = openQuestions(root).find(
81
- (q) => q.question === text && (q.story ?? null) === (story ?? null),
91
+ (q) =>
92
+ q.question === text &&
93
+ (q.story ?? null) === (story ?? null) &&
94
+ (q.session ?? null) === (session ?? null),
82
95
  );
83
- if (open) return { question: open, existed: true };
96
+ if (open) {
97
+ append(root, { kind: 'again', id: open.id, at: new Date(now).toISOString() });
98
+ return { question: readQuestions(root).find((q) => q.id === open.id), existed: true };
99
+ }
84
100
  const n = readQuestions(root).length + 1;
85
101
  const record = {
86
102
  kind: 'ask',
87
103
  id: `Q-${n}`,
88
104
  at: new Date(now).toISOString(),
89
105
  by,
106
+ // OWN-1: the session that asked it: it's asked, answered and heard there only.
107
+ session: session ?? null,
90
108
  question: text,
91
109
  why: why?.trim() || null,
92
110
  story: story ?? null,
@@ -1,6 +1,7 @@
1
1
  // Regression (docs/specs/18-regression.md). REG-1: a fix comes with a test that fails without it, as the
2
2
  // bug, and passes with it. REG-2: a test isn't weakened (deleted, skipped, cut down, its snapshot
3
- // rewritten) without the person's `tests` approval, which the lead can't delegate. The commit hook
3
+ // rewritten) without a `tests` approval: the lead's on a lane's branch where tests are delegated (spec 23,
4
+ // WHO-2), the person's otherwise and always on the lead's own. The commit hook
4
5
  // refuses a weakening commit; verify finds what a branch weakened, from git.
5
6
  import { execFileSync, spawnSync } from 'node:child_process';
6
7
  import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
@@ -31,6 +31,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
33
33
  import { globToRegExp, matchesAny } from './glob.mjs';
34
+ import { noteFor } from './standards.mjs';
34
35
 
35
36
  export const APPROVAL_LOG = '.claude/approval-log.jsonl';
36
37
  export const APPROVALS_DIR = '.claude/approvals';
@@ -80,19 +81,21 @@ export const delegateCommand = (names, lane) =>
80
81
  }>"`;
81
82
 
82
83
  /**
83
- * HELM-1: whether the lead grants approval `name` itself, by the project's delegated rules. Weakening a
84
- * test is always the person's; a package is the lead's where dependencies are delegated.
84
+ * HELM-1, WHO-1: whether the lead grants approval `name` itself, by the project's delegated rules. A
85
+ * package is the lead's where dependencies are delegated. WHO-2: a weakened test is the lead's on a lane's
86
+ * branch, where tests are delegated, and the person's on the lead's own: nobody else reviews those.
85
87
  */
86
- export function leadDecides(name, config) {
88
+ export function leadDecides(name, config, actor) {
87
89
  const rules = config?.approvals?.delegate;
88
- if (!rules || name === 'tests') return false;
90
+ if (!rules) return false;
91
+ if (name === 'tests') return rules.tests === true && actor?.kind === 'lane';
89
92
  if (String(name).startsWith(DEPENDENCY_APPROVAL)) return Boolean(rules.dependencies);
90
93
  return (rules.protected ?? []).includes(name);
91
94
  }
92
95
 
93
96
  /** HELM-2: whose decision approvals `names` are: the lead's when it may grant every one, else the person's. */
94
- export const decidedBy = (names, config) =>
95
- [names].flat().every((n) => leadDecides(n, config)) ? 'lead' : 'person';
97
+ export const decidedBy = (names, config, actor) =>
98
+ [names].flat().every((n) => leadDecides(n, config, actor)) ? 'lead' : 'person';
96
99
 
97
100
  /**
98
101
  * HELM-2: what an actor refused for `names` does next, after the refusal's first line. A lane asks the
@@ -107,7 +110,7 @@ export function askHowTo(names, actor, root, config, { allows, need } = {}) {
107
110
  (pkg
108
111
  ? 'the package, the story and requirement that need it, what the project already has that you checked, and anything lighter that would do'
109
112
  : 'the file, the story and requirement that need the change, and what you checked first');
110
- if (decidedBy(names, config) === 'lead') {
113
+ if (decidedBy(names, config, actor) === 'lead') {
111
114
  if (actor?.kind === 'lead')
112
115
  return `You decide this as the lead. Approve it only if the story's requirements need it${pkg ? ', nothing the project already depends on does the job, and it fits the principles and the layer it goes in' : ' and it keeps to the spec and the architecture'}:\n Decided by the lead: ${delegateCommand(names)}`;
113
116
  return `The lead decides this, not the person. Hand the lead a change request and stop: ${brief}. The lead approves it, or tells you what to use instead.\n Decided by the lead: ${delegateCommand(names, lane)}`;
@@ -277,19 +280,21 @@ export function currentBranch(root) {
277
280
 
278
281
  /** Who is acting: { kind: 'lead' | 'lane' | 'readonly', lane?, label }. */
279
282
  export function actorFor(input, root, config) {
283
+ // OWN-1: the session it acts in, whose decisions what it's refused become.
284
+ const session = input.session_id ?? null;
280
285
  const agent = input.agent_type;
281
286
  if (agent) {
282
287
  const lane = Object.keys(config.lanes).find((l) => config.lanes[l].agent === agent);
283
288
  return lane
284
- ? { kind: 'lane', lane, label: `${agent} (${lane} lane)` }
285
- : { kind: 'readonly', label: agent };
289
+ ? { kind: 'lane', lane, label: `${agent} (${lane} lane)`, session }
290
+ : { kind: 'readonly', label: agent, session };
286
291
  }
287
292
  const laneFile = join(root, LANE_FILE);
288
293
  if (existsSync(laneFile)) {
289
294
  const lane = readFileSync(laneFile, 'utf8').trim();
290
- return { kind: 'lane', lane, label: `${lane} lane worktree` };
295
+ return { kind: 'lane', lane, label: `${lane} lane worktree`, session };
291
296
  }
292
- return { kind: 'lead', label: 'lead session' };
297
+ return { kind: 'lead', label: 'lead session', session };
293
298
  }
294
299
 
295
300
  /** Where approvals live: the main checkout's approvals folder, shared by all of its worktrees. */
@@ -357,6 +362,7 @@ export function useApproval(root, name, actor) {
357
362
  const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
358
363
  const sameRequest = (a, b) =>
359
364
  a.actor === b.actor &&
365
+ (a.session ?? null) === (b.session ?? null) &&
360
366
  a.what === b.what &&
361
367
  [...a.names].sort().join() === [...b.names].sort().join();
362
368
 
@@ -422,6 +428,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now(), e
422
428
  at: new Date(now).toISOString(),
423
429
  actor: actor?.label ?? 'unknown',
424
430
  ...(actor?.kind === 'lane' ? { lane: actor.lane } : {}),
431
+ ...(actor?.session ? { session: actor.session } : {}),
425
432
  names: [names].flat(),
426
433
  what: String(what).slice(0, 200),
427
434
  why: String(why).slice(0, 300),
@@ -760,10 +767,10 @@ export function writeProblem(actor, rel, root, config) {
760
767
  if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
761
768
  recordRequest(root, actor, guarded.approval, rel, guarded.why);
762
769
  const who =
763
- decidedBy(guarded.approval, config) === 'lead'
770
+ decidedBy(guarded.approval, config, actor) === 'lead'
764
771
  ? "the lead's approval"
765
772
  : "a person's approval";
766
- return `${rel} is ${guarded.why} and needs ${who}. ${askHowTo(guarded.approval, actor, root, config)}`;
773
+ return `${rel} is ${guarded.why} and needs ${who}.${noteFor(root, config, { kind: 'protected', name: guarded.approval, glob: guarded.glob })} ${askHowTo(guarded.approval, actor, root, config)}`;
767
774
  }
768
775
  if (matchesAny(rel, config.anyActor)) return null;
769
776
  if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
@@ -0,0 +1,86 @@
1
+ // What starts next, lane by lane (docs/specs/23-the-team.md, SCHED-1 and SCHED-2), the pure parts. A lane
2
+ // runs one story at a time; the plan's graph is for running different lanes at once. Slices (a story's
3
+ // `**Slice:** <name>`, a feature that can be shown working across lanes) set the order, not a gate: a free
4
+ // lane takes a ready story in an open slice first, then one in the next slice while fewer than
5
+ // `harness.slices.open` are open, then any story outside a slice, and only then one ahead of its slice.
6
+
7
+ /** States a story is in once its work has started: its slice is open from then until all of it is done. */
8
+ const STARTED = new Set(['in progress', 'review', 'sent back', 'waiting on you', 'done']);
9
+
10
+ /** Each slice in plan order: [{ name, stories, open, done }]. */
11
+ export function slicesOf(stories) {
12
+ const byName = new Map();
13
+ for (const s of stories) {
14
+ if (!s.slice) continue;
15
+ if (!byName.has(s.slice)) byName.set(s.slice, []);
16
+ byName.get(s.slice).push(s);
17
+ }
18
+ return [...byName].map(([name, own]) => {
19
+ const done = own.every((s) => s.state === 'done');
20
+ return {
21
+ name,
22
+ stories: own.map((s) => s.id),
23
+ done,
24
+ open: !done && own.some((s) => STARTED.has(s.state)),
25
+ };
26
+ });
27
+ }
28
+
29
+ /**
30
+ * SCHED-1, SCHED-2: at most one story for each free lane. `fixes` (sent back, which go first in their
31
+ * lane) and `ready` are story ids; `busy` the lanes running a story now. Returns { pick, ahead, waiting,
32
+ * opened }: the ids to dispatch, those of them ahead of their slice ([{ id, slice }]), the ready or sent-back
33
+ * ids left for a lane's next turn, and the slices a pick opens.
34
+ */
35
+ export function pickPerLane(stories, { fixes = [], ready = [], busy = new Set(), open = 1 } = {}) {
36
+ const order = new Map(stories.map((s, i) => [s.id, i]));
37
+ const byId = new Map(stories.map((s) => [s.id, s]));
38
+ const slices = slicesOf(stories);
39
+ const opened = new Set(slices.filter((s) => s.open).map((s) => s.name));
40
+ const startOrder = [];
41
+ let room = Math.max(0, open - opened.size);
42
+ // The next slice in plan order that isn't open or done: the one a free lane may open.
43
+ const nextSlice = () => slices.find((s) => !s.done && !opened.has(s.name))?.name ?? null;
44
+ const rank = (id) => {
45
+ const slice = byId.get(id)?.slice ?? null;
46
+ if (slice && opened.has(slice)) return 0;
47
+ if (slice && room > 0 && slice === nextSlice()) return 1;
48
+ return slice ? 3 : 2;
49
+ };
50
+ const candidates = [...new Set([...fixes, ...ready])].filter((id) => byId.get(id)?.lane);
51
+ const lanes = [...new Set(candidates.map((id) => byId.get(id).lane))].sort(
52
+ (a, b) =>
53
+ Math.min(...candidates.filter((id) => byId.get(id).lane === a).map((id) => order.get(id))) -
54
+ Math.min(...candidates.filter((id) => byId.get(id).lane === b).map((id) => order.get(id))),
55
+ );
56
+ const pick = [];
57
+ const ahead = [];
58
+ // Lanes with something in an open slice choose first, so the feature in progress is never passed over.
59
+ const firstRank = (lane) =>
60
+ Math.min(...candidates.filter((id) => byId.get(id).lane === lane).map(rank));
61
+ for (const lane of [...lanes].sort((a, b) => firstRank(a) - firstRank(b))) {
62
+ if (busy.has(lane)) continue;
63
+ const own = candidates.filter((id) => byId.get(id).lane === lane);
64
+ const fix = own.find((id) => fixes.includes(id));
65
+ const chosen =
66
+ fix ??
67
+ [...own].sort((a, b) => rank(a) - rank(b) || order.get(a) - order.get(b)).find(Boolean);
68
+ if (!chosen) continue;
69
+ const r = fix ? null : rank(chosen);
70
+ const slice = byId.get(chosen).slice ?? null;
71
+ if (r === 1) {
72
+ opened.add(slice);
73
+ startOrder.push(slice);
74
+ room -= 1;
75
+ }
76
+ if (r === 3) ahead.push({ id: chosen, slice });
77
+ pick.push(chosen);
78
+ }
79
+ pick.sort((a, b) => order.get(a) - order.get(b));
80
+ return {
81
+ pick,
82
+ ahead,
83
+ waiting: candidates.filter((id) => !pick.includes(id)),
84
+ opened: startOrder,
85
+ };
86
+ }