create-agent-rig 0.4.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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -0,0 +1,1260 @@
1
+ /**
2
+ * The decision router — which gate does this change deserve, and what does that
3
+ * cost.
4
+ *
5
+ * `pr-ship` is the merge-time gate and it always runs the expensive path: the
6
+ * full suite, then `code-reviewer` on every diff. That is right for a change
7
+ * that contains code and wrong for one that does not, and today there is no
8
+ * cheaper lane at all — a one-line typo fix in a README buys the same fan-out as
9
+ * a rewrite of the storage layer. This module is the dispatcher that decides
10
+ * *whether the expensive path is warranted*; it does not replace the gate and it
11
+ * never runs a reviewer itself.
12
+ *
13
+ * The ladder is `LANES`, in ascending order of cost, and `RISK_FLAGS` are
14
+ * evaluated **ahead of all three** — a flag escalates straight to `model`, so no
15
+ * cheap gate ever gets the chance to claim a change that carries one.
16
+ *
17
+ * 🔴 **The two directions of error are not symmetric, and every decision below
18
+ * is made in the safe one.** Routing an expensive change into a cheap lane loses
19
+ * the review — silently, and exactly on the diff that needed it. Routing a cheap
20
+ * change into the expensive lane costs tokens. So: an unclassifiable path, a
21
+ * rulebook document, a dependency manifest, a file under a declared elevated
22
+ * path, and an absent file list all resolve to the **expensive** answer. The
23
+ * cheap lanes are narrow on purpose and are meant to stay that way.
24
+ *
25
+ * ⚠ Read "under a declared elevated path" exactly, because it has one carve-out
26
+ * and the carve-out is inherited rather than chosen here: `elevatedPathsIn`
27
+ * treats **`.md`/`.mdx` files and test paths** that provision nothing as inert,
28
+ * so `infra/README.md` does not escalate while `infra/stack.ts` does. 🔴 Both
29
+ * halves are the SWEEP's definitions and neither matches this file's: its inert
30
+ * test paths are `test|tests|__tests__` directories and `.test.`/`.spec.`
31
+ * JS-flavoured names, while `isTestPath` here is wider; and its inert
32
+ * extensions are `.md`/`.mdx` while `PROSE_EXTENSIONS` here is `.md`/`.txt` —
33
+ * **neither set contains the other**, and the difference on `.mdx` is
34
+ * deliberate (MDX executes, see `PROSE_EXTENSIONS`). Aligning either way would
35
+ * do harm: widening the sweep stops `requirements.txt` in an elevated directory
36
+ * from escalating, and copying the sweep's `.mdx` back into this file's prose
37
+ * set reinstates a defect a 🔴 below spends a paragraph closing. They stay
38
+ * apart on purpose, and the difference is written here rather than discovered.
39
+ * A rulebook file
40
+ * is not inert wherever it sits. That is the sweep's definition and this file
41
+ * delegates to it on purpose — two answers to "is this path elevated" would
42
+ * disagree. **Neither cheap lane relies on that carve-out for a derived file:**
43
+ * both test the declared prefixes raw, so an inert-looking derived file under
44
+ * one cannot compose its way in. (Round four scoped this sentence to the
45
+ * no-reviewer lane; the mechanism gated both from the start, and the narrower
46
+ * claim understated the guard.)
47
+ *
48
+ * 🔴 **The lane is a value on stdout; the exit code says only that the router
49
+ * ran.** `0` never means "cheap" and non-zero never means "expensive" — a caller
50
+ * that chains this on `&&` reads *escalate to the model* as *go ahead*, which is
51
+ * the one misreading that turns a gate into a rubber stamp. Read the JSON.
52
+ *
53
+ * **What it deliberately does not do:** decide whether a review PASSED (that is
54
+ * the gate's job), run any reviewer, or write anything except journal records.
55
+ *
56
+ * ⚠ **The limits, stated rather than implied — all six of them.**
57
+ *
58
+ * 1. **It sees paths, never content.** A diff that guts a function inside
59
+ * `docs/` is invisible to it, and so is a secret pasted into a `.md`. It
60
+ * classifies by name because that is what a dispatcher can do in
61
+ * milliseconds before any expensive work starts; the layers behind it (the
62
+ * suite, the reviewers, CI) are what read content. A project whose risky code
63
+ * does not announce itself in its paths should widen `elevated-paths` rather
64
+ * than expect this file to guess.
65
+ * 2. **`derived` is a naming convention, not a proof.** A hand-authored
66
+ * `src/x.generated.ts` satisfies it. So **both cheap lanes** require a status
67
+ * saying the file was drift — `modified` or `removed`; everything else,
68
+ * including an entry with **no status at all** (the `--files` string form),
69
+ * is refused them — as is a derived file **under a declared elevated path**,
70
+ * whatever its status. A `modified` derived file elsewhere is still taken on
71
+ * trust, and that trust rests on the project having a check that regenerates
72
+ * it.
73
+ * 3. **The journal is written only when `RIG_RUN_DIR` is declared**, and only
74
+ * from the CLI — `route()` used as a library writes nothing. So an absent
75
+ * `decisions.jsonl` is the ordinary state of an undeclared run, and a reader
76
+ * auditing one must check the run declared a directory before reading
77
+ * absence as a gate that stopped firing.
78
+ * 4. **Exit 1 is not a lane, and STDOUT is the thing to read.** Exit 1 means
79
+ * nothing was routed — an unreadable diff, an absent file list, a project
80
+ * declaring no elevated path, an unrecognised flag, a `--base`/`--head` that
81
+ * is not a revision, or a run directory that is not there. The caller treats
82
+ * it as `model`; it is never a reason to skip the gate.
83
+ *
84
+ * ⚠ **Do not key on the `run journal:` prefix**, because both journal
85
+ * failures wear it and they end differently: a trace that can no longer
86
+ * accept records ends the TRACE, not the routing — the lane still prints and
87
+ * the exit stays 0 — while a run directory that was never there exits 1 with
88
+ * nothing on stdout. One rule covers both and every other case: **if a lane
89
+ * printed, read it; if stdout is empty, treat the change as `model`.**
90
+ * ⚠ And the diff it reads is the **committed** one, `<base>...<head>`: an
91
+ * uncommitted edit is not routed, so commit before routing.
92
+ * 5. **Case folding is deliberately asymmetric, and the residual is the FLAG,
93
+ * not the lane.** A rulebook file is recognised whatever the case of its
94
+ * name, and this file's own cheap-lane tests fold too — both directions that
95
+ * can only ESCALATE. The gate sweep's `elevatedPathsIn` does not fold, on
96
+ * purpose (`normalizePath` explains why). So against a declared `scripts/`,
97
+ * a file under `Scripts/` **routes to the same lane** as the correctly-cased
98
+ * path — `caseOnlyElevated` in `route` sees to that, and it inherits the
99
+ * sweep's inertness so `Scripts/README.md` stays as cheap as
100
+ * `scripts/README.md` — but `risks` comes back empty, so the trace does not
101
+ * say *why* it was expensive.
102
+ *
103
+ * 🔴 This limit has now been wrong in **both** directions, which is why it
104
+ * is spelled out rather than summarised. It first claimed the residual was a
105
+ * stray `.txt` while a *derived* file under the mismatched directory reached
106
+ * the lane with no reviewer at all; the fix for that then claimed the lane
107
+ * was safe while a `.txt` still lost `code-reviewer`. Both are closed now,
108
+ * in code rather than in prose.
109
+ *
110
+ * ⚠ One genuine false negative survives, and ASCII is not where it lives:
111
+ * Unicode lowercasing is not prefix-preserving at a Greek final sigma, so a
112
+ * declared prefix ending mid-segment on `Σ` can fail to fold-match. It needs
113
+ * a declaration without a trailing slash, a path inert to the sweep, a
114
+ * derived-looking name and a trusted status all at once.
115
+ * 6. **`reviewers` is a floor, not a ceiling** — and this was measured on the
116
+ * router's own first run, not predicted. It returned `code-reviewer` and
117
+ * `prose-reviewer` for a diff that parses untrusted argv and git output,
118
+ * which `pr-ship`'s own trigger list calls a `security-scanner` case. Paths
119
+ * cannot see what code does. The gate's triggers apply on every lane and may
120
+ * only add.
121
+ *
122
+ */
123
+
124
+ import { execFileSync } from 'node:child_process';
125
+ import { realpathSync } from 'node:fs';
126
+ import { dirname, join } from 'node:path';
127
+ import { fileURLToPath } from 'node:url';
128
+
129
+ import {
130
+ elevatedPathsIn,
131
+ isDecisionRecord,
132
+ normalizePath,
133
+ readDeclaredPaths,
134
+ } from './detect-missed-gate.mjs';
135
+ import { withoutGitLocation } from './git-env.mjs';
136
+
137
+ /** The three lanes, cheapest first. The order is the dispatch order. */
138
+ export const LANES = Object.freeze(['deterministic', 'fast-path', 'model']);
139
+
140
+ /**
141
+ * Every gate that produces a verdict, in evaluation order.
142
+ *
143
+ * 🔴 `risk-flags` is first and that placement is the contract. Evaluated after
144
+ * the cheap lanes it could escalate nothing — the change would already be
145
+ * routed, and the flag would be a comment rather than a control.
146
+ */
147
+ export const GATES = Object.freeze(['risk-flags', 'deterministic', 'fast-path', 'model']);
148
+
149
+ /**
150
+ * The closed vocabulary a gate line may carry.
151
+ *
152
+ * `decline` and `skipped` are different facts and collapsing them is how a trace
153
+ * starts lying: a gate that was *evaluated and said no* tells you the router
154
+ * considered the cheap answer, and a gate that was *never reached* tells you an
155
+ * escalation happened above it. A reader who cannot tell those apart cannot tell
156
+ * a working router from one whose cheap lanes silently stopped matching.
157
+ */
158
+ export const VERDICTS = Object.freeze(['clear', 'escalate', 'route', 'decline', 'skipped']);
159
+
160
+ /** What escalates a change ahead of the ladder. */
161
+ export const RISK_FLAGS = Object.freeze(['elevated-path', 'security-surface', 'test-removed']);
162
+
163
+ const DERIVED = 'derived';
164
+ const PROSE = 'prose';
165
+ const CODE = 'code';
166
+ const UNKNOWN = 'unknown';
167
+
168
+ /**
169
+ * Coerce a file entry to its path, accepting both shapes the callers have.
170
+ *
171
+ * `--files` yields plain strings; `git diff --name-status` yields a status too,
172
+ * and `test-removed` cannot be decided without it. Rather than force every
173
+ * caller into the richer shape, both travel in one list.
174
+ */
175
+ const pathOf = (file) => {
176
+ if (typeof file === 'string') return file;
177
+ const path = file?.path;
178
+ return typeof path === 'string' ? path : '';
179
+ };
180
+
181
+ const statusOf = (file) => {
182
+ const status = file?.status;
183
+ return typeof status === 'string' ? status : null;
184
+ };
185
+
186
+ const segmentsOf = (path) => normalizePath(path).split('/').filter(Boolean);
187
+
188
+ /**
189
+ * The documents that instruct agents — where the prose IS the implementation.
190
+ *
191
+ * 🔴 Matched case-INSENSITIVELY, and the reason is a two-commit attack that was
192
+ * reproduced end to end: on a case-insensitive checkout (macOS default)
193
+ * `git mv CLAUDE.md claude.md` is accepted and recorded. The rename itself is
194
+ * caught, because a rename keeps its source path — but from the next commit on,
195
+ * the rulebook is in the prose lane forever. `readDeclaredPaths` still read that
196
+ * same file for its `elevated-paths` block, so the router was parsing it as the
197
+ * rulebook and refusing to classify it as one.
198
+ *
199
+ * ⚠ Case folding in this file is a whitelist, not a single site: this check,
200
+ * `isTestPath`'s directory scan, and `route`'s own prefix tests all fold —
201
+ * every one of them a direction that can only ESCALATE. What must NOT fold is
202
+ * `normalizePath`, and therefore `elevatedPathsIn`: that one decides the gate
203
+ * sweep's escalation, where folding creates false positives.
204
+ * `detect-missed-gate.mjs` states why.
205
+ */
206
+ const RULEBOOK_BASENAMES = new Set([
207
+ 'claude.md',
208
+ 'agents.md',
209
+ 'gemini.md',
210
+ 'conventions.md',
211
+ 'copilot-instructions.md',
212
+ ]);
213
+
214
+ const isRulebookPath = (path) => {
215
+ const segments = segmentsOf(path);
216
+ if (segments.length === 0) return false;
217
+ if (RULEBOOK_BASENAMES.has(segments[segments.length - 1].toLowerCase())) return true;
218
+ for (const segment of segments) if (segment.toLowerCase() === '.claude') return true;
219
+ for (const segment of segments) {
220
+ const lowered = segment.toLowerCase();
221
+ if (lowered === '.agents' || lowered === '.codex') return true;
222
+ }
223
+ // A decision record carries rulebook rationale, so it is code here for the
224
+ // same reason a rule file is: the prose IS the implementation. Shared with
225
+ // the gate sweep rather than re-spelled — see `isDecisionRecord`.
226
+ //
227
+ // Fed the REJOINED, folded segments, not the raw argument: the predicate is
228
+ // case-sensitive by design for the sweep, and this file's case-folding
229
+ // whitelist above covers every check in it. A `git mv` to `docs/Decisions/`
230
+ // on a case-insensitive checkout would otherwise drop a record onto the
231
+ // prose lane — the exact move that whitelist exists to close. Rejoining also
232
+ // keeps this branch on the same normalised path as the two tests above it.
233
+ return isDecisionRecord(segments.join('/').toLowerCase());
234
+ };
235
+
236
+ // 🔴 Deliberately EMPTY, and it held `.rig-manifest.json` for one review round.
237
+ // Nothing in a generated project regenerates or verifies that file — it is
238
+ // written once at scaffold time and read by `upgrade` to decide which files are
239
+ // locally modified. So the premise the cheap lane rests on ("a check already
240
+ // catches its drift") is false for it, on the one file that governs what
241
+ // `upgrade` may overwrite. A project that really does generate a fixed-name
242
+ // artifact adds it here, next to the check that regenerates it.
243
+ const DERIVED_BASENAMES = new Set();
244
+ // 🔴 `.mdx` is NOT here, and it was for four review rounds. MDX compiles to an
245
+ // ES module: it supports `import`/`export` and evaluates every `{…}`
246
+ // expression, so `app/page.mdx` is a route that executes. A single-file diff
247
+ // adding `import { execSync } …` to one routed to `fast-path`, and the router
248
+ // printed "the change carries no code" over it. This is not limit 1 — the path
249
+ // itself declares an executable format, and the router was reading it wrong.
250
+ // `DOC_EXTENSIONS` still sends it to `prose-reviewer` on top of the code review.
251
+ const PROSE_EXTENSIONS = new Set(['md', 'txt']);
252
+ const DOC_EXTENSIONS = new Set(['md', 'mdx', 'txt']);
253
+
254
+ /**
255
+ * Does this path LOOK derived — and it is only ever a look.
256
+ *
257
+ * ⚠ Nothing here verifies that the file is actually generator output; the
258
+ * classification is a naming convention, and a hand-authored file can satisfy
259
+ * it. That matters more than usual because `deterministic` is the lane that runs
260
+ * **no reviewer at all**, so the check on it is `route`'s status guard rather
261
+ * than this predicate: a derived-looking file that was ADDED cannot be drift
262
+ * against a generator, because there is no prior output for it to have drifted
263
+ * from.
264
+ */
265
+ const looksDerived = (segments, basename) => {
266
+ if (DERIVED_BASENAMES.has(basename)) return true;
267
+ for (const segment of segments) if (segment === 'dist') return true;
268
+ // 🔴 A test SNAPSHOT is deliberately not here, and it was, for one review
269
+ // round. A snapshot is not output whose drift a generator check catches — it
270
+ // IS the behaviour claim, rewritten by the test run that then passes by
271
+ // construction. Routing one to the lane that launches no reviewer is
272
+ // "weaken a test to get to green" with a dispatcher doing the weakening.
273
+ return /\.generated\.[^.]+$/.test(basename);
274
+ };
275
+
276
+ /**
277
+ * The statuses under which a derived file may reach the **no-reviewer** lane.
278
+ *
279
+ * Deliberately narrow, and the narrowness is the whole guard. `deterministic`
280
+ * rests on one claim — this file is generator output, so a check already catches
281
+ * its drift — and that claim needs a prior output to have drifted from. An
282
+ * `added` or `copied` file has none. A `renamed` one is new content at that
283
+ * path. And a status-less entry (the `--files` string form) has not been
284
+ * measured at all, which is not the same as measuring `modified`.
285
+ */
286
+ const DERIVED_TRUSTED_STATUSES = new Set(['modified', 'removed']);
287
+
288
+ /**
289
+ * What kind of file this is — the only judgement the cheap lanes are allowed to
290
+ * rest on.
291
+ *
292
+ * 🔴 A rulebook document classifies as `code`, and that is the single most
293
+ * expensive misroute this file exists to prevent: a change rewriting the
294
+ * autonomy tiers is a `.md`, and a router that read extensions would hand the
295
+ * Never list to the prose lane. In this layer the prose *is* the implementation.
296
+ */
297
+ export const classifyFile = (file) => {
298
+ const raw = typeof file === 'string' ? file : '';
299
+ if (raw.trim() === '') return UNKNOWN;
300
+
301
+ const path = normalizePath(raw);
302
+ const segments = segmentsOf(path);
303
+ if (segments.length === 0) return UNKNOWN;
304
+ const basename = segments[segments.length - 1];
305
+
306
+ // Fail expensive first: a rulebook file is never derived and never prose,
307
+ // whatever it is called or where it sits.
308
+ if (isRulebookPath(path)) return CODE;
309
+
310
+ // 🔴 A test path is code too, and this line closes a claim the header used to
311
+ // make and the mechanism did not honour: "every test file classifies as
312
+ // `code`, so a change containing one cannot reach a cheap lane on
313
+ // classification alone". It did not hold for a fixture — `test/golden/
314
+ // expected.txt` and `test/fixtures/golden.md` classified as PROSE, so a
315
+ // deleted golden file reached `fast-path` with `prose-reviewer` as the whole
316
+ // gate. It also has to come before the derived look, or
317
+ // `packages/db/src/test/schema.generated.ts` composes two carve-outs into the
318
+ // lane that launches nobody.
319
+ if (isTestPath(path)) return CODE;
320
+
321
+ if (looksDerived(segments, basename)) return DERIVED;
322
+
323
+ const dot = basename.lastIndexOf('.');
324
+ const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
325
+ if (PROSE_EXTENSIONS.has(extension)) return PROSE;
326
+
327
+ return CODE;
328
+ };
329
+
330
+ /** Dependency manifests and lockfiles: a change here is the supply chain. */
331
+ const DEPENDENCY_FILES = new Set([
332
+ 'package.json',
333
+ 'pnpm-lock.yaml',
334
+ 'package-lock.json',
335
+ 'yarn.lock',
336
+ 'npm-shrinkwrap.json',
337
+ // `.txt` is otherwise prose, so these two are actively DOWNGRADED rather than
338
+ // merely missed — and this layer is what `init` installs into a repo whose
339
+ // shape nobody here knows.
340
+ 'requirements.txt',
341
+ 'constraints.txt',
342
+ // 🔴 A `.txt` that is a BUILD SCRIPT, not prose. `CMakeLists.txt` runs
343
+ // `execute_process` and `FetchContent_Declare` at configure time — a new
344
+ // dependency, a new outbound destination and arbitrary shell, three of the
345
+ // gate's own `security-scanner` triggers — and it took the prose lane.
346
+ // `conanfile.txt` is C/C++'s `requirements.txt`. The same reasoning that
347
+ // pulled Python's manifests out of prose applies here and was simply not
348
+ // carried across.
349
+ 'cmakelists.txt',
350
+ 'conanfile.txt',
351
+ // Other ecosystems. These classify as `code` and so reach `model` anyway —
352
+ // what they buy is the `security-scanner` member of the reviewer floor, which
353
+ // a supply-chain edit is exactly the case for.
354
+ 'pipfile',
355
+ 'pipfile.lock',
356
+ 'poetry.lock',
357
+ 'pyproject.toml',
358
+ 'gemfile',
359
+ 'gemfile.lock',
360
+ 'cargo.toml',
361
+ 'cargo.lock',
362
+ 'go.mod',
363
+ 'go.sum',
364
+ 'composer.json',
365
+ 'composer.lock',
366
+ ]);
367
+
368
+ /**
369
+ * The same manifests, in the forms a name-exact set cannot hold.
370
+ *
371
+ * `requirements-dev.txt`, `dev-requirements.txt` and `requirements/base.txt` are
372
+ * the shapes that actually appear, and each of them is `.txt` — the one prose
373
+ * extension a dependency manifest uses, so missing them does not merely fail to
374
+ * escalate, it actively downgrades a supply-chain edit to the prose lane.
375
+ */
376
+ const isDependencyPath = (segments, basename) => {
377
+ const lower = basename.toLowerCase();
378
+ if (DEPENDENCY_FILES.has(lower)) return true;
379
+ if (!lower.endsWith('.txt')) return false;
380
+ // Matched on the STEM's words rather than on a shape. The shape form spelled
381
+ // `^(dev-)?requirements(-[a-z0-9.]+)?$` and missed `requirements_dev.txt`,
382
+ // `requirements-DEV.txt`, `requirements-dev-extra.txt` and
383
+ // `test-requirements.txt` — each of which then reached the PROSE lane, because
384
+ // `.txt` is the one prose extension a manifest uses.
385
+ // 🔴 The stem keeps its ORIGINAL CASE here. Pre-lowercasing destroyed the
386
+ // camel boundary `wordsOf` exists for — `requirementsDev` collapsed to one
387
+ // word and reached the prose lane — and buys nothing, since `wordsOf`
388
+ // lowercases every word it emits. Prefix-matched so a digit or a singular
389
+ // (`requirements2`, `requirement-dev`) cannot slip past either.
390
+ for (const word of wordsOf(basename.slice(0, -4))) {
391
+ if (word.startsWith('requirement') || word.startsWith('constraint')) return true;
392
+ }
393
+ for (let i = 0; i < segments.length - 1; i += 1) {
394
+ const segment = segments[i].toLowerCase();
395
+ if (segment === 'requirements') return true;
396
+ // Go's vendor manifest is `vendor/modules.txt` and names neither word.
397
+ if (segment === 'vendor' && lower === 'modules.txt') return true;
398
+ }
399
+ return false;
400
+ };
401
+
402
+ /**
403
+ * Files that ARE credentials, rather than files whose name mentions one.
404
+ *
405
+ * These reach `model` on classification anyway; what they buy is the
406
+ * `security-scanner` member of the reviewer floor, which the flag's own `why`
407
+ * already claims to cover.
408
+ */
409
+ const SECRET_EXTENSIONS = new Set(['pem', 'key', 'p12', 'pfx', 'keystore', 'jks']);
410
+ const SECRET_BASENAMES = new Set(['.npmrc', '.netrc', '.pgpass', 'id_rsa', 'id_ed25519']);
411
+
412
+ const isSecretFile = (segments, basename) => {
413
+ if (SECRET_BASENAMES.has(basename)) return true;
414
+ if (basename === '.env' || basename.startsWith('.env.')) return true;
415
+ for (const segment of segments) if (segment === 'secrets' || segment === 'credentials') return true;
416
+ const dot = basename.lastIndexOf('.');
417
+ if (dot <= 0) return false;
418
+ const extension = basename.slice(dot + 1).toLowerCase();
419
+ // The extension is tested against the security words as well, because both
420
+ // sides stripped it before matching: `dist/local.env`, `dist/db.secret` and
421
+ // `dist/api.token` reached the lane that launches no reviewer, while
422
+ // `dist/svc.key` was caught — only because `key` happened to be an extension
423
+ // in the list. That inconsistency was the finding.
424
+ return SECRET_EXTENSIONS.has(extension) || SECURITY_WORDS.has(extension);
425
+ };
426
+
427
+ /**
428
+ * The words that make a path a security surface, matched as whole name-parts.
429
+ *
430
+ * Substring matching was the obvious form and it is wrong in the expensive
431
+ * direction *for the router*: `tokenizer.ts` and `authoring-guide.txt` are
432
+ * ordinary files this repository contains, and a router that escalates
433
+ * everything routes nothing — the cheap lanes stop being reachable and the whole
434
+ * mechanism becomes a slower way to do what `pr-ship` already did.
435
+ */
436
+ const SECURITY_WORDS = new Set([
437
+ 'auth',
438
+ 'authn',
439
+ 'authz',
440
+ 'authenticate',
441
+ 'authentication',
442
+ 'authorization',
443
+ 'authorize',
444
+ 'oauth',
445
+ 'oauth2',
446
+ 'login',
447
+ 'signin',
448
+ 'signup',
449
+ 'sso',
450
+ 'saml',
451
+ 'secret',
452
+ 'secrets',
453
+ 'credential',
454
+ 'credentials',
455
+ 'password',
456
+ 'passwords',
457
+ 'key',
458
+ 'keys',
459
+ 'apikey',
460
+ 'apikeys',
461
+ 'jwt',
462
+ 'crypto',
463
+ 'env',
464
+ 'passwd',
465
+ 'creds',
466
+ 'cookie',
467
+ 'csrf',
468
+ 'cors',
469
+ 'rbac',
470
+ 'acl',
471
+ 'iam',
472
+ 'hmac',
473
+ 'nonce',
474
+ 'cert',
475
+ 'certs',
476
+ 'tls',
477
+ 'mfa',
478
+ 'totp',
479
+ 'otp',
480
+ 'bearer',
481
+ 'oidc',
482
+ 'ldap',
483
+ 'authorise',
484
+ 'authorised',
485
+ 'authorisation',
486
+ 'token',
487
+ 'tokens',
488
+ 'session',
489
+ 'sessions',
490
+ 'permission',
491
+ 'permissions',
492
+ ]);
493
+
494
+ /**
495
+ * Split one path segment's stem into the words a name is made of.
496
+ *
497
+ * 🔴 **One forward pass, written by hand because the regex form was quadratic.**
498
+ * The obvious spelling — `replace(/([A-Z]+)([A-Z][a-z])/g, …)` to break an
499
+ * acronym off the word after it — backtracks the whole remaining run of capitals
500
+ * at every start position. Measured on a stem of N capitals reaching `route()`:
501
+ * 8k → 93 ms, 32k → 1.5 s, 100k → 14 s. And a 40 000-character path component
502
+ * needs no checkout to construct: `git mktree` puts it in a tree and
503
+ * `git diff --name-status` hands it straight back.
504
+ *
505
+ * This is a stall rather than a bypass — the router does not fail open, and a
506
+ * timeout exits 1, which the caller reads as `model`. It is fixed anyway,
507
+ * because `invariants.md` names this exact shape as the lesson that cost the
508
+ * most, and because the comment that used to sit here claimed "no backtracking
509
+ * regex" while pointing at one.
510
+ *
511
+ * A word boundary is a separator (`-`, `_`, `.`, space) or a camel hump: an
512
+ * uppercase char that either follows a non-uppercase one (`authService`) or is
513
+ * the last of a run before a lowercase one (`JWTVerify` → `jwt`, `verify`).
514
+ */
515
+ const wordsOf = (stem) => {
516
+ const words = [];
517
+ const isUpper = (c) => c !== undefined && c >= 'A' && c <= 'Z';
518
+ const isLower = (c) => c !== undefined && c >= 'a' && c <= 'z';
519
+ const isDigit = (c) => c !== undefined && c >= '0' && c <= '9';
520
+ let start = 0;
521
+ const cut = (end) => {
522
+ if (end > start) words.push(stem.slice(start, end).toLowerCase());
523
+ start = end;
524
+ };
525
+ for (let i = 0; i < stem.length; i += 1) {
526
+ const ch = stem[i];
527
+ if (ch === '-' || ch === '_' || ch === '.' || ch === ' ') {
528
+ cut(i);
529
+ start = i + 1;
530
+ continue;
531
+ }
532
+ if (i > start && isUpper(ch) && (!isUpper(stem[i - 1]) || isLower(stem[i + 1]))) cut(i);
533
+ // A digit after a letter is a boundary too: `auth2`, `oauth2` and
534
+ // `requirements2` each hid a whole word behind one character.
535
+ else if (i > start && isDigit(ch) && !isDigit(stem[i - 1])) cut(i);
536
+ // …and the mirror, which was missing: `v2auth`, `s3credentials` and
537
+ // `api2key` hid a whole word behind one leading character exactly the way
538
+ // `auth2` hid one behind a trailing character. Verified against every
539
+ // documented negative — `utf8parser`, `sha256hash`, `base64`, `http2server`
540
+ // all stay clean.
541
+ else if (i > start && !isDigit(ch) && isDigit(stem[i - 1])) cut(i);
542
+ }
543
+ cut(stem.length);
544
+ return words;
545
+ };
546
+
547
+ const isSecuritySurface = (path) => {
548
+ const segments = segmentsOf(path);
549
+ if (segments.length === 0) return false;
550
+ const basename = segments[segments.length - 1];
551
+ if (isDependencyPath(segments, basename)) return true;
552
+ if (isSecretFile(segments, basename)) return true;
553
+
554
+ for (const segment of segments) {
555
+ // One forward pass per segment: strip the extension, then split the stem on
556
+ // the separators a filename actually uses — including a camelCase boundary,
557
+ // because `authService.ts` is the same file as `auth-service.ts` and only
558
+ // one of them was being seen. No rescanning, no backtracking regex — the
559
+ // input is a path from a diff and its length is not ours.
560
+ const dot = segment.lastIndexOf('.');
561
+ const stem = dot > 0 ? segment.slice(0, dot) : segment;
562
+ for (const word of wordsOf(stem)) {
563
+ if (SECURITY_WORDS.has(word)) return true;
564
+ }
565
+ }
566
+ return false;
567
+ };
568
+
569
+ const TEST_DIRECTORIES = new Set([
570
+ 'test',
571
+ 'tests',
572
+ '__tests__',
573
+ 'spec',
574
+ 'specs',
575
+ 'e2e',
576
+ 'cypress',
577
+ ]);
578
+
579
+ /**
580
+ * Directory names that are a test root in one ecosystem and a documentation
581
+ * directory in another — resolved by the EXTENSION rather than by picking a
582
+ * side, because picking a side oscillates.
583
+ *
584
+ * 🔴 Both choices were made in this branch and both were wrong. Treating them
585
+ * as test roots made `docs/features/login.md` code, narrowing the very lane
586
+ * this module exists to open. Removing them put `integration/fixtures/
587
+ * expected.txt` (deleted) back on the prose lane and
588
+ * `integration/schema.generated.ts` into the lane that launches nobody — giving
589
+ * back, for two directory names, exactly the coverage the fixture fix had won.
590
+ *
591
+ * A `.md`/`.mdx` file under one of these is treated as documentation BY THIS
592
+ * PREDICATE; anything else is a test artifact. ⚠ That is not the same as
593
+ * reaching the prose lane — `.mdx` is code to the ladder (see
594
+ * `PROSE_EXTENSIONS`), so `integration/x.mdx` still routes `model`. Those two extensions and no more, deliberately: `.txt` under
595
+ * `integration/` is a golden file far more often than it is prose, and it was
596
+ * the `.txt` fixture that broke. The pair matches the gate sweep's own inert
597
+ * extensions, which is the one other place this distinction is drawn.
598
+ */
599
+ const AMBIGUOUS_TEST_DIRECTORIES = new Set(['integration', 'features']);
600
+ const DOC_ONLY_EXTENSIONS = new Set(['md', 'mdx']);
601
+
602
+ const isTestPath = (path) => {
603
+ const segments = segmentsOf(path);
604
+ if (segments.length === 0) return false;
605
+ const basename = segments[segments.length - 1];
606
+ if (/\.(test|spec)\.[^.]+$/.test(basename)) return true;
607
+ // The JS-flavoured `.test.`/`.spec.` form was the only one recognised, so a
608
+ // deleted `critical_spec.generated.rb` reached the lane that launches no
609
+ // reviewer. `test_x.py`, `x_test.go` and `x_spec.rb` are tests too.
610
+ const dot = basename.lastIndexOf('.');
611
+ const stem = dot > 0 ? basename.slice(0, dot) : basename;
612
+ const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
613
+ const words = wordsOf(stem);
614
+ // Any word, not just the first or last: `critical_spec.generated.rb` puts it
615
+ // in the middle, and that exact name reached the no-reviewer lane when the
616
+ // check looked only at the ends. `words.length > 1` keeps a file simply named
617
+ // `spec.ts` out of it; over-escalating a `spec-loader.ts` that was DELETED is
618
+ // the safe direction and costs one reviewer.
619
+ if (words.length > 1) {
620
+ for (const word of words) {
621
+ if (word === 'test' || word === 'tests' || word === 'spec' || word === 'specs') return true;
622
+ }
623
+ }
624
+ for (let i = 0; i < segments.length - 1; i += 1) {
625
+ // Folded, for the same reason `isRulebookPath` folds: `Tests/`, `Test/` and
626
+ // `Spec/` are the ordinary conventions in .NET, Java and Swift, and on a
627
+ // case-insensitive checkout `git mv test Test` also survives — unlike the
628
+ // rulebook rename, silently, because test runners glob.
629
+ const segment = segments[i].toLowerCase();
630
+ if (TEST_DIRECTORIES.has(segment)) return true;
631
+ if (AMBIGUOUS_TEST_DIRECTORIES.has(segment) && !DOC_ONLY_EXTENSIONS.has(extension)) return true;
632
+ }
633
+ return false;
634
+ };
635
+
636
+ /**
637
+ * The risk flags this change carries — at most one entry per flag, each naming
638
+ * the files that earned it.
639
+ *
640
+ * `elevated-path` is delegated to `elevatedPathsIn` rather than re-derived here.
641
+ * **One mechanism, one implementation** (`invariants.md`): two files deciding
642
+ * "is this path elevated" disagree the first time either changes, and the one
643
+ * nobody is looking at is the wrong one. It also inherits, for free, the two
644
+ * subtleties that copy would have lost — inert prose inside an elevated
645
+ * directory does not count, and a rulebook file counts wherever it sits.
646
+ *
647
+ * ⚠ **`test-removed` needs a status, and without one it is not evaluated.** A
648
+ * plain string list cannot say whether a file was deleted, so this flag stays
649
+ * silent there rather than guessing either way. That is safe and not a hole:
650
+ * every test file classifies as `code`, so a change containing one cannot reach
651
+ * a cheap lane on classification alone. The flag buys the *reason* being visible
652
+ * in the trace, not the escalation itself.
653
+ */
654
+ export const riskFlagsIn = (files, { elevatedPaths = [] } = {}) => {
655
+ const list = Array.isArray(files) ? files : [];
656
+
657
+ const paths = [];
658
+ for (const file of list) paths.push(pathOf(file));
659
+
660
+ const elevated = elevatedPathsIn(paths, elevatedPaths);
661
+
662
+ const security = [];
663
+ const removedTests = [];
664
+ for (const file of list) {
665
+ const path = pathOf(file);
666
+ if (path === '') continue;
667
+ if (isSecuritySurface(path)) security.push(path);
668
+ if (statusOf(file) === 'removed' && isTestPath(path)) removedTests.push(path);
669
+ }
670
+
671
+ const flags = [];
672
+ if (elevated.length > 0) {
673
+ flags.push({
674
+ flag: 'elevated-path',
675
+ files: elevated,
676
+ why: 'a changed file sits under a path this project declares elevated, so the change is Tier 2 by what it touches',
677
+ });
678
+ }
679
+ if (security.length > 0) {
680
+ flags.push({
681
+ flag: 'security-surface',
682
+ files: security,
683
+ why: 'a changed file is a dependency manifest or names auth, secrets, tokens, sessions or permissions',
684
+ });
685
+ }
686
+ if (removedTests.length > 0) {
687
+ flags.push({
688
+ flag: 'test-removed',
689
+ files: removedTests,
690
+ why:
691
+ 'a test file left its path — deleted outright, or renamed away, which the diff \nreports the same way. Deleting a test to reach green is on the Never tier',
692
+ });
693
+ }
694
+ return flags;
695
+ };
696
+
697
+ const line = (gate, verdict, why) => ({ gate, verdict, why });
698
+
699
+ /**
700
+ * Route one change.
701
+ *
702
+ * Returns `{ lane, reviewers, gates, risks, why }`. `gates` carries **one entry
703
+ * per member of `GATES`, always, in `GATES` order** — including the ones that
704
+ * were never reached, which say `skipped` and name the escalation. An absent
705
+ * line is indistinguishable from a declined one and from a gate that crashed,
706
+ * and a trace that cannot tell those apart is the shape a silently disabled gate
707
+ * hides in.
708
+ *
709
+ * 🔴 It **refuses** an absent or non-array file list rather than routing it. A
710
+ * zero and an unknown look identical in a count and mean opposite things: the
711
+ * permissive reading here is "nothing changed, take the cheapest lane", which is
712
+ * precisely the wrong answer written confidently.
713
+ */
714
+ export const route = ({ files, elevatedPaths } = {}) => {
715
+ if (!Array.isArray(files) || files.length === 0) {
716
+ throw new Error(
717
+ 'the decision router needs the changed file list of the change being routed ' +
718
+ '(`git diff --name-status <base>...<head>`). An empty or missing list is an ' +
719
+ 'absence, not a cheap change, and routing on it would send an unmeasured diff ' +
720
+ 'down the lane that reviews least.',
721
+ );
722
+ }
723
+
724
+ // 🔴 The same refusal the CLI makes, made here so a library caller cannot
725
+ // reach the permissive answer the CLI is careful to avoid. With no
726
+ // declaration the `elevated-path` flag is not evaluated at all, and the gate
727
+ // line would then read `risk-flags clear — no risk flag fired`: an
728
+ // unevaluated check presenting as a pass, in the file whose whole argument is
729
+ // that those two must never look alike.
730
+ if (!Array.isArray(elevatedPaths) || elevatedPaths.length === 0) {
731
+ throw new Error(
732
+ 'the decision router needs the project\'s declared elevated paths, and refuses ' +
733
+ 'rather than routing without them: the `elevated-path` risk flag cannot be ' +
734
+ 'evaluated against an empty declaration, and reporting that as "no risk flag ' +
735
+ 'fired" would be an absence dressed as a pass. Read them with ' +
736
+ '`readDeclaredPaths(projectRoot)`.',
737
+ );
738
+ }
739
+
740
+ const risks = riskFlagsIn(files, { elevatedPaths });
741
+
742
+ if (risks.length > 0) {
743
+ const named = risks.map((risk) => risk.flag).join(', ');
744
+ const why = `risk flags escalated ahead of the ladder: ${named}`;
745
+ return {
746
+ lane: 'model',
747
+ reviewers: reviewersFor(files, risks),
748
+ risks,
749
+ why,
750
+ gates: [
751
+ line('risk-flags', 'escalate', why),
752
+ line('deterministic', 'skipped', 'not evaluated — risk flags escalated ahead of it'),
753
+ line('fast-path', 'skipped', 'not evaluated — risk flags escalated ahead of it'),
754
+ line('model', 'route', 'the expensive path is warranted: a risk flag fired'),
755
+ ],
756
+ };
757
+ }
758
+
759
+ // Three counts, because `derived` splits. It is a naming convention a
760
+ // hand-authored file can satisfy, so a derived file only counts as cheap when
761
+ // git says it was drift (`DERIVED_TRUSTED_STATUSES`); anything else is
762
+ // `derivedUntrusted`.
763
+ //
764
+ // 🔴 **Both cheap lanes consult it, and for one review round only one did.**
765
+ // The reasoning that dropped it from `fast-path` — "that lane still has a
766
+ // cold reader" — was wrong about which reader: `prose-reviewer` is scoped to
767
+ // documents that instruct agents, not to a new `.ts` file. Measured
768
+ // consequence: a diff adding `src/x.generated.ts` alongside one `.md` edit
769
+ // routed to `fast-path` and no reviewer read the code. Adding a derived-
770
+ // looking filename was a one-line way to drop `code-reviewer`.
771
+ // The raw prefix test, deliberately NOT the inert-aware one. `elevatedPathsIn`
772
+ // drops prose and test paths as inert, which is right for the gate sweep and
773
+ // wrong for the lane that launches nobody: composing that carve-out with the
774
+ // derived one put `packages/db/src/test/x.generated.ts` — a declared elevated
775
+ // path — into `deterministic` with zero reviewers. Each half is documented;
776
+ // the composition was not, and it contradicted this file's own headline.
777
+ //
778
+ // 🔴 Folded to lower case. `normalizePath` preserves case on purpose —
779
+ // folding THERE would create false positives in the gate sweep's escalation
780
+ // decision. These tests are the opposite direction: they can only move a file
781
+ // OUT of a cheap lane, so folding costs a few extra reviews and closes a real
782
+ // hole. Measured: with a declared `scripts/`, a case-mismatched
783
+ // `Scripts/y.generated.ts` reported `M` reached `deterministic` with ZERO
784
+ // reviewers — and no rename is needed, a repo that simply spells the
785
+ // directory differently from its declaration has it from day one.
786
+ const declared = elevatedPaths.map(normalizePath);
787
+ const folded = declared.map((prefix) => prefix.toLowerCase());
788
+ const underDeclaredPath = (path) => {
789
+ const normalized = normalizePath(path).toLowerCase();
790
+ return folded.some((prefix) => normalized.startsWith(prefix));
791
+ };
792
+
793
+ /**
794
+ * A file the sweep WOULD have called elevated if the case had matched.
795
+ *
796
+ * 🔴 This closes the residual rather than documenting it, and the shape
797
+ * matters: it asks `elevatedPathsIn` the same question twice, once folded and
798
+ * once not, so it inherits the sweep's inertness in BOTH answers. That is
799
+ * what keeps it consistent instead of merely stricter — `Scripts/notes.txt`
800
+ * now routes exactly where `scripts/notes.txt` routes (`model`, because
801
+ * `.txt` is not inert), and `Scripts/README.md` routes exactly where
802
+ * `scripts/README.md` routes (`fast-path`, because `.md` is).
803
+ *
804
+ * The measured hole: with a declared `scripts/`, `Scripts/notes.txt` reached
805
+ * `fast-path` with `prose-reviewer` as the whole gate while the same file
806
+ * spelled `scripts/` reached `model`. No rename is needed — a repo that
807
+ * spells the directory differently from its declaration has it from day one.
808
+ *
809
+ * What remains after this is the **flag**, not the lane: the change routes
810
+ * expensively, and `risks` stays empty because `elevatedPathsIn` is the
811
+ * sweep's answer and the sweep does not fold. That is limit 5, and it is now
812
+ * the whole of it.
813
+ */
814
+ const caseOnlyElevated = (path) =>
815
+ elevatedPathsIn([path.toLowerCase()], folded).length > 0 &&
816
+ elevatedPathsIn([path], declared).length === 0;
817
+
818
+ let prose = 0;
819
+ let derivedUntrusted = 0;
820
+ let derivedUnderDeclaredPath = 0;
821
+ let other = 0;
822
+ for (const file of files) {
823
+ const path = pathOf(file);
824
+ const kind = classifyFile(path);
825
+ // Ahead of the classification, because it disqualifies every kind: a file
826
+ // whose only escape from the declaration is how its directory is spelled
827
+ // must not reach a cheap lane on being prose either.
828
+ if (caseOnlyElevated(path)) other += 1;
829
+ else if (kind === PROSE) prose += 1;
830
+ else if (kind !== DERIVED) other += 1; // code and unknown alike: the expensive answer
831
+ else if (underDeclaredPath(path)) {
832
+ // Counted apart from the status case so the gate line can name WHICH of
833
+ // the two refused the lane. One reason string covering both said "no
834
+ // status saying it was drift" about a file git reported as `M`, and that
835
+ // string is what lands in `decisions.jsonl`.
836
+ derivedUnderDeclaredPath += 1;
837
+ derivedUntrusted += 1;
838
+ } else if (!DERIVED_TRUSTED_STATUSES.has(statusOf(file))) derivedUntrusted += 1;
839
+ }
840
+
841
+ const clear = line('risk-flags', 'clear', 'no risk flag fired on the changed paths');
842
+
843
+ const everyFileDerived = other === 0 && prose === 0;
844
+
845
+ if (everyFileDerived && derivedUntrusted === 0) {
846
+ return {
847
+ lane: 'deterministic',
848
+ reviewers: [],
849
+ risks,
850
+ why: 'every changed file is a derived artifact whose drift a mechanical check already catches',
851
+ gates: [
852
+ clear,
853
+ line(
854
+ 'deterministic',
855
+ 'route',
856
+ 'every changed file is derived — the checks are the review, and a model would re-read the generator',
857
+ ),
858
+ line('fast-path', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
859
+ line('model', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
860
+ ],
861
+ };
862
+ }
863
+
864
+ // 🔴 The reason has to name which of the two disqualified the lane. A verdict
865
+ // saying "not every changed file is a derived artifact" about a change where
866
+ // every file IS one is a false line in `decisions.jsonl` — and that line is
867
+ // the only record of why the cheap gate refused.
868
+ const declinedDeterministic = line(
869
+ 'deterministic',
870
+ 'decline',
871
+ !everyFileDerived
872
+ ? 'not every changed file is a derived artifact'
873
+ : derivedUnderDeclaredPath > 0
874
+ ? 'every changed file is derived, but at least one sits under a path this project declares elevated'
875
+ : 'every changed file is derived, but at least one carries no status saying it was drift — an addition, a copy, a rename, a type change, or a list with no statuses at all',
876
+ );
877
+
878
+ if (other === 0 && derivedUntrusted === 0 && prose > 0) {
879
+ return {
880
+ lane: 'fast-path',
881
+ reviewers: ['prose-reviewer'],
882
+ risks,
883
+ why: 'the change is documentation outside the rulebook, with any derived file travelling with it reported as drift',
884
+ gates: [
885
+ clear,
886
+ declinedDeterministic,
887
+ line(
888
+ 'fast-path',
889
+ 'route',
890
+ 'nothing in the change classifies as code or as a rulebook document — `code-reviewer` has nothing to read',
891
+ ),
892
+ line('model', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
893
+ ],
894
+ };
895
+ }
896
+
897
+ // Same rule one lane down: say which count sent it here. There are two
898
+ // reasons and they are not the same finding.
899
+ const reasons = [];
900
+ if (other > 0) reasons.push('code, a rulebook document, or a path the router could not classify');
901
+ if (derivedUnderDeclaredPath > 0) {
902
+ reasons.push('a derived artifact under a path this project declares elevated');
903
+ }
904
+ // Strictly greater, so a single file tripping BOTH is reported once by its
905
+ // sharper reason rather than twice. A diff carrying one of each names both.
906
+ if (derivedUntrusted > derivedUnderDeclaredPath) {
907
+ reasons.push('a derived artifact with no status saying it was drift');
908
+ }
909
+
910
+ return {
911
+ lane: 'model',
912
+ reviewers: reviewersFor(files, risks),
913
+ risks,
914
+ why: `the change carries ${reasons.join(', and ')}`,
915
+ gates: [
916
+ clear,
917
+ declinedDeterministic,
918
+ line(
919
+ 'fast-path',
920
+ 'decline',
921
+ prose === 0
922
+ ? 'nothing in the change classifies as documentation, which is what this lane admits'
923
+ : 'the change is not documentation-only',
924
+ ),
925
+ line('model', 'route', 'the expensive path is warranted'),
926
+ ],
927
+ };
928
+ };
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
+
948
+ /**
949
+ * Who the expensive lane fans out to — `code-reviewer` first, always, and the
950
+ * conditional gates `pr-ship` already names, decided from the same paths.
951
+ */
952
+ const reviewersFor = (files, risks) => {
953
+ const reviewers = [...reviewersForLane('model')];
954
+
955
+ let wantsProse = false;
956
+ for (const file of files) {
957
+ const path = pathOf(file);
958
+ if (path === '') continue;
959
+ // `DOC_EXTENSIONS` rather than the PROSE classification: `.mdx` is code to
960
+ // the lane logic and a document to a reader, and both are true.
961
+ const segments = segmentsOf(path);
962
+ const basename = segments[segments.length - 1] ?? '';
963
+ const dot = basename.lastIndexOf('.');
964
+ const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
965
+ if (DOC_EXTENSIONS.has(extension) || isRulebookPath(path)) {
966
+ wantsProse = true;
967
+ break;
968
+ }
969
+ }
970
+ if (wantsProse) reviewers.push('prose-reviewer');
971
+
972
+ for (const risk of risks) {
973
+ if (risk.flag === 'security-surface') {
974
+ reviewers.push('security-scanner');
975
+ break;
976
+ }
977
+ }
978
+ return reviewers;
979
+ };
980
+
981
+ /** The verdict a human reads, as opposed to the JSON a caller parses. */
982
+ export const render = (result) => {
983
+ const rows = result.gates.map(
984
+ (gate) => ` ${gate.gate.padEnd(14)} ${gate.verdict.padEnd(9)} ${gate.why}`,
985
+ );
986
+ const reviewers = result.reviewers.length > 0 ? result.reviewers.join(', ') : '(none)';
987
+ return (
988
+ `decision-router: lane ${result.lane.toUpperCase()} — ${result.why}\n\n` +
989
+ `${rows.join('\n')}\n\n` +
990
+ `reviewers: ${reviewers}\n\n` +
991
+ '🔴 the lane is the value on stdout, never the exit code: 0 means the router ran.\n' +
992
+ ' Do not chain this on `&&`.\n'
993
+ );
994
+ };
995
+
996
+ // ---------------------------------------------------------------------------
997
+ // CLI
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ const STATUS_LETTERS = Object.freeze({
1001
+ A: 'added',
1002
+ C: 'copied',
1003
+ D: 'removed',
1004
+ M: 'modified',
1005
+ R: 'renamed',
1006
+ // 🔴 NOT `modified`. A type change swaps a file for a symlink (or back), which
1007
+ // is not "the generator ran again" — so it must not buy the trusted status
1008
+ // that unlocks the lane with no reviewer.
1009
+ T: 'type-changed',
1010
+ });
1011
+
1012
+ // 🔴 The DEFAULT, and it is the point: round four fixed `T` by adding a table
1013
+ // entry, which left the class open. Every letter not in the table — `U`
1014
+ // unmerged, `X` unknown, `B` broken pairing, and whatever git adds next —
1015
+ // resolved to `modified`, the one status that unlocks the lane with no
1016
+ // reviewer. An unclassifiable input resolves to the expensive answer here like
1017
+ // everywhere else in this file.
1018
+ const UNKNOWN_STATUS = 'unknown';
1019
+
1020
+ /**
1021
+ * `git diff --name-status -z` into `{ path, status }` records.
1022
+ *
1023
+ * `-z` and a NUL split rather than lines: with `core.quotePath` on (the default)
1024
+ * a non-ASCII path arrives quoted and octal-escaped, matching no declared
1025
+ * prefix, and a filename containing a newline splits into two junk paths. Both
1026
+ * failures point the same way — an elevated file that stops looking elevated.
1027
+ *
1028
+ * A rename arrives as three fields (`R100`, old, new); everything else as two.
1029
+ */
1030
+ export const parseNameStatus = (raw) => {
1031
+ const fields = String(raw ?? '').split('\0');
1032
+ const files = [];
1033
+ let i = 0;
1034
+ while (i < fields.length) {
1035
+ const code = fields[i];
1036
+ if (!code) {
1037
+ i += 1;
1038
+ continue;
1039
+ }
1040
+ const letter = code[0];
1041
+ const status = STATUS_LETTERS[letter] ?? UNKNOWN_STATUS;
1042
+ if (letter === 'R' || letter === 'C') {
1043
+ const from = fields[i + 1];
1044
+ const to = fields[i + 2];
1045
+ // 🔴 A rename DELETES its source, and the source path is the only place
1046
+ // that deletion appears in the diff. Keeping just the destination let a
1047
+ // rename carry a test out of the suite and a hook out of `.claude/` with
1048
+ // nothing left for any flag to see — measured, not predicted: a diff
1049
+ // renaming `test/foo.test.ts` and `.claude/hooks/guard-bash.mjs` into
1050
+ // `docs/` routed to `fast-path` with `prose-reviewer` as the whole gate.
1051
+ // A COPY leaves its source in place, so only a rename records one.
1052
+ if (from && letter === 'R') files.push({ path: from, status: 'removed' });
1053
+ if (to) files.push({ path: to, status });
1054
+ i += 3;
1055
+ } else {
1056
+ const path = fields[i + 1];
1057
+ if (path) files.push({ path, status });
1058
+ i += 2;
1059
+ }
1060
+ }
1061
+ return files;
1062
+ };
1063
+
1064
+ /**
1065
+ * A revision this file is willing to hand to git.
1066
+ *
1067
+ * `${base}...${head}` is one argv element with no `--` separator ahead of it, so
1068
+ * a value starting with `-` is read by git as an OPTION rather than a revision —
1069
+ * `--base '--output=/tmp/x'` really does make git write a file. There is no
1070
+ * shell here and the caller already owns the process, so this is hardening
1071
+ * rather than a hole; it is refused because the module treats its argv as
1072
+ * untrusted everywhere else and a half-guarded input is the one people rely on.
1073
+ */
1074
+ const revisionOrNull = (value) =>
1075
+ typeof value === 'string' && value !== '' && !value.startsWith('-') ? value : null;
1076
+
1077
+ const parseArgs = (argv) => {
1078
+ const args = { base: 'origin/HEAD', head: 'HEAD', files: null, json: false, bad: null };
1079
+ for (let i = 0; i < argv.length; i += 1) {
1080
+ const arg = argv[i];
1081
+ if (arg === '--json') args.json = true;
1082
+ else if (arg === '--base' || arg === '--head') {
1083
+ const key = arg.slice(2);
1084
+ const value = revisionOrNull(argv[++i]);
1085
+ if (value === null) args.bad = arg;
1086
+ else args[key] = value;
1087
+ } else if (arg === '--files') args.files = argv[++i] ?? '';
1088
+ // An unrecognised flag was silently ignored, so `--file README.md` routed
1089
+ // the whole branch diff at exit 0 — a different change than the caller
1090
+ // asked about, reported as if it were theirs. That is the one input this
1091
+ // module was not treating as untrusted.
1092
+ //
1093
+ // The FIRST offender is kept, not the last: `--file README.md` leaves two
1094
+ // unrecognised tokens behind, and naming the second one points the reader
1095
+ // at the argument rather than at the typo that stranded it.
1096
+ else if (args.bad === null) args.bad = arg;
1097
+ }
1098
+ return args;
1099
+ };
1100
+
1101
+ /**
1102
+ * The changed files of the branch under review.
1103
+ *
1104
+ * `<base>...<head>` — three dots — is right *here* and wrong after a merge: it
1105
+ * is `merge-base..head`, which is exactly "what this branch added" while the
1106
+ * branch is still open. (The post-merge close step needs `<merge>^1 <merge>`
1107
+ * instead, and mixing the two up returns an empty list.)
1108
+ */
1109
+ const gitFiles = (base, head) => {
1110
+ const raw = execFileSync('git', ['diff', '--name-status', '-z', `${base}...${head}`], {
1111
+ encoding: 'utf8',
1112
+ // A process started under a git hook inherits an absolute GIT_DIR, and a
1113
+ // child that keeps it reports the file list of ANOTHER repository — silently,
1114
+ // because the list comes back non-empty.
1115
+ env: withoutGitLocation(),
1116
+ // git's own stderr is captured rather than inherited, so it does not
1117
+ // interleave with this file's output mid-run.
1118
+ //
1119
+ // ⚠ Stated exactly, because the first version of this comment claimed a
1120
+ // sanitisation that does not happen: `execFileSync` folds the captured
1121
+ // stderr into `error.message`, and the handler below prints that verbatim.
1122
+ // So git's `fatal:` text and any repository-supplied name in it still reach
1123
+ // the terminal — just at the end rather than streamed. Capturing buys
1124
+ // ordering, not filtering.
1125
+ stdio: ['ignore', 'pipe', 'pipe'],
1126
+ maxBuffer: 64 * 1024 * 1024,
1127
+ });
1128
+ return parseNameStatus(raw);
1129
+ };
1130
+
1131
+ /**
1132
+ * Was this file invoked directly?
1133
+ *
1134
+ * Compared by REALPATH on both sides, the same way every sibling CLI in this
1135
+ * directory does it. ESM resolves `import.meta.url` through symlinks while
1136
+ * `process.argv[1]` keeps the path as typed, so a checkout behind a link — a
1137
+ * macOS temp dir, a symlinked home — fails a naive equality check and the script
1138
+ * exits 0 having printed nothing. For this file that is the worst shape
1139
+ * available: the gate reads the lane off stdout, and empty stdout with exit 0 is
1140
+ * indistinguishable from a router that ran.
1141
+ */
1142
+ const invokedDirectly = () => {
1143
+ if (!process.argv[1]) return false;
1144
+ const real = (p) => {
1145
+ try {
1146
+ return realpathSync(p);
1147
+ } catch {
1148
+ return p;
1149
+ }
1150
+ };
1151
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
1152
+ };
1153
+
1154
+ if (invokedDirectly()) {
1155
+ const args = parseArgs(process.argv.slice(2));
1156
+ if (args.bad !== null) {
1157
+ process.stderr.write(
1158
+ `decision-router: ${args.bad === '' ? '""' : args.bad} is not a flag this router ` +
1159
+ 'understands, or was given a value starting with "-" that git would read as an ' +
1160
+ 'option rather than a revision. Nothing was routed — treat this as the expensive ' +
1161
+ 'lane.\n',
1162
+ );
1163
+ process.exit(1);
1164
+ }
1165
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
1166
+
1167
+ let files;
1168
+ try {
1169
+ files =
1170
+ args.files === null
1171
+ ? gitFiles(args.base, args.head)
1172
+ : args.files
1173
+ .split(',')
1174
+ .map((entry) => entry.trim())
1175
+ .filter(Boolean);
1176
+ } catch (error) {
1177
+ process.stderr.write(
1178
+ `decision-router: could not read the changed file list from git ` +
1179
+ `(${error?.message ?? error}). Nothing was routed — an unreadable diff is an ` +
1180
+ 'absence, not a cheap change.\n',
1181
+ );
1182
+ process.exit(1);
1183
+ }
1184
+
1185
+ // 🔴 A project that declares no elevated path cannot have the `elevated-path`
1186
+ // flag EVALUATED — and degrading that to an empty declaration made the trace
1187
+ // say `risk-flags clear — no risk flag fired`, which is an unevaluated check
1188
+ // reading as a pass. That is the one thing a gate's own journal must never
1189
+ // produce, so this refuses instead, exactly as `recordCompletedTier` does.
1190
+ const declared = readDeclaredPaths(projectRoot);
1191
+ if (!declared || declared.length === 0) {
1192
+ process.stderr.write(
1193
+ 'decision-router: nothing in this project declares an elevated path, so the ' +
1194
+ '`elevated-path` risk flag cannot be evaluated and no lane can be trusted. Add an ' +
1195
+ '`elevated-paths` block to CLAUDE.md or a rule file. Nothing was routed — treat ' +
1196
+ 'this as the expensive lane, never as a cheap one.\n',
1197
+ );
1198
+ process.exit(1);
1199
+ }
1200
+
1201
+ let result;
1202
+ try {
1203
+ result = route({ files, elevatedPaths: declared });
1204
+ } catch (error) {
1205
+ // The refusal, as a diagnosis rather than a stack dump: a caller reading a
1206
+ // node trace on stderr learns nothing about why its change was not routed.
1207
+ process.stderr.write(`decision-router: ${error?.message ?? error}\n`);
1208
+ process.exit(1);
1209
+ }
1210
+
1211
+ // 🔴 A router with no journal line is a decision nobody can retrace. One
1212
+ // record per gate — including the skipped ones, because within a run that
1213
+ // declared a directory, "no line" is exactly how a gate that silently stopped
1214
+ // matching looks.
1215
+ //
1216
+ // ⚠ Read that condition exactly, because the two absences are different
1217
+ // facts: this writes ONLY when the run declared `RIG_RUN_DIR`, so an empty
1218
+ // `decisions.jsonl` across an undeclared run is the ordinary state and says
1219
+ // nothing about the gates. Inventing a directory here would make this CLI a
1220
+ // second owner of the `.claude/runs/<run-id>/` convention.
1221
+ const runDir = process.env.RIG_RUN_DIR;
1222
+ if (runDir) {
1223
+ let journal = null;
1224
+ try {
1225
+ journal = await import('./run-journal.mjs');
1226
+ for (const gate of result.gates) {
1227
+ journal.recordDecision({
1228
+ runDir,
1229
+ gate: `review-routing:${gate.gate}`,
1230
+ verdict: gate.verdict,
1231
+ why: gate.why,
1232
+ // Only on the lane that was actually taken: the set belongs to the
1233
+ // route, not to the gates that declined it. Recorded because it
1234
+ // cannot be recomputed afterwards — it is a floor `pr-ship` may add
1235
+ // to (limit 6), so paths alone do not reproduce it.
1236
+ ...(gate.verdict === 'route' ? { reviewers: result.reviewers } : {}),
1237
+ now: new Date().toISOString(),
1238
+ });
1239
+ }
1240
+ } catch (error) {
1241
+ // Same split the queue CLI makes, and for the same reason: a trace that
1242
+ // cannot accept another record is over, and the routing is not. Asking the
1243
+ // module which failure this is — never matching on the message text, which
1244
+ // would put the decision in two files and let them drift.
1245
+ const classify = journal?.isTraceExhausted;
1246
+ if (typeof classify === 'function' && classify(error)) {
1247
+ process.stderr.write(
1248
+ `run journal: ${error.message}\n` +
1249
+ ` the route below was NOT recorded in ${runDir}. This run's trace ends here; ` +
1250
+ 'the routing is fine, and a new run needs a new run directory.\n',
1251
+ );
1252
+ } else {
1253
+ process.stderr.write(`run journal: ${error?.message ?? error}\n`);
1254
+ process.exit(1);
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : render(result));
1260
+ }