create-agent-rig 0.5.0 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +140 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +11 -3
  10. package/templates/agent-os/init/CLAUDE.md +11 -3
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
  23. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  24. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
  25. package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
  26. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
  27. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
  28. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  29. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  30. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  31. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  32. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  33. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  34. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  36. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  37. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
  38. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
  39. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  40. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  41. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  42. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  43. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  44. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  45. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  46. package/templates/agent-os/universal/.claude/settings.json +5 -1
  47. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  48. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
  49. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  50. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  51. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  52. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  53. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  54. package/templates/agent-os/universal/AGENTS.md +3 -1
  55. package/templates/agent-os/universal/CLAUDE.md +3 -1
  56. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  57. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  58. package/templates/agent-os/universal/layers.json +9 -0
  59. package/templates/hash-history.json +309 -49
  60. package/templates/release-ledger.json +9 -0
@@ -62,10 +62,30 @@
62
62
  // exists and cannot be used announces —
63
63
  // see hooks.test.ts › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
64
64
  import { execSync, spawnSync } from 'node:child_process';
65
- import { readFileSync } from 'node:fs';
65
+ import { readFileSync, realpathSync } from 'node:fs';
66
+ import { fileURLToPath } from 'node:url';
66
67
 
67
68
  import { withoutGitLocation } from '../scripts/git-env.mjs';
68
69
 
70
+ // 🔴 The tree this gate measures is the project the hook BELONGS to — the
71
+ // directory above `.claude/hooks/` — never the directory the session happens
72
+ // to be in. Observed twice in one session (AR-119): reviewing another branch
73
+ // in a worktree under `.claude/worktrees/`, the gate ran the suite in the
74
+ // worktree the shell had last cd-ed into and reported THAT branch's failing
75
+ // test as this session's own Definition-of-Done failure. Reviewing a foreign
76
+ // branch in a worktree is a first-class motion here, so an inherited cwd is
77
+ // the wrong tree often enough to matter. `git status` and every check run
78
+ // with this as their `cwd`, and both refusals name it, so a failure that is
79
+ // still foreign is visible at a glance. It is the session's ROOT, not its
80
+ // branch: a session started at the main checkout whose own task lives in a
81
+ // `worktree-task` worktree is measured at the main checkout, and the refusal
82
+ // says so. Resolved inside `main()`, so a throw here reaches the backstop
83
+ // and announces itself like every other fault of the gate's own.
84
+ // see hooks.test.ts › "runs the checks in the project root, so a check reading the tree sees the session project"
85
+ // see hooks.test.ts › "asks "is the tree clean?" about the project, not about the cwd"
86
+ // see hooks.test.ts › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
87
+ const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
88
+
69
89
  // The default total budget, and the allowance for everything that happens
70
90
  // OUTSIDE it.
71
91
  //
