@warren-dean/code-kit 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +46 -23
  5. package/bin/code-kit.mjs +271 -21
  6. package/hooks/guard-agent.mjs +36 -0
  7. package/hooks/guard-bash.mjs +23 -11
  8. package/hooks/guard-paths.mjs +11 -0
  9. package/hooks/hooks.json +20 -0
  10. package/hooks/lane-audit.mjs +38 -1
  11. package/hooks/lib/adapters/hub/client.mjs +177 -25
  12. package/hooks/lib/config.mjs +148 -5
  13. package/hooks/lib/decisionstore.mjs +89 -0
  14. package/hooks/lib/harness.mjs +7 -0
  15. package/hooks/lib/hook.mjs +7 -0
  16. package/hooks/lib/instructions.mjs +1 -1
  17. package/hooks/lib/laneclaims.mjs +89 -0
  18. package/hooks/lib/loop-state.mjs +20 -2
  19. package/hooks/lib/next.mjs +34 -1
  20. package/hooks/lib/plan.mjs +3 -1
  21. package/hooks/lib/questions.mjs +23 -5
  22. package/hooks/lib/regression.mjs +2 -1
  23. package/hooks/lib/rules.mjs +18 -12
  24. package/hooks/lib/schedule.mjs +86 -0
  25. package/hooks/lib/sessions.mjs +223 -0
  26. package/hooks/lib/snapshot.mjs +9 -3
  27. package/hooks/mod/decisions.mjs +1 -28
  28. package/hooks/mod/loop.mjs +8 -0
  29. package/hooks/mod/mission.mjs +21 -19
  30. package/hooks/mod/register.mjs +118 -41
  31. package/hooks/mod/register.test.ts +244 -73
  32. package/hooks/mod/view.mjs +98 -47
  33. package/hooks/post-edit-check.mjs +3 -0
  34. package/hooks/session.mjs +24 -0
  35. package/hooks/stop-check.mjs +12 -0
  36. package/package.json +1 -1
  37. package/skills/decide/SKILL.md +1 -1
  38. package/skills/dispatch/SKILL.md +2 -2
  39. package/skills/init/SKILL.md +1 -1
  40. package/skills/lead/SKILL.md +28 -0
  41. package/skills/next/SKILL.md +1 -1
  42. package/skills/spec-design/SKILL.md +2 -1
  43. package/skills/team-init/SKILL.md +1 -1
  44. package/templates/claude-md.md +3 -1
  45. package/templates/plan.md +2 -1
@@ -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
+ }
@@ -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';
@@ -81,19 +81,21 @@ export const delegateCommand = (names, lane) =>
81
81
  }>"`;
82
82
 
83
83
  /**
84
- * HELM-1: whether the lead grants approval `name` itself, by the project's delegated rules. Weakening a
85
- * 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.
86
87
  */
87
- export function leadDecides(name, config) {
88
+ export function leadDecides(name, config, actor) {
88
89
  const rules = config?.approvals?.delegate;
89
- if (!rules || name === 'tests') return false;
90
+ if (!rules) return false;
91
+ if (name === 'tests') return rules.tests === true && actor?.kind === 'lane';
90
92
  if (String(name).startsWith(DEPENDENCY_APPROVAL)) return Boolean(rules.dependencies);
91
93
  return (rules.protected ?? []).includes(name);
92
94
  }
93
95
 
94
96
  /** HELM-2: whose decision approvals `names` are: the lead's when it may grant every one, else the person's. */
95
- export const decidedBy = (names, config) =>
96
- [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';
97
99
 
98
100
  /**
99
101
  * HELM-2: what an actor refused for `names` does next, after the refusal's first line. A lane asks the
@@ -108,7 +110,7 @@ export function askHowTo(names, actor, root, config, { allows, need } = {}) {
108
110
  (pkg
109
111
  ? 'the package, the story and requirement that need it, what the project already has that you checked, and anything lighter that would do'
110
112
  : 'the file, the story and requirement that need the change, and what you checked first');
111
- if (decidedBy(names, config) === 'lead') {
113
+ if (decidedBy(names, config, actor) === 'lead') {
112
114
  if (actor?.kind === 'lead')
113
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)}`;
114
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)}`;
@@ -278,19 +280,21 @@ export function currentBranch(root) {
278
280
 
279
281
  /** Who is acting: { kind: 'lead' | 'lane' | 'readonly', lane?, label }. */
280
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;
281
285
  const agent = input.agent_type;
282
286
  if (agent) {
283
287
  const lane = Object.keys(config.lanes).find((l) => config.lanes[l].agent === agent);
284
288
  return lane
285
- ? { kind: 'lane', lane, label: `${agent} (${lane} lane)` }
286
- : { kind: 'readonly', label: agent };
289
+ ? { kind: 'lane', lane, label: `${agent} (${lane} lane)`, session }
290
+ : { kind: 'readonly', label: agent, session };
287
291
  }
288
292
  const laneFile = join(root, LANE_FILE);
289
293
  if (existsSync(laneFile)) {
290
294
  const lane = readFileSync(laneFile, 'utf8').trim();
291
- return { kind: 'lane', lane, label: `${lane} lane worktree` };
295
+ return { kind: 'lane', lane, label: `${lane} lane worktree`, session };
292
296
  }
293
- return { kind: 'lead', label: 'lead session' };
297
+ return { kind: 'lead', label: 'lead session', session };
294
298
  }
295
299
 
296
300
  /** Where approvals live: the main checkout's approvals folder, shared by all of its worktrees. */
@@ -358,6 +362,7 @@ export function useApproval(root, name, actor) {
358
362
  const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
359
363
  const sameRequest = (a, b) =>
360
364
  a.actor === b.actor &&
365
+ (a.session ?? null) === (b.session ?? null) &&
361
366
  a.what === b.what &&
362
367
  [...a.names].sort().join() === [...b.names].sort().join();
363
368
 
@@ -423,6 +428,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now(), e
423
428
  at: new Date(now).toISOString(),
424
429
  actor: actor?.label ?? 'unknown',
425
430
  ...(actor?.kind === 'lane' ? { lane: actor.lane } : {}),
431
+ ...(actor?.session ? { session: actor.session } : {}),
426
432
  names: [names].flat(),
427
433
  what: String(what).slice(0, 200),
428
434
  why: String(why).slice(0, 300),
@@ -761,7 +767,7 @@ export function writeProblem(actor, rel, root, config) {
761
767
  if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
762
768
  recordRequest(root, actor, guarded.approval, rel, guarded.why);
763
769
  const who =
764
- decidedBy(guarded.approval, config) === 'lead'
770
+ decidedBy(guarded.approval, config, actor) === 'lead'
765
771
  ? "the lead's approval"
766
772
  : "a person's approval";
767
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)}`;
@@ -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
+ }