@warren-dean/code-kit 0.4.1 → 0.6.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.
@@ -5,19 +5,22 @@
5
5
  // approval log on commit and scans commits for secrets. Shell writes are also checked afterwards by
6
6
  // lane-audit.mjs. With `approvals.lead` on, the lead may act for the person on what they say in chat:
7
7
  // record their approvals (`code-kit approve`) and run blocked commands marked `person`.
8
- import { appendFileSync } from 'node:fs';
8
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { join, resolve } from 'node:path';
11
- import { addedPackages, dependencyApproval } from './lib/dependencies.mjs';
11
+ import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
12
+ import { CODE_KIT, GIT, codeOf, runs } from './lib/shell.mjs';
12
13
  import { block, start } from './lib/hook.mjs';
13
14
  import {
14
15
  APPROVAL_LOG,
15
16
  actorFor,
17
+ arrivesWithMerge,
16
18
  approval,
17
19
  approvalHowTo,
18
20
  currentBranch,
19
21
  dependencyChange,
20
22
  dependencyGrants,
23
+ useApproval,
21
24
  owns,
22
25
  protectedEntry,
23
26
  recordRequest,
@@ -27,28 +30,46 @@ import {
27
30
  const { input, project, config, error } = start();
28
31
  const cmd = String(input.tool_input?.command || '');
29
32
 
30
- const ALWAYS = [
31
- [/\bgit\s+push\b.*(--force\b|\s-f\b|--force-with-lease\b)/, 'Force-push is not allowed.'],
32
- [/--no-verify\b/, 'Skipping git hooks is not allowed.'],
33
+ // Commands are recognised where they run (lib/shell.mjs), so a commit message, a heredoc, a quoted
34
+ // --text or a grep pattern that mentions one doesn't trip its rule.
35
+ const RUNS = [
36
+ [
37
+ new RegExp(String.raw`^${GIT}push\b.*(--force\b|\s-f\b|--force-with-lease\b)`),
38
+ 'Force-push is not allowed.',
39
+ ],
40
+ [new RegExp(String.raw`^${GIT}\S+\b.*\s--no-verify\b`), 'Skipping git hooks is not allowed.'],
33
41
  [
34
- /\brm\s+-rf?\s+(\/|~|\$HOME|\.\.?)(\s|$)/,
42
+ /^rm\s+-rf?\s+(\/|~|\$HOME|\.\.?)(\s|$)/,
35
43
  'Refusing a destructive delete of a root, home or parent directory.',
36
44
  ],
37
- [/\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/, 'Piping downloads into a shell is not allowed.'],
38
- [/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.'],
45
+ ];
46
+ for (const [re, why] of RUNS) {
47
+ if (runs(cmd, re, { quoted: false })) block(`Blocked: ${why}\nCommand: ${cmd}`);
48
+ }
49
+ // These read the command whole: a pipe spans commands, and an approval or the log can be written
50
+ // through a quoted path.
51
+ const ALWAYS = [
52
+ [
53
+ /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/,
54
+ 'Piping downloads into a shell is not allowed.',
55
+ codeOf(cmd),
56
+ ],
57
+ [/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.', cmd],
39
58
  [
40
59
  /approval-log\.jsonl/,
41
60
  'The approval audit log is appended only by the commit hook; nobody edits it.',
61
+ cmd,
42
62
  ],
43
63
  ];
44
- for (const [re, why] of ALWAYS) {
45
- if (re.test(cmd)) block(`Blocked: ${why}\nCommand: ${cmd}`);
64
+ for (const [re, why, text] of ALWAYS) {
65
+ if (re.test(text)) block(`Blocked: ${why}\nCommand: ${cmd}`);
46
66
  }
47
67
  const packages = addedPackages(cmd);
48
- const approves = /\bcode-kit(\.mjs)?["']?\s+approve\b/.test(cmd);
49
- const delegatedMerge = /\bcode-kit(\.mjs)?["']?\s+merge\b/.test(cmd);
68
+ const approves = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+approve\b`));
69
+ const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+merge\b`));
70
+ const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
50
71
  if (error) {
51
- if (/\bgit\s+commit\b/.test(cmd)) block(`Blocked: ${error}\nFix the config before committing.`);
72
+ if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
52
73
  if (approves) block(`Blocked: ${error}\nApprovals wait until the config is valid.`);
53
74
  if (delegatedMerge) block(`Blocked: ${error}\nMerges wait until the config is valid.`);
54
75
  if (packages.length)
@@ -62,23 +83,35 @@ const branches = config.branches.protected
62
83
  // The branch is read before the command runs, so a command that switches to a protected branch and then
63
84
  // commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
64
85
  // protected branch directly (branch -f, update-ref) is a commit on it by another name.
65
- const writesHistory = /\bgit\s+(commit|merge|cherry-pick|rebase|revert|am)\b/.test(cmd);
66
- const switchesToProtected = new RegExp(
67
- `\\bgit\\s+(?:switch|checkout)\\s+(?:-[^\\s]+\\s+)*(${branches})(?![\\w./-])(?!\\s+--)`,
68
- ).test(cmd);
69
- const movesProtected =
86
+ const writesHistory = runs(
87
+ cmd,
88
+ new RegExp(String.raw`^${GIT}(commit|merge|cherry-pick|rebase|revert|am)\b`),
89
+ );
90
+ const switchesToProtected = runs(
91
+ cmd,
70
92
  new RegExp(
71
- `\\bgit\\s+branch\\s+(?:\\S+\\s+)*(?:-f|--force|-M|-C)\\b.*\\b(${branches})(?![\\w./-])`,
72
- ).test(cmd) ||
73
- new RegExp(
74
- `\\bgit\\s+update-ref\\s+(?:-\\S+\\s+)*(?:refs/heads/)?(${branches})(?![\\w./-])`,
75
- ).test(cmd);
93
+ String.raw`^${GIT}(?:switch|checkout)\s+(?:-[^\s]+\s+)*(${branches})(?![\w./-])(?!\s+--)`,
94
+ ),
95
+ );
96
+ const movesProtected =
97
+ runs(
98
+ cmd,
99
+ new RegExp(
100
+ String.raw`^${GIT}branch\s+(?:\S+\s+)*(?:-f|--force|-M|-C)\b.*\b(${branches})(?![\w./-])`,
101
+ ),
102
+ ) ||
103
+ runs(
104
+ cmd,
105
+ new RegExp(
106
+ String.raw`^${GIT}update-ref\s+(?:-\S+\s+)*(?:refs/heads/)?(${branches})(?![\w./-])`,
107
+ ),
108
+ );
76
109
  if ((switchesToProtected && writesHistory) || movesProtected) {
77
110
  block(
78
111
  `Blocked: never commit to, merge into or move ${config.branches.protected.join(' or ')}, even by switching to it first. Work on a branch; a person merges after review${config.approvals.delegate?.merge ? ', or the lead merges a verified branch with `code-kit merge <branch> --delegated`' : ''}.\nCommand: ${cmd}`,
79
112
  );
80
113
  }
81
- if (new RegExp(`\\bgit\\s+push\\b.*\\b(${branches})\\b`).test(cmd)) {
114
+ if (runs(cmd, new RegExp(String.raw`^${GIT}push\b.*\b(${branches})\b`))) {
82
115
  block(
83
116
  `Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
84
117
  );
@@ -107,6 +140,11 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
107
140
  block(
108
141
  `Blocked: merging or approving as the person is the person's own act, from the code-kit pane or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
109
142
  );
143
+ // The harness settings are the person's to change, from the pane or their terminal.
144
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
145
+ block(
146
+ `Blocked: changing the harness settings is the person's own act, from the code-kit pane (/harness) or their terminal. Tell them what you'd change and why, and stop.\nCommand: ${cmd}`,
147
+ );
110
148
  if (delegatedMerge && actor.kind !== 'lead')
111
149
  block(
112
150
  `Blocked: only the lead merges into a protected branch. Finish your story and commit it on your branch; the lead reviews and merges it.\nCommand: ${cmd}`,
@@ -180,10 +218,7 @@ try {
180
218
  } catch {
181
219
  // not a git repository yet
182
220
  }
183
- if (
184
- /\bgit\s+(commit|merge|cherry-pick|rebase|revert)\b/.test(cmd) &&
185
- config.branches.protected.includes(branch)
186
- ) {
221
+ if (writesHistory && config.branches.protected.includes(branch)) {
187
222
  block(
188
223
  `Blocked: never commit or merge on ${branch}. Work on a branch; a person merges after review.`,
189
224
  );
@@ -253,6 +288,8 @@ function recordApprovals() {
253
288
  }),
254
289
  );
255
290
  for (const file of files) {
291
+ // Concluding a merge: what arrives as the other branch has it was logged there, with its approvals.
292
+ if (arrivesWithMerge(root, file, { staged: true })) continue;
256
293
  const entry = protectedEntry(file, config);
257
294
  const granted = entry && approval(root, entry.approval, actor);
258
295
  const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
@@ -283,12 +320,30 @@ function recordApprovals() {
283
320
  grantedAt: null,
284
321
  });
285
322
  }
323
+ // A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
324
+ const manifests = files.filter((f) => isDependencyFile(f));
325
+ if (manifests.length && actor.kind !== 'readonly') {
326
+ // The lines each manifest gains on HEAD, read from the working tree: this hook runs before
327
+ // `git add … && git commit` has staged anything, and a new manifest isn't tracked yet.
328
+ const added = manifests
329
+ .flatMap((f) => {
330
+ const before = new Set(git('show', `HEAD:${f}`).split('\n'));
331
+ const now = existsSync(join(root, f)) ? readFileSync(join(root, f), 'utf8') : '';
332
+ return now.split('\n').filter((l) => !before.has(l));
333
+ })
334
+ .join('\n');
335
+ for (const g of dependencyGrants(root, actor)) {
336
+ const pkg = g.name.slice('dep-'.length).replaceAll('+', '/');
337
+ const quoted = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+ if (new RegExp(`["'\\s/]${quoted}["'@=:\\s]`).test(added)) useApproval(root, g.name, actor);
339
+ }
340
+ }
286
341
  if (lines.length === 0) return;
287
342
  appendFileSync(join(root, APPROVAL_LOG), `${lines.join('\n')}\n`);
288
343
  git('add', APPROVAL_LOG);
289
344
  }
290
345
 
291
- if (/\bgit\s+commit\b/.test(cmd)) {
346
+ if (commits) {
292
347
  recordApprovals();
293
348
  const scan = spawnSync('gitleaks', ['protect', '--staged', '--redact', '--no-banner'], {
294
349
  cwd: root,
@@ -5,7 +5,14 @@
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { join } from 'node:path';
8
- import { APPROVAL_LOG, LANE_FILE, actorFor, actorRoot, writeProblem } from './lib/rules.mjs';
8
+ import {
9
+ APPROVAL_LOG,
10
+ LANE_FILE,
11
+ actorFor,
12
+ actorRoot,
13
+ arrivesWithMerge,
14
+ writeProblem,
15
+ } from './lib/rules.mjs';
9
16
 
10
17
  // The commit hook appends to the approval log and stages it; if that commit then fails, the
11
18
  // appended lines are left uncommitted. That is the only change to the log that is not a problem.
@@ -36,11 +43,15 @@ export function auditProblems(input, config) {
36
43
  const root = actorRoot(input);
37
44
  if (!root) return [];
38
45
  const actor = actorFor(input, root, config);
39
- return changedFiles(root)
40
- .filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
41
- .map((rel) => [rel, writeProblem(actor, rel, root, config)])
42
- .filter(([, problem]) => problem)
43
- .map(([rel, problem]) => `${rel}: ${problem.split('\n')[0]}`);
46
+ return (
47
+ changedFiles(root)
48
+ .filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
49
+ // Mid-merge, the incoming branch's changes are its own, not this actor's writes.
50
+ .filter((rel) => !arrivesWithMerge(root, rel))
51
+ .map((rel) => [rel, writeProblem(actor, rel, root, config)])
52
+ .filter(([, problem]) => problem)
53
+ .map(([rel, problem]) => `${rel}: ${problem.split('\n')[0]}`)
54
+ );
44
55
  }
45
56
 
46
57
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -1,6 +1,7 @@
1
1
  // The project's rules live in .claude/code-kit.json. The hooks read it from the project the session
2
2
  // was opened in; a project without one is not governed by the kit, and an invalid one fails closed.
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
+ import { harnessProblems, harnessSettings } from './harness.mjs';
4
5
  import { join } from 'node:path';
5
6
  import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
6
7
 
@@ -31,6 +32,9 @@ export function loadConfig(projectDir) {
31
32
  /** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
32
33
  export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
33
34
 
35
+ /** An anyActor entry's glob: entries are globs, or { glob, reviewer } naming who reviews changes to it. */
36
+ export const anyActorGlob = (e) => (typeof e === 'string' ? e : e?.glob);
37
+
34
38
  /** The engine's fixed rules, merged into the project's: the kit and Claude's own files belong to the lead. */
35
39
  export function withDefaults(c) {
36
40
  return {
@@ -39,7 +43,11 @@ export function withDefaults(c) {
39
43
  paths: [...c.lead.paths, '.claude/**'],
40
44
  outside: [...(c.lead.outside ?? []), ...CLAUDE_OWN],
41
45
  },
42
- anyActor: [...(c.anyActor ?? []), '.claude/state/**'],
46
+ anyActor: [...(c.anyActor ?? []).map(anyActorGlob), '.claude/state/**'],
47
+ // Who reviews changes to files any agent may write; the lead unless an entry names a lane.
48
+ anyActorReviewers: (c.anyActor ?? [])
49
+ .filter((e) => typeof e === 'object' && e?.reviewer)
50
+ .map((e) => ({ glob: e.glob, reviewer: e.reviewer })),
43
51
  protected: [KIT, ...(c.protected ?? []).filter((p) => p.glob !== KIT.glob)],
44
52
  layers: c.layers ?? [],
45
53
  importAliases: c.importAliases ?? {},
@@ -48,6 +56,7 @@ export function withDefaults(c) {
48
56
  checks: c.checks ?? [],
49
57
  branches: { protected: c.branches?.protected ?? ['main', 'master'] },
50
58
  approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
59
+ harness: harnessSettings(c.harness),
51
60
  };
52
61
  }
53
62
 
@@ -71,7 +80,12 @@ function delegateRules(d) {
71
80
  ],
72
81
  minWeeklyDownloads: d.dependencies.minWeeklyDownloads ?? 10_000,
73
82
  maxMonthsSinceRelease: d.dependencies.maxMonthsSinceRelease ?? 18,
74
- allowInstallScripts: d.dependencies.allowInstallScripts === true,
83
+ // true (any), false (none), or the kinds allowed: binary, build (lib/registry.mjs).
84
+ allowInstallScripts:
85
+ d.dependencies.allowInstallScripts === true ||
86
+ (Array.isArray(d.dependencies.allowInstallScripts)
87
+ ? d.dependencies.allowInstallScripts
88
+ : false),
75
89
  }
76
90
  : null,
77
91
  };
@@ -113,9 +127,20 @@ export function validate(c) {
113
127
  c.lead?.outside === undefined || isList(c.lead.outside),
114
128
  '"lead.outside" must be a list of globs',
115
129
  );
116
- need(c.anyActor === undefined || isList(c.anyActor), '"anyActor" must be a list of globs');
117
130
  validateLanes(c, need);
118
131
  const lanes = Object.keys(isObj(c.lanes) ? c.lanes : {});
132
+ need(
133
+ c.anyActor === undefined ||
134
+ (Array.isArray(c.anyActor) &&
135
+ c.anyActor.every(
136
+ (e) =>
137
+ isStr(e) ||
138
+ (isObj(e) &&
139
+ isStr(e.glob) &&
140
+ (e.reviewer === undefined || e.reviewer === 'lead' || lanes.includes(e.reviewer))),
141
+ )),
142
+ '"anyActor" must be a list of globs, or { "glob", "reviewer" } with the lead or a lane as reviewer',
143
+ );
119
144
  each('protected', (e, at) => {
120
145
  need(isStr(e.glob) && isStr(e.why), `${at} needs "glob" and "why"`);
121
146
  need(
@@ -159,13 +184,16 @@ export function validate(c) {
159
184
  (dep.licences === undefined || isList(dep.licences)) &&
160
185
  isCount(dep.minWeeklyDownloads) &&
161
186
  isCount(dep.maxMonthsSinceRelease) &&
162
- [undefined, true, false].includes(dep.allowInstallScripts)))),
163
- '"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }',
187
+ ([undefined, true, false].includes(dep.allowInstallScripts) ||
188
+ (isList(dep.allowInstallScripts) &&
189
+ dep.allowInstallScripts.every((k) => ['binary', 'build', 'other'].includes(k))))))),
190
+ '"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
164
191
  );
165
192
  need(
166
193
  c.branches === undefined || isList(c.branches?.protected),
167
194
  '"branches.protected" must be a list of branch names',
168
195
  );
196
+ p.push(...harnessProblems(c.harness));
169
197
  return p;
170
198
  }
171
199
 
@@ -2,6 +2,10 @@
2
2
  // lanes, layers and rules added, removed or changed, and which files would change owner.
3
3
  import { ownerOf } from './rules.mjs';
4
4
 
5
+ /** An anyActor entry in words: its glob, with its reviewer when named. */
6
+ const anyActorText = (e) =>
7
+ typeof e === 'string' ? e : `${e.glob} (reviewed by ${e.reviewer ?? 'the lead'})`;
8
+
5
9
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
6
10
  const lists = (a = [], b = []) => ({
7
11
  added: b.filter((x) => !a.includes(x)),
@@ -95,7 +99,7 @@ export function diffConfigs(before, after) {
95
99
  for (const [label, a, b] of [
96
100
  ['lead paths', before.lead?.paths, after.lead?.paths],
97
101
  ['lead outside paths', before.lead?.outside, after.lead?.outside],
98
- ['any-actor paths', before.anyActor, after.anyActor],
102
+ ['any-actor paths', before.anyActor?.map(anyActorText), after.anyActor?.map(anyActorText)],
99
103
  ['protected branches', before.branches?.protected, after.branches?.protected],
100
104
  ]) {
101
105
  const change = listChange('', a, b);
@@ -0,0 +1,131 @@
1
+ // The harness's settings (docs/specs/12-settings.md): the `harness` section of .claude/code-kit.json,
2
+ // what the mod's loop, hold, reviewer and usage read. Defaults fill what the section leaves out; a value
3
+ // out of range is a config problem like any other.
4
+
5
+ export const AUTONOMY = ['autonomous', 'propose', 'off'];
6
+ export const MODELS = ['haiku', 'sonnet', 'opus'];
7
+
8
+ /** Each setting by its dotted key: its default, and why a value is wrong (or null when it's right). */
9
+ export const SETTINGS = {
10
+ autonomy: {
11
+ default: 'autonomous',
12
+ problem: (v) => (AUTONOMY.includes(v) ? null : `must be one of ${AUTONOMY.join(', ')}`),
13
+ about: 'How the lead loop acts: on its own, by proposing each step, or not at all',
14
+ },
15
+ 'hold.minutes': {
16
+ default: 2,
17
+ problem: (v) =>
18
+ Number.isFinite(v) && v >= 0 && v <= 30
19
+ ? null
20
+ : 'must be a number of minutes from 0 (off) to 30',
21
+ about: 'How long a call waiting on your approval is held before it is refused',
22
+ },
23
+ 'stall.nudgeMinutes': {
24
+ default: 5,
25
+ problem: (v) =>
26
+ Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
27
+ about: 'Minutes without a tool call before a lane agent is nudged',
28
+ },
29
+ 'stall.restartMinutes': {
30
+ default: 10,
31
+ problem: (v) =>
32
+ Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
33
+ about: 'Minutes without a tool call before a lane agent is restarted',
34
+ },
35
+ 'stall.maxRestarts': {
36
+ default: 2,
37
+ problem: (v) =>
38
+ Number.isInteger(v) && v >= 0 && v <= 10 ? null : 'must be a whole number from 0 to 10',
39
+ about: 'Restarts on one story before the lane is flagged for you',
40
+ },
41
+ 'agents.reviewer.on': {
42
+ default: false,
43
+ problem: (v) => (typeof v === 'boolean' ? null : 'must be true or false'),
44
+ about: 'A background reviewer for each finished branch',
45
+ },
46
+ 'agents.reviewer.model': {
47
+ default: null,
48
+ problem: (v) =>
49
+ v === null || (typeof v === 'string' && v.length > 0)
50
+ ? null
51
+ : `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
52
+ about: "The reviewer's model; none means the session's",
53
+ },
54
+ 'background.pauseAtPercent': {
55
+ default: 80,
56
+ problem: (v) =>
57
+ Number.isFinite(v) && v > 0 && v <= 100 ? null : 'must be a percentage from 1 to 100',
58
+ about: "The plan's 5-hour use at which background agents pause",
59
+ },
60
+ };
61
+
62
+ const get = (obj, key) =>
63
+ key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
64
+ const set = (obj, key, value) => {
65
+ const parts = key.split('.');
66
+ let o = obj;
67
+ for (const k of parts.slice(0, -1)) o = o[k] && typeof o[k] === 'object' ? o[k] : (o[k] = {});
68
+ o[parts.at(-1)] = value;
69
+ return obj;
70
+ };
71
+
72
+ /** Every key the section holds, as dotted keys: `stall.nudgeMinutes`, `agents.reviewer.on`… */
73
+ function keysOf(obj, prefix = '') {
74
+ return Object.entries(obj ?? {}).flatMap(([k, v]) =>
75
+ v && typeof v === 'object' && !Array.isArray(v) && !(`${prefix}${k}` in SETTINGS)
76
+ ? keysOf(v, `${prefix}${k}.`)
77
+ : [`${prefix}${k}`],
78
+ );
79
+ }
80
+
81
+ /** The settings in force: the section's values over the defaults, as a nested object. A value that's
82
+ * wrong (reported by `harnessProblems`) gives way to the default. */
83
+ export function harnessSettings(section) {
84
+ const out = {};
85
+ for (const [key, s] of Object.entries(SETTINGS)) {
86
+ const v = section && typeof section === 'object' ? get(section, key) : undefined;
87
+ set(out, key, v === undefined || s.problem(v) ? s.default : v);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** The section's problems, in the config's words. */
93
+ export function harnessProblems(section) {
94
+ if (section === undefined) return [];
95
+ if (!section || typeof section !== 'object' || Array.isArray(section))
96
+ return ['"harness" must be an object'];
97
+ const p = [];
98
+ for (const key of keysOf(section)) {
99
+ if (!(key in SETTINGS)) {
100
+ p.push(
101
+ `"harness.${key}" isn't a harness setting (they are ${Object.keys(SETTINGS).join(', ')})`,
102
+ );
103
+ continue;
104
+ }
105
+ const why = SETTINGS[key].problem(get(section, key));
106
+ if (why) p.push(`"harness.${key}" ${why}`);
107
+ }
108
+ const h = harnessSettings(section);
109
+ if (h.stall.restartMinutes <= h.stall.nudgeMinutes)
110
+ p.push('"harness.stall.restartMinutes" must be more than "harness.stall.nudgeMinutes"');
111
+ return p;
112
+ }
113
+
114
+ /** A value typed on the command line, as the setting's type: numbers, true and false, null, or text. */
115
+ export function parseSetting(key, text) {
116
+ const d = SETTINGS[key]?.default;
117
+ if (text === 'null') return null;
118
+ if (typeof d === 'boolean' || text === 'true' || text === 'false')
119
+ return text === 'true' ? true : text === 'false' ? false : text;
120
+ if (typeof d === 'number' && /^-?\d+(\.\d+)?$/.test(text)) return Number(text);
121
+ return text;
122
+ }
123
+
124
+ /** The raw config with one harness setting changed (a copy). */
125
+ export function withSetting(raw, key, value) {
126
+ const copy = structuredClone(raw);
127
+ copy.harness = set(copy.harness ?? {}, key, value);
128
+ return copy;
129
+ }
130
+
131
+ export { get as settingOf };
@@ -150,7 +150,9 @@ export function nextStep(root, base) {
150
150
  );
151
151
 
152
152
  const ids = (state) => status.stories.filter((s) => s.state === state).map((s) => s.id);
153
- const [review, building, blocked] = ['review', 'in progress', 'blocked'].map(ids);
153
+ const [review, building, blocked, fixes] = ['review', 'in progress', 'blocked', 'sent back'].map(
154
+ ids,
155
+ );
154
156
  // A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
155
157
  const defined = new Set(status.requirements.map((r) => r.id));
156
158
  const buildable = (s) =>
@@ -167,13 +169,22 @@ export function nextStep(root, base) {
167
169
  `${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
168
170
  );
169
171
  const then = [];
170
- if (review.length && ready.length) then.push({ step: 'dispatch', args: ready.join(' ') });
172
+ // A sent-back story goes back to its lane with the review's findings, before new work starts.
173
+ const toDispatch = [...fixes, ...ready];
174
+ if (review.length && toDispatch.length)
175
+ then.push({ step: 'dispatch', args: toDispatch.join(' ') });
171
176
  if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
172
177
  if (review.length)
173
178
  return result('review', `${review.join(', ')} finished and wait for review.`, {
174
179
  args: review.join(' '),
175
180
  then,
176
181
  });
182
+ if (fixes.length)
183
+ return result(
184
+ 'dispatch',
185
+ `${fixes.join(', ')} ${fixes.length === 1 ? 'was' : 'were'} sent back and ${fixes.length === 1 ? 'waits' : 'wait'} for the lane's fix: dispatch ${fixes.length === 1 ? 'it' : 'each'} again with the review's findings${ready.length ? `, then ${ready.join(', ')}` : ''}.`,
186
+ { args: toDispatch.join(' '), then },
187
+ );
177
188
  if (ready.length)
178
189
  return result('dispatch', `${ready.join(', ')} can start: their dependencies are done.`, {
179
190
  args: ready.join(' '),
@@ -7,6 +7,7 @@
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
+ import { sentBack } from './reviews.mjs';
10
11
 
11
12
  export const REQUIREMENT = /\b(?!ST-)([A-Z][A-Z0-9]*-\d+)\b/g;
12
13
  const STORY_HEADING = /^###\s+(ST-\d+)\b\s*(.*)$/;
@@ -125,18 +126,22 @@ function worktrees(root) {
125
126
  * Where each story is. The plan's `done` stands (the lead marks it after merging, and merged
126
127
  * branches are often deleted or squashed). Otherwise git decides: a branch with commits not in
127
128
  * `base` is in review; one level with it is in progress, or done once the plan had it in review.
128
- * Without a branch, the plan's status stands. A todo story is `blocked` until every story it
129
- * depends on is done, then `ready`.
129
+ * A branch still at the commit a review sent back is `sent back`, waiting for its lane's fix.
130
+ * Without a branch, the plan's status stands. A story is `blocked` while a story it depends on
131
+ * isn't done, whatever its branch holds; a todo story whose dependencies are done is `ready`.
130
132
  */
131
133
  export function storyStates(root, stories, base) {
132
134
  const trees = worktrees(root);
135
+ const backs = sentBack(root);
133
136
  const states = stories.map((story) => {
134
137
  const branch = storyBranch(story);
135
138
  const ref = story.lane ? branchRef(root, branch) : null;
136
139
  const ahead = ref ? Number(git(root, 'rev-list', '--count', `${base}..${ref}`) ?? 0) : 0;
137
140
  let state = story.status;
138
141
  if (ref && story.status !== 'done') {
139
- if (ahead > 0) state = 'review';
142
+ const head = backs[branch] ? git(root, 'rev-parse', ref) : null;
143
+ if (head && head === backs[branch].sha) state = 'sent back';
144
+ else if (ahead > 0) state = 'review';
140
145
  else state = story.status === 'review' ? 'done' : 'in progress';
141
146
  }
142
147
  return {
@@ -151,9 +156,10 @@ export function storyStates(root, stories, base) {
151
156
  });
152
157
  const done = new Set(states.filter((s) => s.state === 'done').map((s) => s.id));
153
158
  for (const s of states) {
154
- if (s.state !== 'todo') continue;
159
+ if (s.state === 'done') continue;
155
160
  s.waitingOn = s.dependsOn.filter((d) => !done.has(d));
156
- s.state = s.waitingOn.length ? 'blocked' : 'ready';
161
+ if (s.waitingOn.length) s.state = 'blocked';
162
+ else if (s.state === 'todo') s.state = 'ready';
157
163
  }
158
164
  return states;
159
165
  }
@@ -200,13 +206,18 @@ export function buildStatus(root, config, base, files) {
200
206
  let state = 'no story';
201
207
  if (r.removed) state = 'removed';
202
208
  else if (covering.length && covering.every((s) => s.state === 'done')) state = 'done';
203
- else if (covering.some((s) => ['in progress', 'review', 'done'].includes(s.state)))
209
+ else if (covering.some((s) => ['in progress', 'review', 'sent back', 'done'].includes(s.state)))
204
210
  state = 'in progress';
205
211
  else if (covering.length) state = 'todo';
206
212
  return { ...r, state, stories: covering.map((s) => s.id), tests: tested[r.id] ?? [] };
207
213
  });
208
214
  const drift = stories
209
- .filter((s) => !['ready', 'blocked'].includes(s.state) && s.state !== s.status)
215
+ .filter(
216
+ (s) =>
217
+ !['ready', 'blocked'].includes(s.state) &&
218
+ s.state !== s.status &&
219
+ !(s.state === 'sent back' && s.status === 'in progress'),
220
+ )
210
221
  .map((s) => ({ story: s.id, plan: s.status, actual: s.state }));
211
222
  return { stories, requirements: byRequirement, problems, drift };
212
223
  }
@@ -12,7 +12,35 @@ export const packageOf = (approvalName) =>
12
12
 
13
13
  const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
14
14
 
15
- /** { licence, weeklyDownloads, lastRelease (ISO), installScripts } for `pkg`, or { error }. */
15
+ // Kinds of install script, for the rule's allowInstallScripts:
16
+ // binary fetches a prebuilt binary for this platform (esbuild, sharp): a prebuild tool, or a script
17
+ // beside optional dependencies built per platform;
18
+ // build compiles native code from source (node-gyp);
19
+ // other anything else: code the package runs on install.
20
+ const BINARY_TOOLS =
21
+ /\b(prebuild-install|node-pre-gyp|node-gyp-build|prebuildify|napi-postinstall)\b/;
22
+ const PLATFORM = /(darwin|linux|win32|windows|freebsd|android|musl|arm64|x64|ia32)/;
23
+
24
+ /** The kind of install script a package version runs (binary, build or other), or null for none. */
25
+ export function installScriptKind(version) {
26
+ const scripts = INSTALL_SCRIPTS.map((s) => version?.scripts?.[s]).filter(Boolean);
27
+ if (!scripts.length) return null;
28
+ const text = scripts.join(' && ');
29
+ const perPlatform = Object.keys(version?.optionalDependencies ?? {}).filter((d) =>
30
+ PLATFORM.test(d),
31
+ );
32
+ if (BINARY_TOOLS.test(text) || perPlatform.length >= 2) return 'binary';
33
+ if (/\bnode-gyp\b|\bcmake-js\b/.test(text)) return 'build';
34
+ return 'other';
35
+ }
36
+
37
+ const KIND_WORDS = {
38
+ binary: 'downloads a prebuilt binary',
39
+ build: 'compiles native code',
40
+ other: 'runs its own code',
41
+ };
42
+
43
+ /** { licence, weeklyDownloads, lastRelease (ISO), installScripts, installScriptKind } for `pkg`, or { error }. */
16
44
  export async function npmFacts(pkg) {
17
45
  const name = pkg.startsWith('@')
18
46
  ? `@${encodeURIComponent(pkg.slice(1))}`
@@ -34,6 +62,7 @@ export async function npmFacts(pkg) {
34
62
  weeklyDownloads,
35
63
  lastRelease: (latest && doc.time?.[latest]) ?? doc.time?.modified ?? null,
36
64
  installScripts: INSTALL_SCRIPTS.some((s) => version?.scripts?.[s]),
65
+ installScriptKind: installScriptKind(version),
37
66
  };
38
67
  } catch (e) {
39
68
  return { error: `couldn't reach the npm registry (${e.message})` };
@@ -57,7 +86,12 @@ export function dependencyRuleProblems(facts, rule, now = Date.now()) {
57
86
  problems.push(
58
87
  `its last release was ${Number.isFinite(months) ? `${Math.floor(months)} months` : 'never'} ago, over ${rule.maxMonthsSinceRelease}`,
59
88
  );
60
- if (facts.installScripts && !rule.allowInstallScripts)
61
- problems.push('installing it runs scripts');
89
+ if (facts.installScripts) {
90
+ const kind = facts.installScriptKind ?? 'other';
91
+ const allowed =
92
+ rule.allowInstallScripts === true ||
93
+ (Array.isArray(rule.allowInstallScripts) && rule.allowInstallScripts.includes(kind));
94
+ if (!allowed) problems.push(`installing it runs a script that ${KIND_WORDS[kind]}`);
95
+ }
62
96
  return problems;
63
97
  }