@@ -155,6 +175,8 @@ function main() {
155
175
  if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
156
176
  if (input.stop_hook_active) return 0;
157
177
 
178
+ const PROJECT_ROOT = projectRootOf();
179
+
158
180
  try {
159
181
  // The environment loses the variables that locate a repository first. A
160
182
  // process started under a git hook inherits an absolute GIT_DIR, and this
@@ -183,6 +205,7 @@ function main() {
183
205
  // and the reason the bound can be generous. The options object stays terse
184
206
  // on purpose; a sibling test matches it by a bounded window.
185
207
  const status = execSync('git status --porcelain', {
208
+ cwd: PROJECT_ROOT,
186
209
  encoding: 'utf8',
187
210
  stdio: ['ignore', 'pipe', 'ignore'],
188
211
  env: withoutGitLocation(),
@@ -252,6 +275,7 @@ function main() {
252
275
  // that did not fit instead of blaming it for spending what an earlier one
253
276
  // spent. One path, and no branch that only a race can reach.
254
277
  const result = spawnSync(command, {
278
+ cwd: PROJECT_ROOT,
255
279
  shell: true,
256
280
  encoding: 'utf8',
257
281
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -277,7 +301,8 @@ function main() {
277
301
  // unknown, and an unmeasured Definition of Done is not a passed one.
278
302
  const timedOut = result.error.code === 'ETIMEDOUT';
279
303
  process.stderr.write(
280
- `STOP GATED — \`${command}\` produced no verdict: ${result.error.message}\n` +
304
+ `STOP GATED — \`${command}\` produced no verdict (measured in ${PROJECT_ROOT}): ` +
305
+ `${result.error.message}\n` +
281
306
  (timedOut
282
307
  ? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
283
308
  `(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
@@ -297,7 +322,7 @@ function main() {
297
322
  .slice(-15)
298
323
  .join('\n');
299
324
  process.stderr.write(
300
- `STOP GATED — a Definition of Done check fails: ${command}\n` +
325
+ `STOP GATED — a Definition of Done check fails (measured in ${PROJECT_ROOT}): ${command}\n` +
301
326
  (tail.trim() ? `${tail}\n` : '') +
302
327
  `Fix the failure before ending the session. If this failure has resisted ` +
303
328
  `repeated attempts, follow the stop rules instead: end with a written ` +
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
3
+ // the current item's allow-list (AR-51).
4
+ //
5
+ // The rulebook is the set of files that decide what this session may do — the
6
+ // hooks, their wiring, the queue config and adapters, the router and the gate
7
+ // sweep, the rules, `CLAUDE.md`. Every one of them was editable by the run it
8
+ // governs: a Write to `.claude/hooks/dod-checks.json` with `[]` switched the
9
+ // stop gate off, and nothing refused it until the merge sweep, long after.
10
+ //
11
+ // How it decides — and it is deliberately dumb:
12
+ // 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
13
+ // attended session → exit 0 for everything. A human at the keyboard edits
14
+ // the rulebook on purpose.
15
+ // 2. Flag present and readable → every edit fragment whose repo-relative path
16
+ // sits under a rulebook prefix is refused (exit 2) unless it also sits
17
+ // under one of the item's `allow` prefixes. Paths outside the rulebook are
18
+ // never judged.
19
+ // 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
20
+ // names the flag; an edit outside the rulebook still passes. Refusing to
21
+ // inspect is not allowing (`.claude/rules/invariants.md`).
22
+ //
23
+ // Limits — each stated here and each measured in the generator's
24
+ // `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
25
+ // test named beside it:
26
+ // - it sees one edit at a time, as text, before it lands — a rulebook file
27
+ // rewritten through a Bash redirect (`echo … > .claude/settings.json`), a
28
+ // generated file, or `git checkout` of another branch is not an edit tool
29
+ // call and never reaches it, and `guard-bash` does not cover that either —
30
+ // › "a Bash redirect into the rulebook is not an edit tool call and passes
31
+ // — guard-bash does not cover it either";
32
+ // - the flag in either home arms it (the env-derived one and the password
33
+ // database one, like the kill switch), and ONLY a flag arms it: an
34
+ // exported variable changes nothing, and an attended session that never
35
+ // set a flag is exactly as free as before — › "only a flag arms it — an
36
+ // exported RIG_UNATTENDED=1 with no flag changes nothing";
37
+ // - it judges paths, not content: a README that merely mentions
38
+ // `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
39
+ // path, not prose that mentions a guarded path";
40
+ // - it compares paths as text: the repo-relative tail is what is left after
41
+ // stripping `CLAUDE_PROJECT_DIR` (falling back to the working directory
42
+ // when the harness does not set it) from the front of the tool's absolute
43
+ // path, so a root spelled differently from the file path — a symlinked
44
+ // `/tmp` versus `/private/tmp`, a case difference on a case-insensitive
45
+ // disk — is not stripped, and the edit is not judged. Documented and
46
+ // measured, not fixed: the harness spells both from one root — › "compares
47
+ // paths as text: a root spelled differently from the file path is not
48
+ // judged (documented, fails open)";
49
+ // - an `allow` prefix is a string prefix of the repo-relative path and may
50
+ // not widen the rulebook — an entry that is itself a prefix of a rulebook
51
+ // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
52
+ // and the guard refuses — › "a flag whose allow-list widens the rulebook is
53
+ // unreadable, so `--allow .` cannot disarm it";
54
+ // - fail-open on its own errors and on a payload it cannot parse — › "allows
55
+ // an empty payload object" and › "allows non-JSON stdin" — and fail-closed
56
+ // on a flag it cannot read — › "blocks a rulebook edit when the flag exists
57
+ // but cannot be read, and names the file": the guard targets drift, not an
58
+ // adversary.
59
+ //
60
+ // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
61
+ import { readFileSync } from 'node:fs';
62
+ import { editFragments } from './lib/edit-input.mjs';
63
+ import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
64
+
65
+ export { RULEBOOK_PREFIXES, isRulebookPath };
66
+
67
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
68
+
69
+ const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
70
+
71
+ /** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
72
+ export const relativeTo = (root, filePath) => {
73
+ const dir = toPosix(root).replace(/\/+$/, '');
74
+ const file = toPosix(filePath);
75
+ if (dir !== '' && file.startsWith(`${dir}/`)) return file.slice(dir.length + 1);
76
+ return file.replace(/^\.\//, '');
77
+ };
78
+
79
+ export const isAllowed = (rel, allow) =>
80
+ (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
81
+
82
+ function main() {
83
+ let input;
84
+ try {
85
+ input = JSON.parse(readFileSync(0, 'utf8'));
86
+ } catch {
87
+ return 0; // unparseable payload: not ours to judge
88
+ }
89
+ if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
90
+
91
+ const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
92
+ const paths = [];
93
+ for (const { filePath } of editFragments(input)) {
94
+ if (typeof filePath !== 'string' || filePath === '') continue;
95
+ const rel = relativeTo(root, filePath);
96
+ if (isRulebookPath(rel) && !paths.includes(rel)) paths.push(rel);
97
+ if (paths.length >= 64) break;
98
+ }
99
+ if (paths.length === 0) return 0; // nothing under the rulebook: never judged
100
+
101
+ const mode = readUnattended();
102
+ if (!mode.on) return 0; // attended session
103
+
104
+ if (mode.unreadable) {
105
+ process.stderr.write(
106
+ `BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
107
+ 'Refusing to inspect is not allowing: fix or remove the flag (`node .claude/scripts/unattended-flag.mjs off`), then retry.\n',
108
+ );
109
+ return 2;
110
+ }
111
+
112
+ const refused = paths.filter((rel) => !isAllowed(rel, mode.allow));
113
+ if (refused.length === 0) return 0;
114
+ process.stderr.write(
115
+ `BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
116
+ `(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +
117
+ 'If the item really needs this path, it belongs in the allow-list the loop wrote at claim time — a decision, not a default. ' +
118
+ 'See .claude/rules/autonomy.md, "Never".\n',
119
+ );
120
+ return 2;
121
+ }
122
+
123
+ try {
124
+ process.exit(main());
125
+ } catch {
126
+ process.exit(0); // a crashed guard must not block the session
127
+ }
@@ -45,6 +45,8 @@ const MAX_TOTAL_HUNK_LINES = 10_000;
45
45
  const MAX_OUTPUT_LINES = 20_000;
46
46
  const MAX_SPLICE_OPERATIONS = 1_000;
47
47
  const MAX_PATCH_SECTIONS = 128;
48
+ /** A MultiEdit is capped before it is mapped — bounded work, never a spread of input. */
49
+ const MAX_MULTI_EDITS = 256;
48
50
  const MAX_PATCH_PATH_COMPONENTS = 512;
49
51
 
50
52
  export function editFragments(input) {
@@ -60,6 +62,27 @@ export function editFragments(input) {
60
62
  },
61
63
  ];
62
64
  }
65
+ // Claude Code's other two edit surfaces (AR-51). `MultiEdit` carries one
66
+ // file and a list of edits — one fragment per edit, same path — and
67
+ // `NotebookEdit` carries a cell's new source. Before this, both reached every
68
+ // guard through the unanchored `Write|Edit` matcher and yielded no fragment,
69
+ // so a `Date.now()` in a MultiEdit to the core passed unchecked.
70
+ if (toolName === 'MultiEdit') {
71
+ if (!Array.isArray(toolInput.edits)) return [];
72
+ const filePath = normalisePath(toolInput.file_path);
73
+ return toolInput.edits.slice(0, MAX_MULTI_EDITS).map((edit) => ({
74
+ filePath,
75
+ fragment: String(edit?.new_string ?? ''),
76
+ }));
77
+ }
78
+ if (toolName === 'NotebookEdit') {
79
+ return [
80
+ {
81
+ filePath: normalisePath(toolInput.notebook_path),
82
+ fragment: String(toolInput.new_source ?? ''),
83
+ },
84
+ ];
85
+ }
63
86
  if (toolName !== 'apply_patch') return [];
64
87
  const rawCommand = toolInput.command;
65
88
  // ⚠ **Absent is not malformed, and the difference decides which way this fails.**
@@ -108,6 +108,14 @@ own cost figures are read next to the lane they do not cover.
108
108
  at runtime instead of writing it out, or the check reports its own test data as
109
109
  a leak.
110
110
  - touch production data outside a reviewed migration
111
+ - edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it.
112
+ The rulebook: the hooks, their wiring, `.claude/queue.json`, the queue
113
+ adapters, the router, the gate sweep, the rules, `CLAUDE.md`. Mechanical:
114
+ the hook refuses the edit while the unattended flag the `loop` skill writes
115
+ at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
116
+ nothing in an attended session. ⚠ It sees edit tool calls only — a
117
+ shell redirect into `.claude/settings.json` is not one — and the flag, not
118
+ the run, is what arms it; its header states the rest of its limits.
111
119
 
112
120
  ## Stop rules — by work-state, not by feelings
113
121
 
@@ -168,9 +168,28 @@ Two rules that follow from it:
168
168
  deliberately coarse and stop trying to out-parse the input. Where a false block
169
169
  interrupts ordinary work, stay narrow and specific. Uniform precision
170
170
  everywhere is how a guard ends up simultaneously too loose and too annoying.
171
- - **One mechanism, one implementation.** If two files enforce the same
172
- invariant, they will disagree — and the one nobody is looking at is the one
173
- that is wrong. Export it from a single module and import it.
171
+ - **One mechanism, one implementation.** And one spelling of a fact. If two
172
+ files enforce the same invariant, they will disagree — and the one nobody is
173
+ looking at is the one that is wrong. Export it from a single module and import
174
+ it. The same holds for any mechanically expressible fact two artifacts encode
175
+ — a list, a vocabulary, a set of paths, a lane's reviewer floor: prefer one
176
+ source that the others derive from or are generated from. Where a second copy
177
+ has to stay (prose a reader needs), put a correspondence check between the two
178
+ that goes red in both directions — a copy that gains an entry the source
179
+ lacks, and a source that gains one the copy lacks — and put that check in
180
+ before adding prose or a memory note about keeping them aligned. The shape is
181
+ in the generator's `test/template/correspondence.test.ts` (absent in a
182
+ generated rig): the check itself, ›
183
+ "every point the module knows is named by the loop or pr-ship skill, and vice versa"
184
+ and ›
185
+ "the pr-ship fan-out bullets name exactly the floor of each lane, and only known lanes";
186
+ and the proof that it names the offender, one mutation per side, ›
187
+ "reports a point named in prose that no script knows (mutation: BEFORE_MERGE)",
188
+ › "reports a point the module knows that no prose mentions (mutation: extended POINTS)",
189
+ › "reports a reviewer added to a bullet the floor does not include (mutation: prose)"
190
+ and › "reports a floor widened in the mapping that the prose does not carry (mutation: mapping)".
191
+ What such a check costs is written where the check is, in the header of the
192
+ file that carries it.
174
193
 
175
194
  ## The worked example — and it is one project's answer, not a law
176
195
 
@@ -217,6 +236,17 @@ If a hook matters enough to keep, it is worth ten minutes to copy the shape from
217
236
  `.claude/skills/new-invariant/guard-invariant.example.test.mjs` and pin the
218
237
  behaviour you actually rely on.
219
238
 
239
+ **That boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
240
+ reads `.claude/.rig-manifest.json` and asks of every hook in `.claude/hooks/` (and
241
+ `.husky/`, when it exists) whether the project owns it — the bytes differ from
242
+ what the generator installed, or the manifest has no entry — and, if so, whether
243
+ `<hook>.test.mjs` sits beside it. A shipped, unchanged hook is not a finding; an
244
+ owned hook with no neighbour is; a rig with no manifest gets `unknown` for every
245
+ hook that has no test neighbour, never a pass. Exemptions are an explicit list with reasons in
246
+ `.claude/doctor-exemptions.json`, and the report ends with what the script did
247
+ not check. Pinned in the generator's `test/template/doctor.test.ts` — absent in a
248
+ generated rig — › "an owned hook without a test is a FAIL, and the run is STOP".
249
+
220
250
  ## Adding one
221
251
 
222
252
  Use the `new-invariant` skill. It asks what the invariant is (it will not invent
@@ -927,12 +927,30 @@ export const route = ({ files, elevatedPaths } = {}) => {
927
927
  };
928
928
  };
929
929
 
930
+ /**
931
+ * The floor each lane sets, and nothing else — what `pr-ship` step 4 launches
932
+ * before any trigger adds to it. Exported as the ONE spelling of that fact:
933
+ * `pr-ship/SKILL.md` restates it in prose, and the generator's
934
+ * test/template/correspondence.test.ts › "the pr-ship fan-out bullets name
935
+ * exactly the floor of each lane, and only known lanes" keeps the two in step
936
+ * in both directions (AR-137). Only the `model` entry has a runtime consumer
937
+ * (`reviewersFor` below, which the router calls); the other two are checked
938
+ * against the prose alone — `route()` launches nothing on those lanes itself.
939
+ * Cost of the check: a floor change touches this function and one bullet, and
940
+ * the bullet must keep its `- \`lane\` → …` shape or the parse fails by name.
941
+ */
942
+ export const reviewersForLane = (lane) => {
943
+ if (lane === 'model') return ['code-reviewer'];
944
+ if (lane === 'fast-path') return ['prose-reviewer'];
945
+ return [];
946
+ };
947
+
930
948
  /**
931
949
  * Who the expensive lane fans out to — `code-reviewer` first, always, and the
932
950
  * conditional gates `pr-ship` already names, decided from the same paths.
933
951
  */
934
952
  const reviewersFor = (files, risks) => {
935
- const reviewers = ['code-reviewer'];
953
+ const reviewers = [...reviewersForLane('model')];
936
954
 
937
955
  let wantsProse = false;
938
956
  for (const file of files) {