@warren-dean/code-kit 0.6.0 → 0.8.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.
@@ -9,8 +9,10 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
12
- import { CODE_KIT, GIT, codeOf, runs } from './lib/shell.mjs';
12
+ import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
13
13
  import { block, start } from './lib/hook.mjs';
14
+ import { CONFIG_FILE } from './lib/config.mjs';
15
+ import { noteBefore } from './lane-audit.mjs';
14
16
  import {
15
17
  APPROVAL_LOG,
16
18
  actorFor,
@@ -54,10 +56,14 @@ const ALWAYS = [
54
56
  'Piping downloads into a shell is not allowed.',
55
57
  codeOf(cmd),
56
58
  ],
57
- [/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.', cmd],
59
+ [
60
+ /\.claude\/approvals/,
61
+ "Approvals are created only by a person, with a `!` shell command. A shell command may not name them, even to read them; read them with the Read tool (or Glob to list them), which can't write.",
62
+ cmd,
63
+ ],
58
64
  [
59
65
  /approval-log\.jsonl/,
60
- 'The approval audit log is appended only by the commit hook; nobody edits it.',
66
+ "The approval audit log is appended only by the commit hook; nobody edits it. A shell command may not name it, even to read it; read it with the Read tool, which can't write.",
61
67
  cmd,
62
68
  ],
63
69
  ];
@@ -66,7 +72,8 @@ for (const [re, why, text] of ALWAYS) {
66
72
  }
67
73
  const packages = addedPackages(cmd);
68
74
  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`));
75
+ const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+(?:queue\s+)?merge\b`));
76
+ const queues = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+queue\s+(?:add|drop)\b`));
70
77
  const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
71
78
  if (error) {
72
79
  if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
@@ -83,6 +90,14 @@ const branches = config.branches.protected
83
90
  // The branch is read before the command runs, so a command that switches to a protected branch and then
84
91
  // commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
85
92
  // protected branch directly (branch -f, update-ref) is a commit on it by another name.
93
+ /** Whether `file` is committed at HEAD in the repository at `root`. */
94
+ function committedOnHead(root, file) {
95
+ return (
96
+ spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${file}`], { stdio: 'ignore' }).status ===
97
+ 0
98
+ );
99
+ }
100
+
86
101
  const writesHistory = runs(
87
102
  cmd,
88
103
  new RegExp(String.raw`^${GIT}(commit|merge|cherry-pick|rebase|revert|am)\b`),
@@ -119,11 +134,16 @@ if (runs(cmd, new RegExp(String.raw`^${GIT}push\b.*\b(${branches})\b`))) {
119
134
 
120
135
  const sessionIn = sessionRoot(input, project);
121
136
  const actor = actorFor(input, sessionIn ?? project, config);
137
+ // A read-only agent's command is judged afterwards by what it changed (lane-audit).
138
+ if (actor.kind === 'readonly') noteBefore(input);
122
139
  // The lead acts for the person only where the config says the person may approve from chat.
123
140
  const forPerson = actor.kind === 'lead' && config.approvals.lead;
124
141
 
125
- for (const { pattern, why, person } of config.shell.block) {
126
- if (!new RegExp(pattern).test(cmd) || (person && forPerson)) continue;
142
+ for (const { pattern, why, person, command } of config.shell.block) {
143
+ // `command: true`: the pattern names a command, matched from the start of each one the line runs, so a
144
+ // heredoc or a quoted message that mentions it isn't refused. Otherwise it reads the whole text.
145
+ const matched = command ? runs(cmd, new RegExp(pattern)) : new RegExp(pattern).test(cmd);
146
+ if (!matched || (person && forPerson)) continue;
127
147
  const hint =
128
148
  person && actor.kind === 'lead'
129
149
  ? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
@@ -140,11 +160,20 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
140
160
  block(
141
161
  `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}`,
142
162
  );
163
+ // Pausing and resuming the lead loop is the person's, from the band or their terminal.
164
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
165
+ block(
166
+ `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}`,
167
+ );
143
168
  // The harness settings are the person's to change, from the pane or their terminal.
144
169
  if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
145
170
  block(
146
171
  `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
172
  );
173
+ if (queues && actor.kind !== 'lead')
174
+ block(
175
+ `Blocked: only the lead puts branches in the merge queue, after its review passes them. Finish your story and commit it on your branch; the lead reviews it.\nCommand: ${cmd}`,
176
+ );
148
177
  if (delegatedMerge && actor.kind !== 'lead')
149
178
  block(
150
179
  `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}`,
@@ -218,21 +247,30 @@ try {
218
247
  } catch {
219
248
  // not a git repository yet
220
249
  }
221
- if (writesHistory && config.branches.protected.includes(branch)) {
250
+ // SETUP-1: the one exception is setting up. Until the protected branch has the rules committed, the
251
+ // lead commits the setup the person approved straight onto it, so no first merge needs anyone; from
252
+ // that commit on, the branch is protected by the rules it now holds.
253
+ const settingUp =
254
+ actor.kind === 'lead' &&
255
+ runs(cmd, new RegExp(String.raw`^${GIT}commit\b`)) &&
256
+ !runs(cmd, new RegExp(String.raw`^${GIT}(merge|cherry-pick|rebase|revert|am)\b`)) &&
257
+ !committedOnHead(root, CONFIG_FILE);
258
+ if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
222
259
  block(
223
- `Blocked: never commit or merge on ${branch}. Work on a branch; a person merges after review.`,
260
+ `Blocked: never commit or merge on ${branch}. Work on a branch; the lead merges it after review (or the person, where merges aren't delegated).`,
224
261
  );
225
262
  }
226
263
 
227
- // Kit files edited through the shell follow the same rule as edits through Write/Edit.
228
- const touchesKit =
229
- /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/.test(cmd);
264
+ // Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
265
+ // names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
266
+ // (`npm install > /tmp/log; ls .claude/skills`) isn't taken for a write to the kit. A write the shell
267
+ // hides (a script, a variable) is still caught after the call, as a change outside the actor's paths.
268
+ const KIT_PATH = /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/;
230
269
  // a real redirect into a file; `2>&1` and `>/dev/null` are not writes
231
- const writes =
232
- /((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)/.test(
233
- cmd,
234
- );
235
- if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
270
+ const WRITES =
271
+ /((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)/;
272
+ const kitWrite = commandsOf(cmd).some((c) => KIT_PATH.test(c.text) && WRITES.test(c.text));
273
+ if (kitWrite && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
236
274
  recordRequest(
237
275
  root,
238
276
  actor,
@@ -2,8 +2,9 @@
2
2
  // PostToolUse (Bash) and part of Stop: shell commands can write files without going through the
3
3
  // path guard, so after each one every uncommitted change in the worktree is checked against the
4
4
  // same rules (lib/rules.mjs). Anything the actor may not write is reported for revert.
5
- import { readFileSync } from 'node:fs';
5
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { tmpdir } from 'node:os';
7
8
  import { join } from 'node:path';
8
9
  import {
9
10
  APPROVAL_LOG,
@@ -39,12 +40,54 @@ export function changedFiles(root) {
39
40
  .filter((f) => f !== LANE_FILE);
40
41
  }
41
42
 
43
+ // A read-only agent (a reviewer, a card writer) works in a checkout it shares, usually the lead's,
44
+ // whose uncommitted work isn't the agent's. So its shell commands are judged by what each one changed:
45
+ // the uncommitted files are noted before the command runs (guard-bash) and compared after it.
46
+ const snapshotFile = (id) => join(tmpdir(), 'code-kit-shell', `${id}.json`);
47
+ const signature = (root, rel) => {
48
+ try {
49
+ const s = statSync(join(root, rel));
50
+ return `${s.size}:${s.mtimeMs}`;
51
+ } catch {
52
+ return 'gone';
53
+ }
54
+ };
55
+
56
+ /** Notes the uncommitted files and their state before a read-only agent's command runs. */
57
+ export function noteBefore(input) {
58
+ const root = actorRoot(input);
59
+ if (!root || !input.tool_use_id) return;
60
+ const before = Object.fromEntries(changedFiles(root).map((rel) => [rel, signature(root, rel)]));
61
+ try {
62
+ mkdirSync(join(tmpdir(), 'code-kit-shell'), { recursive: true });
63
+ writeFileSync(snapshotFile(input.tool_use_id), JSON.stringify(before));
64
+ } catch {
65
+ // without a note, every uncommitted change is judged, as for any actor
66
+ }
67
+ }
68
+
69
+ /** What was noted before the command, taken once; null when nothing was. */
70
+ function takeNote(input) {
71
+ if (!input.tool_use_id) return null;
72
+ const file = snapshotFile(input.tool_use_id);
73
+ try {
74
+ const before = JSON.parse(readFileSync(file, 'utf8'));
75
+ rmSync(file, { force: true });
76
+ return before;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
42
82
  export function auditProblems(input, config) {
43
83
  const root = actorRoot(input);
44
84
  if (!root) return [];
45
85
  const actor = actorFor(input, root, config);
86
+ const before = actor.kind === 'readonly' ? takeNote(input) : null;
46
87
  return (
47
88
  changedFiles(root)
89
+ // A read-only agent answers for what its command changed, not for the checkout it shares.
90
+ .filter((rel) => !before || before[rel] !== signature(root, rel))
48
91
  .filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
49
92
  // Mid-merge, the incoming branch's changes are its own, not this actor's writes.
50
93
  .filter((rel) => !arrivesWithMerge(root, rel))
@@ -1,35 +1,58 @@
1
- // Context Graph (ctx) keeps a project's engineering context in `.ctx/`: ratified rules and concepts in
2
- // graph.ctx, settings in config.toml, the decisions agents record as they work in decisions.ctx, and
3
- // each file's card (its why) in cards.ctx. Every agent records decisions and cards for the files it
4
- // edits, so those two files are writable by any actor; the rules and settings
5
- // are the lead's, and changes to them go through the approval log. Ratifying a rule is a person's act,
6
- // signed with a commit trailer, so no Claude actor may write that trailer, unless the project lets the
7
- // lead act on what the person says in chat (`approvals.lead`). A delegated ratification, signed
8
- // `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under the repository's [delegate]
9
- // rules: only the lead may write that trailer, and Context Graph's gate checks the kinds it covers.
10
- // Ratifying with `--commit`, and dropping a proposal, are the person's acts in ctx too, so no actor runs
11
- // them; only a command in command position counts, so text that mentions one isn't refused.
1
+ // Context Graph (ctx) keeps a project's engineering context in `.ctx/`: rules and concepts in graph.ctx
2
+ // (proposals in proposals.ctx), settings in config.toml, the decisions agents record as they work in
3
+ // decisions.ctx, and each file's card (its why) in cards.ctx. It runs in the background, so every agent
4
+ // writes its cards, its decisions and the guidance it finds; the settings are the lead's, and a change
5
+ // to them goes through the approval log. A rule that blocks edits, or a concept, needs a person's
6
+ // signature, a commit trailer, and Context Graph's own gate checks it, so no Claude actor may write that
7
+ // trailer, unless the project lets the lead act on what the person says in chat (`approvals.lead`). A
8
+ // delegated ratification, signed `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under
9
+ // the repository's [delegate] rules: only the lead may write that trailer.
10
+ // Ratifying with `--commit`, dropping a proposal and changing the harness settings are the person's acts
11
+ // in ctx too, so no actor runs
12
+ // them; only a command the line runs counts, so a heredoc or quoted text that mentions one isn't refused.
13
+ import { execFileSync } from 'node:child_process';
12
14
  import { existsSync } from 'node:fs';
13
15
  import { join } from 'node:path';
14
16
 
17
+ /** ctx's JSON for `args`, run in `root`, or null where ctx isn't installed or fails. */
18
+ function ctx(root, args) {
19
+ try {
20
+ return JSON.parse(
21
+ execFileSync('ctx', [...args, '--json'], {
22
+ cwd: root,
23
+ encoding: 'utf8',
24
+ stdio: ['ignore', 'pipe', 'ignore'],
25
+ timeout: 30000,
26
+ }),
27
+ );
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
15
33
  const CTX = String.raw`(?:ctx|node\s+["']?[^\s"']*ctx\.mjs["']?|npx\s+(?:--yes\s+)?@warren-dean/context-graph(?:@\S+)?)`;
16
- const PERSONS_ACT = String.raw`(?:^|[;&|\n])\s*${CTX}\s+(?:ratify\b[^;&|\n]*\s--commit\b|drop\b)`;
34
+ const PERSONS_ACT = String.raw`^${CTX}\s+(?:ratify\b[^;&|\n]*\s--commit\b|drop\b|settings\s+set\b)`;
35
+
36
+ /** A commit the person signed with their ratification trailer; agents may not write it. */
37
+ const PERSON_SIGNED = '^Ctx-Ratified-By:(?![^\\n]*\\(delegated\\))\\s*\\S';
17
38
 
18
39
  export default {
19
40
  name: 'context-graph',
20
41
  detect: (root) => existsSync(join(root, '.ctx')),
21
42
  config: {
22
43
  lead: ['.ctx/**'],
23
- anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx'],
44
+ // Context Graph runs in the background: every agent writes its cards and decisions, and the guidance
45
+ // rules it finds (which inform and never block). A rule that blocks edits, or a concept, still needs
46
+ // a person's signed commit, and Context Graph's own gate checks that on every pull request.
47
+ anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx', '.ctx/graph.ctx', '.ctx/proposals.ctx'],
24
48
  protected: [
49
+ // Its settings are the person's: a change is logged, or signed when they commit it themselves.
25
50
  {
26
- glob: '.ctx/graph.ctx',
51
+ glob: '.ctx/config.toml',
27
52
  approval: 'ctx',
28
- why: "Context Graph's rules and concepts",
29
- // A person's ratification (`ctx ratify --commit`, `ctx drop --commit`) is signed, not logged.
30
- signedBy: '^Ctx-Ratified-By:(?![^\\n]*\\(delegated\\))\\s*\\S',
53
+ why: "Context Graph's settings",
54
+ signedBy: PERSON_SIGNED,
31
55
  },
32
- { glob: '.ctx/config.toml', approval: 'ctx', why: "Context Graph's settings" },
33
56
  ],
34
57
  },
35
58
  shell: {
@@ -41,7 +64,8 @@ export default {
41
64
  },
42
65
  {
43
66
  pattern: PERSONS_ACT,
44
- why: "Ratifying a Context Graph proposal with --commit, and dropping one, are the person's own acts: they run them themselves, with a `!` command, or from the Context Graph pane.",
67
+ command: true,
68
+ why: "Ratifying a Context Graph proposal with --commit, dropping one, and changing Context Graph's harness settings are the person's own acts: they run them themselves, with a `!` command, or from the Context Graph pane.",
45
69
  },
46
70
  ],
47
71
  restricted: [
@@ -52,6 +76,50 @@ export default {
52
76
  },
53
77
  ],
54
78
  },
79
+ // What Context Graph knows of the lanes' work (the combined lane view, docs/specs/13-combined.md).
80
+ facts: {
81
+ /** Whether ctx's command line runs here. */
82
+ available: (root) => ctx(root, ['agents', '--session', 'none']) !== null,
83
+ /**
84
+ * Per agent type in a session (null for the main session): the edits made without understanding,
85
+ * each with the files still unread, and the cards owed.
86
+ */
87
+ lanes: (root, { session }) => {
88
+ const agents = session ? (ctx(root, ['agents', '--session', session]) ?? []) : [];
89
+ const byType = {};
90
+ for (const a of Array.isArray(agents) ? agents : []) {
91
+ const key = a.agentType ?? 'lead';
92
+ const f = (byType[key] ??= { withoutUnderstanding: [], cardsOwed: [] });
93
+ for (const e of a.edited ?? [])
94
+ if (!e.understood) f.withoutUnderstanding.push({ path: e.path, unread: e.missing ?? [] });
95
+ for (const c of a.cardsOwed ?? []) if (!f.cardsOwed.includes(c)) f.cardsOwed.push(c);
96
+ }
97
+ return byType;
98
+ },
99
+ /** Per file: the rules that apply to it, agreed and proposed, and its card: current, stale or missing, with its text. */
100
+ files: (root, paths) =>
101
+ Object.fromEntries(
102
+ paths.map((path) => {
103
+ const f = ctx(root, ['file', path]);
104
+ return [
105
+ path,
106
+ {
107
+ card: f?.card
108
+ ? { state: f.card.fresh === false ? 'stale' : 'current', text: f.card.text ?? '' }
109
+ : { state: 'missing', text: '' },
110
+ rules: (f?.rules ?? []).map((r) => ({
111
+ id: r.id,
112
+ text: r.text,
113
+ mode: r.mode,
114
+ proposed: r.mode === 'G?',
115
+ })),
116
+ },
117
+ ];
118
+ }),
119
+ ),
120
+ /** The command that opens Context Graph's pane on a file, given its path. */
121
+ open: { command: 'graph' },
122
+ },
55
123
  setup: [
56
124
  '.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
57
125
  '.gitattributes: .ctx/decisions.ctx merge=union and .ctx/cards.ctx merge=union (lanes record decisions and cards on parallel branches; merging keeps them all)',
@@ -11,6 +11,9 @@
11
11
  // shell.block (`person: true` marks a person's act the lead may do for them under
12
12
  // approvals.lead); restricted: commands only the lead and the listed lanes may run
13
13
  // setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
14
+ // facts what the tool knows of the lanes' work, for the mod's combined view: { available(root),
15
+ // lanes(root, { session }) → { [agent type or 'lead']: { withoutUnderstanding, cardsOwed } },
16
+ // files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
14
17
  //
15
18
  // The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
16
19
  import contextGraph from './context-graph.mjs';
@@ -22,6 +25,21 @@ export function activeAdapters(raw, root) {
22
25
  return ADAPTERS.filter((a) => raw.adapters?.[a.name] !== false && a.detect(root));
23
26
  }
24
27
 
28
+ /**
29
+ * What the active adapters that know of the lanes' work report (JOIN-1): [{ name, lanes?, files?, open }],
30
+ * only those whose tool is available here. `paths` asks for those files' facts too.
31
+ */
32
+ export function adapterFacts(raw, root, { session = null, paths = null } = {}) {
33
+ return activeAdapters(raw, root)
34
+ .filter((a) => a.facts && a.facts.available(root))
35
+ .map((a) => ({
36
+ name: a.name,
37
+ ...(a.facts.lanes ? { lanes: a.facts.lanes(root, { session }) } : {}),
38
+ ...(paths && a.facts.files ? { files: a.facts.files(root, paths) } : {}),
39
+ open: a.facts.open ?? null,
40
+ }));
41
+ }
42
+
25
43
  /** The effective config with every active adapter's contributions merged in. */
26
44
  export function applyAdapters(config, raw, root) {
27
45
  const active = activeAdapters(raw, root);
@@ -1,5 +1,6 @@
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
+ import { execFileSync } from 'node:child_process';
3
4
  import { existsSync, readFileSync } from 'node:fs';
4
5
  import { harnessProblems, harnessSettings } from './harness.mjs';
5
6
  import { join } from 'node:path';
@@ -29,6 +30,43 @@ export function loadConfig(projectDir) {
29
30
  return { config: effectiveConfig(raw, projectDir) };
30
31
  }
31
32
 
33
+ /**
34
+ * The config as committed on `ref`: what governs merging into it, so a branch's own config can't grant
35
+ * itself a merge its target doesn't allow. `{ config }`, `{ error }` when it's invalid, or `{}` when
36
+ * `ref` has none committed yet (the first merge, which brings code-kit's rules onto it).
37
+ */
38
+ export function committedConfig(root, ref, file = CONFIG_FILE) {
39
+ let text;
40
+ try {
41
+ text = execFileSync('git', ['-C', root, 'show', `${ref}:${file}`], {
42
+ encoding: 'utf8',
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ });
45
+ } catch {
46
+ return {};
47
+ }
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(text);
51
+ } catch (e) {
52
+ return { error: `${file} on ${ref} is not valid JSON: ${e.message}` };
53
+ }
54
+ const problems = validate(raw);
55
+ if (problems.length)
56
+ return { error: `${file} on ${ref} is invalid:\n ${problems.join('\n ')}` };
57
+ return { config: effectiveConfig(raw, root) };
58
+ }
59
+
60
+ /**
61
+ * Whether the lead may merge into the base (the first protected branch), by the rules committed there:
62
+ * `{ into, delegated, first }`, `first` when the base has no config committed yet.
63
+ */
64
+ export function mergeDelegation(root, config, file = CONFIG_FILE) {
65
+ const into = config.branches.protected[0];
66
+ const { config: target, error } = committedConfig(root, into, file);
67
+ return { into, delegated: Boolean(target?.approvals.delegate?.merge), first: !target && !error };
68
+ }
69
+
32
70
  /** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
33
71
  export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
34
72
 
@@ -252,10 +290,13 @@ function validateLayers(c, need, each) {
252
290
  function validateShell(s, lanes, need) {
253
291
  if (!need(isObj(s), '"shell" must be an object')) return;
254
292
  const rule = (r) => isObj(r) && isRegExp(r.pattern) && isStr(r.why);
255
- const blockRule = (r) => rule(r) && [undefined, true, false].includes(r.person);
293
+ const blockRule = (r) =>
294
+ rule(r) &&
295
+ [undefined, true, false].includes(r.person) &&
296
+ [undefined, true, false].includes(r.command);
256
297
  need(
257
298
  s.block === undefined || (Array.isArray(s.block) && s.block.every(blockRule)),
258
- '"shell.block" must be a list of { pattern, why, person? }',
299
+ '"shell.block" must be a list of { pattern, why, person?, command? }',
259
300
  );
260
301
  const restricted =
261
302
  s.restricted === undefined ||
@@ -1,6 +1,7 @@
1
1
  // New dependencies: which packages a shell command adds, and which files installing them changes.
2
2
  // Each package needs a person's approval (`dep-<package>`), which also covers those files.
3
3
  import { basename } from 'node:path';
4
+ import { commandsOf } from './shell.mjs';
4
5
 
5
6
  // What installing a package rewrites: manifests and lockfiles, wherever they sit.
6
7
  const DEPENDENCY_FILES = new Set([
@@ -86,22 +87,38 @@ const MANAGERS = {
86
87
  };
87
88
  MANAGERS.pip3 = MANAGERS.pip;
88
89
 
89
- /** The packages `cmd` adds as dependencies, in order: none for a bare install or a requirements file. */
90
+ /**
91
+ * The packages `cmd` adds as dependencies, in order: none for a bare install or a requirements file.
92
+ * Only the commands it runs count: quoted text and heredoc bodies are data (an echo, a commit message),
93
+ * except a shell's `-c` script, which runs.
94
+ */
90
95
  export function addedPackages(cmd) {
91
96
  const found = [];
92
- for (const segment of cmd.split(/&&|\|\||[;|\n]/)) {
93
- const tokens = segment
94
- .trim()
95
- .split(/\s+/)
96
- .map((t) => t.replace(/^["']|["']$/g, ''));
97
- tokens.forEach((token, i) => {
98
- const manager = MANAGERS[basename(token)];
99
- if (manager) found.push(...packagesAfter(tokens.slice(i + 1), manager));
97
+ for (const { text, code } of commandsOf(cmd)) {
98
+ // Words split where the quote-masked code has spaces, so a quoted word stays one word.
99
+ const words = [...code.matchAll(/\S+/g)].map((m) => ({
100
+ quoted: /^["']/.test(m[0]),
101
+ word: text.slice(m.index, m.index + m[0].length).replace(/^["']|["']$/g, ''),
102
+ }));
103
+ words.forEach(({ word, quoted }, i) => {
104
+ if (quoted) return;
105
+ if (SHELLS.has(basename(word)) && words[i + 1]?.word === '-c' && words[i + 2])
106
+ found.push(...addedPackages(words[i + 2].word));
107
+ const manager = MANAGERS[basename(word)];
108
+ if (manager)
109
+ found.push(
110
+ ...packagesAfter(
111
+ words.slice(i + 1).map((w) => w.word),
112
+ manager,
113
+ ),
114
+ );
100
115
  });
101
116
  }
102
117
  return [...new Set(found)];
103
118
  }
104
119
 
120
+ const SHELLS = new Set(['sh', 'bash', 'zsh']);
121
+
105
122
  // The words after a package manager's name: its subcommand must be one that adds packages, and the
106
123
  // words after that, other than flags and their values, are the packages.
107
124
  function packagesAfter(words, manager) {
@@ -15,9 +15,9 @@ export const SETTINGS = {
15
15
  'hold.minutes': {
16
16
  default: 2,
17
17
  problem: (v) =>
18
- Number.isFinite(v) && v >= 0 && v <= 30
18
+ Number.isFinite(v) && v >= 0 && v <= 10
19
19
  ? null
20
- : 'must be a number of minutes from 0 (off) to 30',
20
+ : 'must be a number of minutes from 0 (off) to 10',
21
21
  about: 'How long a call waiting on your approval is held before it is refused',
22
22
  },
23
23
  'stall.nudgeMinutes': {