@warren-dean/code-kit 0.12.2 → 0.14.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.
package/bin/code-kit.mjs CHANGED
@@ -79,7 +79,7 @@ import { PROJECT, projectMap, storyMap, trail } from '../hooks/lib/storymap.mjs'
79
79
  import { journal } from '../hooks/lib/journal.mjs';
80
80
  import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
81
81
  import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
82
- import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
82
+ import { claimLead, readLoop, releaseLead, writeLoop } from '../hooks/lib/loop-state.mjs';
83
83
  import {
84
84
  claimChanges,
85
85
  storyOfBranch,
@@ -106,6 +106,8 @@ import {
106
106
  import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
107
107
  import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
108
108
  import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
109
+ import { skippedLine } from '../hooks/lib/checks.mjs';
110
+ import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
109
111
 
110
112
  const out = (s) => process.stdout.write(`${s}\n`);
111
113
  // Piped into `head` and the like: stop quietly when the reader closes.
@@ -160,6 +162,7 @@ const FLAGS = [
160
162
  '--peek',
161
163
  '--off',
162
164
  '--blocks',
165
+ '--take',
163
166
  ];
164
167
  const flag = (name) => args.includes(name);
165
168
  const writeBaseline = flag('--write');
@@ -398,6 +401,8 @@ const VERIFY_GROUPS = {
398
401
  layers: 'Layer rules',
399
402
  baseline: 'Baseline',
400
403
  design: 'Designs before screens',
404
+ tests: 'Weakened tests',
405
+ regression: 'Fixes and their tests',
401
406
  checks: 'Checks',
402
407
  };
403
408
 
@@ -522,9 +527,11 @@ function merge(branch) {
522
527
 
523
528
  /**
524
529
  * Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
525
- * branch). Returns { into, message }; throws a MergeStop when nothing was merged.
530
+ * branch). With `commit`, the commit the lead's review passed, it verifies and merges exactly that
531
+ * commit, and refuses if the branch has moved on from it. Returns { into, message }; throws a
532
+ * MergeStop when nothing was merged.
526
533
  */
527
- function mergeOne(branch, config, byPerson) {
534
+ function mergeOne(branch, config, byPerson, commit = null) {
528
535
  const refuse = (message) => {
529
536
  throw new MergeStop('refused', message);
530
537
  };
@@ -583,13 +590,20 @@ function mergeOne(branch, config, byPerson) {
583
590
  // temporary worktree, where checks that need an install fail rather than being skipped.
584
591
  const verifyIn = checkedOut(branch) ?? worktree(branch, true);
585
592
  clean(verifyIn, branch);
593
+ const tip = g(verifyIn, 'rev-parse', 'HEAD').trim();
594
+ if (commit && tip !== commit)
595
+ fail(
596
+ `Nothing was merged: ${branch} has moved on since its review passed (${commit.slice(0, 7)} → ${tip.slice(0, 7)}). Review it again, then queue it.`,
597
+ 'moved',
598
+ );
586
599
  const lane = laneOfBranch(branch, config);
587
- const { changed, found } = verifyBranch({
600
+ const { changed, found, skipped } = verifyBranch({
588
601
  root: verifyIn,
589
602
  base: into,
590
603
  config,
591
604
  lane,
592
605
  checks: true,
606
+ branch,
593
607
  });
594
608
  const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
595
609
  found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
@@ -609,8 +623,8 @@ function mergeOne(branch, config, byPerson) {
609
623
  '-m',
610
624
  `Merge ${branch} into ${into}`,
611
625
  '-m',
612
- `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.`,
613
- branch,
626
+ `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) at ${tip.slice(0, 7)}${commit ? ', the commit the review passed,' : ''} and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.${skipped.length ? ` ${notRun(skipped)}` : ''}`,
627
+ tip,
614
628
  );
615
629
  } catch (e) {
616
630
  try {
@@ -644,7 +658,7 @@ function mergeOne(branch, config, byPerson) {
644
658
  }
645
659
  return {
646
660
  into,
647
- message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
661
+ message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}, at ${tip.slice(0, 7)}.${skipped.length ? ` ${notRun(skipped)}` : ''}${removed}`,
648
662
  };
649
663
  } finally {
650
664
  cleanup();
@@ -669,16 +683,24 @@ function queue() {
669
683
  ? `Took ${branch} out of the merge queue.`
670
684
  : `${branch} isn't waiting in the merge queue.`,
671
685
  );
686
+ let commit;
672
687
  try {
673
- execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
674
- stdio: 'ignore',
675
- });
688
+ commit = execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
689
+ encoding: 'utf8',
690
+ stdio: ['ignore', 'pipe', 'ignore'],
691
+ }).trim();
676
692
  } catch {
677
693
  die(`There is no branch ${branch}.`);
678
694
  }
679
- if (!enqueue(root, branch)) return out(`${branch} is already in the merge queue.`);
695
+ // The commit the review passed: the queue merges exactly it, and a branch that moves is reviewed again.
696
+ const added = enqueue(root, branch, commit);
697
+ if (!added) return out(`${branch} is already in the merge queue, at ${commit.slice(0, 7)}.`);
698
+ if (added === 'updated')
699
+ return out(`${branch} keeps its place in the merge queue, now at ${commit.slice(0, 7)}.`);
680
700
  const place = waiting(readQueue(root)).length;
681
- return out(`Queued ${branch} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`);
701
+ return out(
702
+ `Queued ${branch} at ${commit.slice(0, 7)} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`,
703
+ );
682
704
  }
683
705
  if (sub === 'rules') return rulesOntoBase(root, config);
684
706
  if (sub === 'merge') {
@@ -687,7 +709,7 @@ function queue() {
687
709
  const head = waiting(entries)[0];
688
710
  if (!head) return out('The merge queue is empty.');
689
711
  try {
690
- const { message } = mergeOne(head.branch, config, byPerson);
712
+ const { message } = mergeOne(head.branch, config, byPerson, head.commit ?? null);
691
713
  settle(root, head.branch, 'merged');
692
714
  const left = waiting(readQueue(root));
693
715
  return out(
@@ -697,6 +719,12 @@ function queue() {
697
719
  if (!(e instanceof MergeStop)) throw e;
698
720
  // MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
699
721
  if (e.kind === 'refused') die(e.message);
722
+ // A branch that moved after its review leaves the queue for the lead to review it again; it
723
+ // isn't sent back, since nothing is wrong with it yet.
724
+ if (e.kind === 'moved') {
725
+ settle(root, head.branch, 'moved', 'changed after its review');
726
+ die(`${e.message}\n${head.branch} leaves the queue until then.`);
727
+ }
700
728
  const into = intoName ?? config.branches.protected[0];
701
729
  const after = lastMerged(entries);
702
730
  const why =
@@ -722,7 +750,11 @@ function queue() {
722
750
  const left = waiting(entries);
723
751
  if (!left.length) out('The merge queue is empty.');
724
752
  else out(`${left.length} waiting to merge, in order:`);
725
- left.forEach((e, i) => out(` ${i + 1}. ${e.branch} (passed review ${e.passed})`));
753
+ left.forEach((e, i) =>
754
+ out(
755
+ ` ${i + 1}. ${e.branch} (passed review ${e.passed}${e.commit ? ` at ${e.commit.slice(0, 7)}` : ''})`,
756
+ ),
757
+ );
726
758
  }
727
759
 
728
760
  /**
@@ -906,6 +938,7 @@ function story(id) {
906
938
  config,
907
939
  lane,
908
940
  checks: false,
941
+ branch: s.branch,
909
942
  });
910
943
  report.verify = {
911
944
  checks: false,
@@ -1099,18 +1132,21 @@ function verify() {
1099
1132
  }
1100
1133
  }
1101
1134
  const lane = laneOfBranch(branch, config);
1102
- const { changed, found } = verifyBranch({
1135
+ const { changed, found, skipped } = verifyBranch({
1103
1136
  root: resolve('.'),
1104
1137
  base: from,
1105
1138
  config,
1106
1139
  lane,
1107
1140
  checks: !flag('--no-checks'),
1141
+ // REG-1 is code-kit's own, so CI's --no-checks still proves a fix.
1142
+ fixes: true,
1143
+ branch,
1108
1144
  });
1109
1145
  if (flag('--json')) {
1110
1146
  const count = Object.values(found).reduce((n, list) => n + list.length, 0);
1111
1147
  out(
1112
1148
  JSON.stringify(
1113
- { base: from, branch, lane, changed: changed.length, found, problems: count },
1149
+ { base: from, branch, lane, changed: changed.length, found, problems: count, skipped },
1114
1150
  null,
1115
1151
  2,
1116
1152
  ),
@@ -1128,10 +1164,15 @@ function verify() {
1128
1164
  out(`\n${title}:`);
1129
1165
  for (const p of found[key]) out(` ${p.split('\n').join('\n ')}`);
1130
1166
  }
1167
+ if (skipped.length) out(`\n${notRun(skipped)}`);
1131
1168
  if (count) die(`\n${count} problem(s). Fix them on this branch.`);
1132
- out('No problems.');
1169
+ out(skipped.length ? 'No problems in what ran.' : 'No problems.');
1133
1170
  }
1134
1171
 
1172
+ /** The checks that didn't run, for verify's and a merge's report. */
1173
+ const notRun = (skipped) =>
1174
+ `Not run, because a tool isn't installed here: ${skipped.map(skippedLine).join('; ')}. Install it where verify runs, or set "ifMissing": "fail" on the check so a missing tool fails it.`;
1175
+
1135
1176
  /**
1136
1177
  * ONB-7: whether a branch's changes need the security reviewer, and why: sign-in, permissions, outside
1137
1178
  * input, personal data, encryption, payments, secrets, commands built from strings, new dependencies,
@@ -1299,7 +1340,7 @@ async function approve(given) {
1299
1340
  );
1300
1341
  if (laneName !== undefined && !config.lanes[laneName])
1301
1342
  die(`There is no lane "${laneName}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
1302
- const known = new Set(['kit', ...config.protected.map((p) => p.approval)]);
1343
+ const known = new Set(['kit', TESTS_APPROVAL, ...config.protected.map((p) => p.approval)]);
1303
1344
  const isPackage = (n) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(n);
1304
1345
  const names = given.map((n) =>
1305
1346
  n.startsWith(DEPENDENCY_APPROVAL) || known.has(n) || !isPackage(n) ? n : dependencyApproval(n),
@@ -1327,6 +1368,10 @@ async function approve(given) {
1327
1368
  );
1328
1369
  const outside = [];
1329
1370
  for (const name of names) {
1371
+ if (name === TESTS_APPROVAL) {
1372
+ outside.push(`${name}: weakening a test is the person's to allow, and is never delegated`);
1373
+ continue;
1374
+ }
1330
1375
  if (name.startsWith(DEPENDENCY_APPROVAL)) {
1331
1376
  if (!rules.dependencies) {
1332
1377
  outside.push(`${name}: dependencies aren't delegated`);
@@ -1770,6 +1815,8 @@ function reportCommand() {
1770
1815
  /**
1771
1816
  * The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
1772
1817
  * person's act from the code-kit band, records it, so other tools' background work pauses with it.
1818
+ * LOOP-10: `loop lead` takes or renews the lease of the one session that runs the loop, and `loop
1819
+ * release` gives it up; the mod runs both, and the hooks refuse all four to agents.
1773
1820
  */
1774
1821
  function loopCommand() {
1775
1822
  const root = resolve('.');
@@ -1778,14 +1825,31 @@ function loopCommand() {
1778
1825
  writeLoop(root, sub === 'pause');
1779
1826
  return out(sub === 'pause' ? 'The lead loop is paused.' : 'The lead loop is running.');
1780
1827
  }
1781
- if (sub !== undefined) die('code-kit loop [--json] | loop pause | loop resume');
1828
+ // LOOP-10: one session in the project runs the loop; the mod takes and renews the lease.
1829
+ if (sub === 'lead' || sub === 'release') {
1830
+ if (!sessionId) die(`code-kit loop ${sub} needs the session: --session <id>.`);
1831
+ if (sub === 'release')
1832
+ return out(
1833
+ releaseLead(root, sessionId)
1834
+ ? 'This session no longer runs the lead loop.'
1835
+ : "This session wasn't running the lead loop.",
1836
+ );
1837
+ const r = claimLead(root, sessionId, { take: flag('--take') });
1838
+ if (!r.ok)
1839
+ die(
1840
+ `Another session (${r.lead.session}, renewed ${r.lead.at}) runs the lead loop in this project. Leave it there, or take it over with --take.`,
1841
+ );
1842
+ return out('This session runs the lead loop.');
1843
+ }
1844
+ if (sub !== undefined)
1845
+ die(
1846
+ 'code-kit loop [--json] | loop pause | loop resume | loop lead --session <id> [--take] | loop release --session <id>',
1847
+ );
1782
1848
  const state = readLoop(root);
1783
1849
  out(
1784
1850
  flag('--json')
1785
1851
  ? JSON.stringify(state)
1786
- : state.paused
1787
- ? `The lead loop is paused (since ${state.at}).`
1788
- : 'The lead loop is running.',
1852
+ : `${state.paused ? `The lead loop is paused (since ${state.at}).` : 'The lead loop is running.'}${state.lead ? ` Session ${state.lead.session} runs it.` : ''}`,
1789
1853
  );
1790
1854
  }
1791
1855
 
@@ -1837,7 +1901,7 @@ function usage() {
1837
1901
  ' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
1838
1902
  ' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
1839
1903
  ' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
1840
- ' | loop [--json] | loop pause|resume\n' +
1904
+ ' | loop [--json] | loop pause|resume | loop lead|release --session id [--take]\n' +
1841
1905
  ' | map <story>|project [--json] | trail [--json] | journal [--json]\n' +
1842
1906
  ' | story <id> [--json] | timeline [--json]\n' +
1843
1907
  ' | team [--json] [--fresh] | team since [--json] [--peek]\n' +
@@ -15,6 +15,7 @@ import { CONFIG_FILE } from './lib/config.mjs';
15
15
  import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
16
16
  import { matchesAny } from './lib/glob.mjs';
17
17
  import { noteBefore } from './lane-audit.mjs';
18
+ import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
18
19
  import {
19
20
  APPROVAL_LOG,
20
21
  actorFor,
@@ -185,6 +186,11 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
185
186
  block(
186
187
  `Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
187
188
  );
189
+ // LOOP-10: which session runs the loop is the mod's to record, and moving it the person's.
190
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(lead|release)\b`)))
191
+ block(
192
+ `Blocked: which session runs the lead loop is recorded by the code-kit band, and moving it is the person's own act (Lead from here, in the band). Carry on with your work.\nCommand: ${cmd}`,
193
+ );
188
194
  // TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
189
195
  if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
190
196
  block(
@@ -452,6 +458,40 @@ function recordApprovals() {
452
458
  grantedAt: null,
453
459
  });
454
460
  }
461
+ // REG-2: a commit that weakens a test waits for the person's `tests` approval, which nobody else can
462
+ // give; it's recorded in the log, so verify accepts the branch, and used up by this commit.
463
+ const weakened = commitWeakenings(
464
+ root,
465
+ files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
466
+ config,
467
+ );
468
+ if (weakened.length) {
469
+ const granted = approval(root, TESTS_APPROVAL, actor);
470
+ const said = (w) => `it ${w.reasons.join(', and ')}`;
471
+ if (!granted) {
472
+ const [w, ...more] = weakened;
473
+ recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
474
+ const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
475
+ block(
476
+ `Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
477
+ ` +
478
+ (more.length
479
+ ? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
480
+ `
481
+ : '') +
482
+ `A weakened test can hide a regression. Fix the code so the test passes as it was. If the test itself is wrong, ask the person: neither the lead nor a lane can approve it. They run
483
+ ${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`,
484
+ );
485
+ }
486
+ for (const w of weakened)
487
+ record(w.file, {
488
+ approval: TESTS_APPROVAL,
489
+ reason: granted.reason,
490
+ grantedAt: granted.grantedAt,
491
+ weakened: w.reasons,
492
+ });
493
+ useApproval(root, TESTS_APPROVAL, actor);
494
+ }
455
495
  // A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
456
496
  const manifests = files.filter((f) => isDependencyFile(f));
457
497
  if (manifests.length && actor.kind !== 'readonly') {
@@ -15,8 +15,12 @@ function directories(check, files, root) {
15
15
  return [...new Set(dirs)].map((d) => join(root, d)).filter((d) => existsSync(d));
16
16
  }
17
17
 
18
- /** The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. */
19
- function runCheck(check, files, root, actor) {
18
+ /**
19
+ * The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
20
+ * command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
21
+ * added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
22
+ */
23
+ function runCheck(check, files, root, actor, skipped) {
20
24
  if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
21
25
  return {
22
26
  title: `The ${actor.label} changed ${check.name} files it does not own:`,
@@ -37,6 +41,7 @@ function runCheck(check, files, root, actor) {
37
41
  return {
38
42
  title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
39
43
  };
44
+ skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
40
45
  return null; // not installed yet (fresh clone): the check can't run
41
46
  }
42
47
  if (res.error || res.status === null)
@@ -53,15 +58,23 @@ function runCheck(check, files, root, actor) {
53
58
  return null;
54
59
  }
55
60
 
56
- /** Runs every check whose files changed, in order; the first failure, or null when all pass. */
61
+ /**
62
+ * Runs every check whose files changed, in order: { failure, skipped }, where `failure` is the first
63
+ * failure or null, and `skipped` lists the checks that couldn't run because a tool isn't installed.
64
+ */
57
65
  export function runChecks(config, changed, root, actor) {
66
+ const skipped = [];
58
67
  for (const check of config.checks) {
59
68
  const files = changed.filter(
60
69
  (f) => matchesAny(f, check.files) && !matchesAny(f, check.exclude),
61
70
  );
62
71
  if (!files.length) continue;
63
- const failure = runCheck(check, files, root, actor);
64
- if (failure) return failure;
72
+ const failure = runCheck(check, files, root, actor, skipped);
73
+ if (failure) return { failure, skipped };
65
74
  }
66
- return null;
75
+ return { failure: null, skipped };
67
76
  }
77
+
78
+ /** A skipped check in a sentence: "typecheck (`tsc` isn't installed in apps/web)". */
79
+ export const skippedLine = (s) =>
80
+ `${s.check} (\`${s.command}\` isn't installed${s.where ? ` in ${s.where}` : ''})`;
@@ -93,6 +93,8 @@ export function withDefaults(c) {
93
93
  shell: { block: c.shell?.block ?? [], restricted: c.shell?.restricted ?? [] },
94
94
  postEdit: c.postEdit ?? [],
95
95
  checks: c.checks ?? [],
96
+ tests: c.tests ?? [],
97
+ regression: { prepare: c.regression?.prepare ?? null },
96
98
  branches: { protected: c.branches?.protected ?? ['main', 'master'] },
97
99
  approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
98
100
  harness: harnessSettings(c.harness),
@@ -211,6 +213,12 @@ export function validate(c) {
211
213
  need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
212
214
  });
213
215
  each('checks', (e, at) => validateCheck(e, at, lanes, need));
216
+ need(c.tests === undefined || isList(c.tests), '"tests" must be a list of globs: the test files');
217
+ need(
218
+ c.regression === undefined ||
219
+ (isObj(c.regression) && (c.regression.prepare === undefined || isStr(c.regression.prepare))),
220
+ '"regression" must be { prepare?: a command run in a fresh checkout before a fix\'s tests }',
221
+ );
214
222
  const known = ADAPTERS.map((a) => a.name);
215
223
  need(
216
224
  c.adapters === undefined ||
@@ -1,26 +1,66 @@
1
- // Whether the person has paused the lead loop (spec 05, LOOP-6), kept beside the merge queue so other
2
- // tools sharing the project can hold their own background work while it's paused. The mod records it
3
- // through the CLI; a session that starts begins unpaused.
1
+ // The lead loop's state, kept beside the merge queue (spec 05): whether the person has paused it
2
+ // (LOOP-6), so other tools sharing the project can hold their own background work while it's paused;
3
+ // and which session runs it (LOOP-10), so a second session opened in the project never prompts its
4
+ // lead too. The mod records both through the CLI.
4
5
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
6
  import { dirname, join } from 'node:path';
6
7
  import { projectWorktrees } from './rules.mjs';
7
8
 
8
9
  export const LOOP_FILE = '.claude/state/loop.json';
10
+ /** LOOP-10: a lead session renews its lease well within this; one not renewed for this long has ended. */
11
+ export const LEAD_LEASE_MS = 90000;
9
12
 
10
13
  const loopFile = (root) => join(projectWorktrees(root)[0] ?? root, LOOP_FILE);
11
14
 
12
- /** { paused, at }: not paused when nothing was recorded. */
13
- export function readLoop(root) {
15
+ function readRaw(root) {
14
16
  try {
15
17
  const state = JSON.parse(readFileSync(loopFile(root), 'utf8'));
16
- return { paused: state?.paused === true, at: state?.at ?? null };
18
+ return state && typeof state === 'object' ? state : {};
17
19
  } catch {
18
- return { paused: false, at: null };
20
+ return {};
19
21
  }
20
22
  }
21
23
 
22
- export function writeLoop(root, paused, now = Date.now()) {
24
+ function writeRaw(root, state) {
23
25
  const file = loopFile(root);
24
26
  mkdirSync(dirname(file), { recursive: true });
25
- writeFileSync(file, `${JSON.stringify({ paused, at: new Date(now).toISOString() })}\n`);
27
+ writeFileSync(file, `${JSON.stringify(state)}\n`);
28
+ }
29
+
30
+ /** The session holding the lead lease, while it holds it: { session, at }, or null. */
31
+ const liveLead = (state, now) => {
32
+ const lead = state.lead;
33
+ if (!lead?.session || !lead.at) return null;
34
+ return now - Date.parse(lead.at) < LEAD_LEASE_MS ? { session: lead.session, at: lead.at } : null;
35
+ };
36
+
37
+ /** { paused, at, lead }: not paused when nothing was recorded; `lead` is the live lead session or null. */
38
+ export function readLoop(root, now = Date.now()) {
39
+ const state = readRaw(root);
40
+ return { paused: state.paused === true, at: state.at ?? null, lead: liveLead(state, now) };
41
+ }
42
+
43
+ export function writeLoop(root, paused, now = Date.now()) {
44
+ writeRaw(root, { ...readRaw(root), paused, at: new Date(now).toISOString() });
45
+ }
46
+
47
+ /**
48
+ * LOOP-10: `session` takes or renews the lead lease. Another live session keeps it unless `take` (the
49
+ * person moved the loop here): { ok: true } or { ok: false, lead } naming the session that has it.
50
+ */
51
+ export function claimLead(root, session, { take = false } = {}, now = Date.now()) {
52
+ const state = readRaw(root);
53
+ const lead = liveLead(state, now);
54
+ if (lead && lead.session !== session && !take) return { ok: false, lead };
55
+ writeRaw(root, { ...state, lead: { session, at: new Date(now).toISOString() } });
56
+ return { ok: true, lead: null };
57
+ }
58
+
59
+ /** LOOP-10: the lead session ends: its lease goes, so the next session to act takes the loop at once. */
60
+ export function releaseLead(root, session) {
61
+ const state = readRaw(root);
62
+ if (state.lead?.session !== session) return false;
63
+ const { lead: _lead, ...rest } = state;
64
+ writeRaw(root, rest);
65
+ return true;
26
66
  }
@@ -26,7 +26,10 @@ const git = (root, ...args) => {
26
26
  }
27
27
  };
28
28
 
29
- /** The spec-design brief: { path, status, openQuestions: [question, ...] }, or null. */
29
+ /**
30
+ * The spec-design brief: { path, status, openQuestions: [question, ...], open: [{ question, owner,
31
+ * blocks }] }, or null. `open` reads the table's Owner and Blocks columns where it has them.
32
+ */
30
33
  export function readBrief(root, config) {
31
34
  const specs = config?.docs?.specs;
32
35
  const candidates = [specs && `${posix.dirname(specs)}/brief.md`, 'docs/brief.md'].filter(Boolean);
@@ -39,12 +42,27 @@ export function readBrief(root, config) {
39
42
  const start = lines.findIndex((l) => /^##\s+Open questions/i.test(l));
40
43
  const section = start < 0 ? [] : lines.slice(start + 1);
41
44
  const end = section.findIndex((l) => /^##\s/.test(l));
42
- const openQuestions = section
43
- .slice(0, end < 0 ? undefined : end)
44
- .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Question\s*\|/i.test(l))
45
- .map((l) => l.split('|')[1].trim())
46
- .filter((q) => q && !q.startsWith('{{'));
47
- return { path, status: status ?? null, openQuestions };
45
+ const rows = section.slice(0, end < 0 ? undefined : end).filter((l) => /^\|/.test(l));
46
+ const cells = (l) =>
47
+ l
48
+ .split('|')
49
+ .slice(1, -1)
50
+ .map((c) => c.trim());
51
+ const header = rows.find((l) => /^\|\s*Question\s*\|/i.test(l));
52
+ const columns = header ? cells(header).map((c) => c.toLowerCase()) : ['question'];
53
+ const at = (name) => columns.findIndex((c) => c.startsWith(name));
54
+ const open = rows
55
+ .filter((l) => l !== header && !/^\|\s*-/.test(l))
56
+ .map((l) => {
57
+ const c = cells(l);
58
+ return {
59
+ question: c[0] ?? '',
60
+ owner: at('owner') > 0 ? c[at('owner')] || null : null,
61
+ blocks: at('block') > 0 ? c[at('block')] || null : null,
62
+ };
63
+ })
64
+ .filter((q) => q.question && !q.question.startsWith('{{'));
65
+ return { path, status: status ?? null, openQuestions: open.map((q) => q.question), open };
48
66
  }
49
67
 
50
68
  const hasMarkdown = (dir) => {
@@ -87,7 +105,15 @@ export function nextStep(root, base) {
87
105
  const brief = readBrief(root, config);
88
106
  if (brief?.openQuestions.length) {
89
107
  attention.push(`${brief.openQuestions.length} open question(s) in ${brief.path}.`);
90
- briefQuestions = brief.openQuestions.map((question) => ({ question, path: brief.path }));
108
+ // A question the brief gives to the lead alone (Owner: Lead) is the lead's to settle, not the person's.
109
+ briefQuestions = brief.open
110
+ .filter(
111
+ (q) =>
112
+ !q.owner ||
113
+ !/\blead\b/i.test(q.owner) ||
114
+ /owner|person|you|product|client/i.test(q.owner),
115
+ )
116
+ .map((q) => ({ ...q, path: brief.path }));
91
117
  }
92
118
 
93
119
  if (!config) {
@@ -1,7 +1,7 @@
1
1
  // Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
2
2
  // headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
3
3
  // stories as `### ST-12 Title` followed by `**Lane:**`, `**Requirements:**`, `**Depends on:**` and
4
- // `**Status:**` lines. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
4
+ // `**Status:**` lines, and for a fix, `**Fixes:**`. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
5
5
  // lead's own stories (contracts, foundations, spikes), which the lead builds rather than dispatches;
6
6
  // `**Requirements:** none` marks a story that delivers no requirement, such as a spike.
7
7
  import { execFileSync } from 'node:child_process';
@@ -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, milestone }]. A story's
55
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. 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) {
@@ -75,6 +75,8 @@ export function readStories(root, planPath) {
75
75
  lane: field(body, 'Lane'),
76
76
  requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
77
77
  noRequirements: /^none\b/i.test(field(body, 'Requirements')),
78
+ // REG-1: what a fix story fixes, or null.
79
+ fixes: field(body, 'Fixes') || null,
78
80
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
79
81
  status: STATES.includes(status) ? status : 'todo',
80
82
  milestone,
@@ -11,7 +11,10 @@ const DONE_KEPT = 20;
11
11
 
12
12
  const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
13
13
 
14
- /** The queue's entries: [{ branch, passed, state, at?, reason? }], waiting ones in merge order. */
14
+ /**
15
+ * The queue's entries: [{ branch, commit, passed, state, at?, reason? }], waiting ones in merge order.
16
+ * `commit` is the one the lead's review passed; entries queued before 0.13.0 have none.
17
+ */
15
18
  export function readQueue(root) {
16
19
  try {
17
20
  const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
@@ -35,13 +38,26 @@ function writeQueue(root, entries) {
35
38
  /** The branches still to merge, in order (MQ-1). */
36
39
  export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
37
40
 
38
- /** Adds a branch the lead's review passed, at the back; a branch already waiting keeps its place. */
39
- export function enqueue(root, branch, now = Date.now()) {
41
+ /**
42
+ * Adds a branch the lead's review passed at `commit`, at the back; a branch already waiting keeps its
43
+ * place, with the commit its latest review passed.
44
+ */
45
+ export function enqueue(root, branch, commit, now = Date.now()) {
40
46
  const entries = readQueue(root);
41
- if (waiting(entries).some((e) => e.branch === branch)) return false;
47
+ const queued = waiting(entries).find((e) => e.branch === branch);
48
+ if (queued) {
49
+ if (queued.commit === commit) return false;
50
+ writeQueue(
51
+ root,
52
+ entries.map((e) =>
53
+ e === queued ? { ...e, commit, passed: new Date(now).toISOString() } : e,
54
+ ),
55
+ );
56
+ return 'updated';
57
+ }
42
58
  writeQueue(root, [
43
59
  ...entries.filter((e) => e.branch !== branch),
44
- { branch, passed: new Date(now).toISOString(), state: 'waiting' },
60
+ { branch, commit, passed: new Date(now).toISOString(), state: 'waiting' },
45
61
  ]);
46
62
  return true;
47
63
  }
@@ -57,7 +73,10 @@ export function dequeue(root, branch) {
57
73
  return true;
58
74
  }
59
75
 
60
- /** Sets a waiting branch's state: merging, merged, sent back (with why), or waiting again. */
76
+ /**
77
+ * Sets a waiting branch's state: merging, merged, sent back (with why), moved (it changed after its
78
+ * review, so it's reviewed again), or waiting again.
79
+ */
61
80
  export function settle(root, branch, state, reason = null, now = Date.now()) {
62
81
  const entries = readQueue(root).map((e) =>
63
82
  e.branch === branch && ['waiting', 'merging'].includes(e.state)