github-router 0.3.249 → 0.3.255

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 (95) hide show
  1. package/dist/attribution-settings-CegtE0TY.js +2347 -0
  2. package/dist/attribution-settings-CegtE0TY.js.map +1 -0
  3. package/dist/auth-CP4q2CjO.js +44 -0
  4. package/dist/auth-CP4q2CjO.js.map +1 -0
  5. package/dist/browser-ext/background.js +8 -1
  6. package/dist/browser-ext/manifest.json +1 -1
  7. package/dist/check-usage-6PB5IUFb.js +43 -0
  8. package/dist/check-usage-6PB5IUFb.js.map +1 -0
  9. package/dist/claude-BiZxoNWq.js +593 -0
  10. package/dist/claude-BiZxoNWq.js.map +1 -0
  11. package/dist/client-CQMfroGV.js +413 -0
  12. package/dist/client-CQMfroGV.js.map +1 -0
  13. package/dist/codex-ST21X3WF.js +85 -0
  14. package/dist/codex-ST21X3WF.js.map +1 -0
  15. package/dist/debug-CjmE9sgG.js +81 -0
  16. package/dist/debug-CjmE9sgG.js.map +1 -0
  17. package/dist/engine-DNCX0Hfd.js +2 -0
  18. package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
  19. package/dist/exec-DTO2oin0.js.map +1 -0
  20. package/dist/file-log-reporter-IFQcL5IK.js +158 -0
  21. package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
  22. package/dist/gate-discovery-CepmbZnc.js +635 -0
  23. package/dist/gate-discovery-CepmbZnc.js.map +1 -0
  24. package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
  25. package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
  26. package/dist/hook-mcp-client-DBvm9608.js +136 -0
  27. package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
  28. package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
  29. package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
  30. package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
  31. package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
  32. package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
  33. package/dist/internal-plan-review-ipnT3bhj.js +114 -0
  34. package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
  35. package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
  36. package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
  37. package/dist/internal-session-bind-DwK2iGW0.js +133 -0
  38. package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
  39. package/dist/internal-stop-hook-CNPGJm63.js +212 -0
  40. package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
  41. package/dist/internal-stop-review-AUcFssrW.js +122 -0
  42. package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
  43. package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
  44. package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
  45. package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
  46. package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
  47. package/dist/lifecycle-Byw5s1G8.js +134 -0
  48. package/dist/lifecycle-Byw5s1G8.js.map +1 -0
  49. package/dist/lifecycle-D80CaWie.js +2 -0
  50. package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
  51. package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
  52. package/dist/lifecycle-DzqCqDlM.js +2 -0
  53. package/dist/main.js +43 -12884
  54. package/dist/main.js.map +1 -1
  55. package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
  56. package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
  57. package/dist/models-B6UnGyRo.js +158 -0
  58. package/dist/models-B6UnGyRo.js.map +1 -0
  59. package/dist/orchestration-hRU4TdWr.js +39 -0
  60. package/dist/orchestration-hRU4TdWr.js.map +1 -0
  61. package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
  62. package/dist/paths-C1cLZ7Ga.js.map +1 -0
  63. package/dist/paths-CwKwc2pi.js +2 -0
  64. package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
  65. package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
  66. package/dist/plan-review-hook-C8x0uKPf.js +159 -0
  67. package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
  68. package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
  69. package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
  70. package/dist/provision-DSkPa5hY.js +656 -0
  71. package/dist/provision-DSkPa5hY.js.map +1 -0
  72. package/dist/serve-sa_7c-yD.js +1511 -0
  73. package/dist/serve-sa_7c-yD.js.map +1 -0
  74. package/dist/server-setup-DcO9qvN3.js +4776 -0
  75. package/dist/server-setup-DcO9qvN3.js.map +1 -0
  76. package/dist/start-siGxuZhO.js +129 -0
  77. package/dist/start-siGxuZhO.js.map +1 -0
  78. package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
  79. package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
  80. package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
  81. package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
  82. package/dist/token-R2ocFC3M.js +1054 -0
  83. package/dist/token-R2ocFC3M.js.map +1 -0
  84. package/dist/version-_Q1WpsQp.js +32 -0
  85. package/dist/version-_Q1WpsQp.js.map +1 -0
  86. package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
  87. package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
  88. package/package.json +1 -1
  89. package/dist/engine-BuuHUb4t.js +0 -2
  90. package/dist/lifecycle-0rXrwjWk.js +0 -2
  91. package/dist/lifecycle-DHZsVN8j.js.map +0 -1
  92. package/dist/lifecycle-hzPwXc3I.js +0 -2
  93. package/dist/paths-BjTMI_xK.js.map +0 -1
  94. package/dist/paths-D3XgmTcQ.js +0 -2
  95. package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
@@ -0,0 +1,686 @@
1
+ import { r as parseBoolEnv } from "./exec-DTO2oin0.js";
2
+ import { a as isSubagentContext, o as regressions } from "./stop-gate-policy-CHwWbuF_.js";
3
+ import { promises } from "node:fs";
4
+ import path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ //#region src/lib/orchestration/gate-runner.ts
7
+ async function runGateChecks(checks, cwd, exec) {
8
+ const results = await Promise.all(checks.map(async (c) => {
9
+ try {
10
+ const r = await exec({
11
+ command: c.command,
12
+ cwd
13
+ });
14
+ return {
15
+ id: c.id,
16
+ passed: r.exitCode === 0
17
+ };
18
+ } catch {
19
+ return {
20
+ id: c.id,
21
+ passed: false
22
+ };
23
+ }
24
+ }));
25
+ const passed = /* @__PURE__ */ new Set();
26
+ const ran = /* @__PURE__ */ new Set();
27
+ for (const r of results) {
28
+ ran.add(r.id);
29
+ if (r.passed) passed.add(r.id);
30
+ }
31
+ return {
32
+ passed,
33
+ ran
34
+ };
35
+ }
36
+ //#endregion
37
+ //#region src/lib/orchestration/gate-registry.ts
38
+ /**
39
+ * Built-in sealed gates. Commands follow this repo's TS/Bun conventions (the
40
+ * `bun run <script>` indirection means a repo without that script simply fails
41
+ * the check, which the selector treats as not-passed rather than a crash). New
42
+ * ecosystems get a new sealed id here, never a caller-supplied command.
43
+ */
44
+ const SEALED_GATES = {
45
+ "default-ci": [
46
+ {
47
+ id: "typecheck",
48
+ command: "bun run typecheck"
49
+ },
50
+ {
51
+ id: "test",
52
+ command: "bun test"
53
+ },
54
+ {
55
+ id: "lint",
56
+ command: "bun run lint"
57
+ }
58
+ ],
59
+ "typecheck-test": [{
60
+ id: "typecheck",
61
+ command: "bun run typecheck"
62
+ }, {
63
+ id: "test",
64
+ command: "bun test"
65
+ }],
66
+ "typecheck-only": [{
67
+ id: "typecheck",
68
+ command: "bun run typecheck"
69
+ }]
70
+ };
71
+ /** The set of sealed gate ids, used as the kernel's `knownGateIds` so the IR
72
+ * verifier rejects an executable gate that references an unregistered id. */
73
+ function sealedGateIds() {
74
+ return new Set(Object.keys(SEALED_GATES));
75
+ }
76
+ /**
77
+ * Resolve a sealed gate by id. Returns a DEFENSIVE CLONE (fresh objects) so a
78
+ * caller can never mutate the registry's command set. `undefined` for an
79
+ * unknown id, which `run_workflow` rejects before executing anything.
80
+ */
81
+ function resolveSealedGate(gateId) {
82
+ const checks = SEALED_GATES[gateId];
83
+ if (!checks) return void 0;
84
+ return {
85
+ id: gateId,
86
+ checks: checks.map((c) => ({
87
+ id: c.id,
88
+ command: c.command
89
+ }))
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region src/lib/orchestration/gate-immutability.ts
94
+ /** Patterns that weaken a gate regardless of language (test exclusivity / focus
95
+ * idioms shared across JS test runners; kept in COMMON since several languages
96
+ * reuse them). */
97
+ const COMMON_PATTERNS = [{
98
+ name: "skipped-test",
99
+ re: /(\.\s*skip\s*\(|\bxit\s*\(|\bxdescribe\s*\(|\.\s*only\s*\()/
100
+ }];
101
+ /** Language-specific weakening patterns, selected by the added line's file type. */
102
+ const LANG_PATTERNS = {
103
+ ts: [
104
+ {
105
+ name: "ts-suppression",
106
+ re: /@ts-(ignore|nocheck|expect-error)\b/
107
+ },
108
+ {
109
+ name: "any-cast",
110
+ re: /\bas\s+any\b|:\s*any\b/
111
+ },
112
+ {
113
+ name: "eslint-disable",
114
+ re: /eslint-disable\b/
115
+ }
116
+ ],
117
+ py: [
118
+ {
119
+ name: "py-type-ignore",
120
+ re: /#\s*type:\s*ignore\b/
121
+ },
122
+ {
123
+ name: "py-noqa",
124
+ re: /#\s*noqa\b/
125
+ },
126
+ {
127
+ name: "py-skip",
128
+ re: /@(pytest\.mark\.skip|unittest\.skip)\b/
129
+ }
130
+ ],
131
+ go: [{
132
+ name: "go-skip",
133
+ re: /\bt\.Skip\s*\(/
134
+ }, {
135
+ name: "go-nolint",
136
+ re: /\/\/\s*nolint\b/
137
+ }],
138
+ rust: [{
139
+ name: "rust-ignore",
140
+ re: /#\[\s*ignore\b/
141
+ }, {
142
+ name: "rust-allow",
143
+ re: /#\[\s*allow\s*\(/
144
+ }]
145
+ };
146
+ /** Map a file path to a language key for `LANG_PATTERNS` (null → COMMON only). */
147
+ function langForFile(file) {
148
+ if (!file) return null;
149
+ switch (file.slice(file.lastIndexOf(".")).toLowerCase()) {
150
+ case ".ts":
151
+ case ".tsx":
152
+ case ".mts":
153
+ case ".cts":
154
+ case ".js":
155
+ case ".jsx":
156
+ case ".mjs":
157
+ case ".cjs": return "ts";
158
+ case ".py":
159
+ case ".pyi": return "py";
160
+ case ".go": return "go";
161
+ case ".rs": return "rust";
162
+ default: return null;
163
+ }
164
+ }
165
+ /** Union of COMMON + every language — the pre-header default (no file known yet),
166
+ * so a header-less diff is matched permissively, exactly as before this became
167
+ * language-scoped. Once a real file header appears, the set narrows to that
168
+ * file's language (or COMMON only for an unknown extension → fail open). */
169
+ const ALL_PATTERNS = [...COMMON_PATTERNS, ...Object.values(LANG_PATTERNS).flat()];
170
+ /** A `diff --git a/x b/x` or `+++ b/x` header → the current file path. */
171
+ function fileFromHeader(line) {
172
+ const git = /^diff --git a\/.+ b\/(.+)$/.exec(line);
173
+ if (git) return git[1];
174
+ const plus = /^\+\+\+ b\/(.+)$/.exec(line);
175
+ if (plus) return plus[1];
176
+ }
177
+ function detectGateWeakening(diff) {
178
+ const findings = [];
179
+ let file;
180
+ let patterns = ALL_PATTERNS;
181
+ for (const raw of diff.split("\n")) {
182
+ const headerFile = fileFromHeader(raw);
183
+ if (headerFile !== void 0) {
184
+ file = headerFile;
185
+ const lang = langForFile(file);
186
+ patterns = lang ? [...COMMON_PATTERNS, ...LANG_PATTERNS[lang]] : COMMON_PATTERNS;
187
+ continue;
188
+ }
189
+ if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
190
+ const added = raw.slice(1);
191
+ for (const p of patterns) if (p.re.test(added)) findings.push(file === void 0 ? {
192
+ pattern: p.name,
193
+ line: added.trim()
194
+ } : {
195
+ pattern: p.name,
196
+ line: added.trim(),
197
+ file
198
+ });
199
+ }
200
+ return {
201
+ weakened: findings.length > 0,
202
+ findings
203
+ };
204
+ }
205
+ //#endregion
206
+ //#region src/lib/orchestration/stop-gate.ts
207
+ /**
208
+ * The Phase-0 structural-gate decision — the cheap, CERTAIN floor win. It blocks
209
+ * marking work "done" when any canonical gate fails OR the diff weakens a gate.
210
+ * A non-model check that fires every run and never ships broken or gate-gamed
211
+ * code; per the floor analysis it is the only component that raises the
212
+ * worst-case floor BY CONSTRUCTION (the orchestration on top is a conditional
213
+ * bet).
214
+ *
215
+ * This is the logic behind the spawned-session Stop-hook AND the kernel runner's
216
+ * pre-accept check. The process exec and the diff are inputs, so the decision is
217
+ * pure + unit-testable; the hook script that runs `git diff` + invokes this is a
218
+ * thin wrapper.
219
+ */
220
+ async function evaluateStopGate(input) {
221
+ const gate = await runGateChecks(input.checks, input.cwd, input.exec);
222
+ const weak = detectGateWeakening(input.diff);
223
+ const failedChecks = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
224
+ const block = failedChecks.length > 0 || weak.weakened;
225
+ const parts = [];
226
+ if (failedChecks.length > 0) parts.push(`failing gates: ${failedChecks.join(", ")}`);
227
+ if (weak.weakened) {
228
+ const pats = [...new Set(weak.findings.map((f) => f.pattern))].join(", ");
229
+ parts.push(`gate-weakening in the diff: ${pats}`);
230
+ }
231
+ return {
232
+ block,
233
+ reason: block ? parts.join("; ") : "all canonical gates pass; no gate-weakening in the diff",
234
+ failedChecks,
235
+ weakening: weak.findings
236
+ };
237
+ }
238
+ //#endregion
239
+ //#region src/lib/orchestration/stop-gate-hook.ts
240
+ /**
241
+ * The Phase-0 structural-gate Stop-hook glue. `runStopGateForLaunch` resolves a
242
+ * SEALED gate by id and evaluates it against the working-tree diff, returning the
243
+ * block decision a spawned-session Stop hook (or a manual check) acts on.
244
+ *
245
+ * Safety posture (deliberately different from the kernel): a Stop hook that
246
+ * blocks on a MISCONFIGURATION would wedge the user's session (they could not
247
+ * stop). So this fails OPEN on config problems (unknown gate id) and blocks ONLY
248
+ * on a genuine red gate or a gate-weakening diff. The kernel, by contrast, fails
249
+ * CLOSED to the baseline. Both are correct for their context.
250
+ *
251
+ * The sealed gate + diff + exec are inputs, so the decision is pure and
252
+ * unit-testable; the thin live wrapper (capture `git diff`, spawn the checks,
253
+ * map the result to an exit code) is the only part that needs a real process.
254
+ */
255
+ async function runStopGateForLaunch(input) {
256
+ const gate = resolveSealedGate(input.gateId);
257
+ if (!gate) return {
258
+ block: false,
259
+ reason: `stop-gate: unknown gateId "${input.gateId}" (not blocking)`,
260
+ failedChecks: [],
261
+ weakening: []
262
+ };
263
+ return evaluateStopGate({
264
+ checks: gate.checks,
265
+ cwd: input.workspace,
266
+ exec: input.exec,
267
+ diff: input.diff
268
+ });
269
+ }
270
+ /**
271
+ * The launch-stable baseline key for the dynamic gate path: keyed by the
272
+ * workdir + the descriptor's check set + a per-launch token, NOT the session id
273
+ * and NOT HEAD. The LAUNCHER generates the token (passed to the child via env),
274
+ * computes this key, and seeds the baseline (the set of checks already failing at
275
+ * launch, BEFORE the agent mutates the tree); the runtime hook recomputes the
276
+ * same key (reading the token from its env) and reads that baseline, so an
277
+ * agent-introduced failure is a REGRESSION rather than being silently adopted as
278
+ * the baseline at first stop. The per-launch token isolates concurrent sessions
279
+ * on the same repo (they don't clobber each other's baseline). HEAD is
280
+ * deliberately NOT in the key: an agent that COMMITS its work mid-session must
281
+ * still be judged against the pre-session baseline (a HEAD-keyed baseline would
282
+ * vanish on the commit — the exact moment it matters). A changed check set (the
283
+ * agent edited the harness) yields a different key → the launch baseline no
284
+ * longer matches and the hook degrades to first-stop baselining.
285
+ */
286
+ function launchBaselineKey(workdir, descriptorKey, token) {
287
+ return JSON.stringify([
288
+ "launch",
289
+ path.resolve(workdir),
290
+ descriptorKey,
291
+ token ?? ""
292
+ ]);
293
+ }
294
+ /**
295
+ * Run the resolved checks ONCE at launch (pre-mutation) and record the failing
296
+ * set as the baseline under `launchBaselineKey`. Best-effort: any failure is
297
+ * swallowed (the hook then degrades to first-stop baselining). The launcher MUST
298
+ * pass the SAME check set the hook will run (same `includeTests`) so a
299
+ * pre-existing test failure isn't later miscounted as a regression.
300
+ *
301
+ * NOTE: capture is best-effort pre-mutation — the launcher fires it before the
302
+ * child can edit, and in practice the agent does not mutate until well after a
303
+ * user prompt, so the checks see the clean tree. If the agent somehow mutates
304
+ * before this completes, the worst case is the legacy first-stop baseline (no
305
+ * regression, never a wedge).
306
+ */
307
+ async function captureLaunchBaseline(input) {
308
+ if (input.checks.length === 0) return;
309
+ try {
310
+ const gate = await runGateChecks(input.checks, input.workdir, input.exec);
311
+ const failed = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
312
+ await input.baseline.set(launchBaselineKey(input.workdir, input.descriptorKey, input.token), failed);
313
+ } catch {}
314
+ }
315
+ /**
316
+ * The advisory background review (hook V2) is ON by default whenever the Stop
317
+ * gate runs; it is the cross-lab accountability layer. Opt out with
318
+ * `GH_ROUTER_DISABLE_STOP_REVIEW=1` to keep the deterministic gate but drop the
319
+ * LLM review. (Disabling the whole gate with `GH_ROUTER_DISABLE_STOP_GATE=1`
320
+ * also drops the review, since the review only ever fires from the gate's green
321
+ * path.)
322
+ */
323
+ function stopReviewEnabled(env = process.env) {
324
+ return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_REVIEW) !== true;
325
+ }
326
+ /** The sealed gate the Stop hook runs, overridable via `GH_ROUTER_STOP_GATE_ID`
327
+ * (must be a registered sealed id; the live wrapper falls open on an unknown
328
+ * id). Defaults to `default-ci`. */
329
+ function stopGateId(env = process.env) {
330
+ const v = (env.GH_ROUTER_STOP_GATE_ID ?? "").trim();
331
+ return v.length > 0 ? v : "default-ci";
332
+ }
333
+ /**
334
+ * PLAN-MODE env fallback. When the session is in plan mode, plan/memory scratch
335
+ * edits should not trip the gate-WEAKENING scan (a plan/memory markdown file may
336
+ * legitimately quote `it.skip` / `as any` while describing work). The launcher
337
+ * sets `GH_ROUTER_STOP_GATE_PLAN_MODE` for a plan-mode session; the payload may
338
+ * ALSO carry a forward-compat `plan_mode` field (see `decideStopHook`). Either
339
+ * signal being present activates the scoping — the executable gate STILL runs
340
+ * and non-plan hunks are STILL scanned.
341
+ */
342
+ function stopGatePlanMode(env = process.env) {
343
+ return parseBoolEnv(env.GH_ROUTER_STOP_GATE_PLAN_MODE) === true;
344
+ }
345
+ /**
346
+ * Is a unified-diff file path confined to a `plans/` or `memory/` directory?
347
+ * Windows-safe (normalizes `\` → `/`). Matches only when a DIRECTORY segment of
348
+ * the path is exactly `plans` or `memory`, so `plans/x.md`, `memory/y.md`, and
349
+ * `a/plans/z` match while the filenames `docs/plans.md` / `myplans/x` do not.
350
+ */
351
+ function isPlanMemoryDiffPath(p) {
352
+ const segs = p.replace(/\\/g, "/").split("/");
353
+ for (let i = 0; i < segs.length - 1; i++) if (segs[i] === "plans" || segs[i] === "memory") return true;
354
+ return false;
355
+ }
356
+ /**
357
+ * Drop the file sections of a unified `git diff` whose NEW path (the `b/…` side)
358
+ * is confined to `plans/` or `memory/` — used for the gate-WEAKENING scan when
359
+ * plan mode is active. Sections for other files (e.g. `src/…`) are preserved
360
+ * verbatim, so a MIXED diff keeps its non-plan hunks and `src/` weakening is
361
+ * still caught. The executable gate does not read the diff, so filtering it here
362
+ * never affects which checks run. A diff with no `diff --git` sections (or an
363
+ * empty one) is returned unchanged.
364
+ */
365
+ function stripPlanMemoryDiffHunks(diff) {
366
+ if (diff.length === 0) return diff;
367
+ const out = [];
368
+ let dropping = false;
369
+ for (const line of diff.split("\n")) {
370
+ if (line.startsWith("diff --git ")) {
371
+ const bPath = /^diff --git a\/.+ b\/(.+)$/.exec(line)?.[1];
372
+ dropping = bPath !== void 0 && isPlanMemoryDiffPath(bPath);
373
+ if (!dropping) out.push(line);
374
+ continue;
375
+ }
376
+ if (!dropping) out.push(line);
377
+ }
378
+ return out.join("\n");
379
+ }
380
+ /**
381
+ * C3: whether the structural Stop-gate is disabled for THIS launch. True when the
382
+ * `GH_ROUTER_DISABLE_STOP_GATE` env is set OR the `--no-stop-gate` launcher flag was
383
+ * passed (`args["no-stop-gate"] === true`). A DRIVEN session sets the flag so a
384
+ * blocking Stop hook never hangs its turn-end (~10 min) waiting on the fleet driver.
385
+ * Pure so it is unit-testable without the live launch path.
386
+ */
387
+ function stopGateDisabled(args, env = process.env) {
388
+ return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_GATE) === true || args["no-stop-gate"] === true;
389
+ }
390
+ /** True when a settings `Stop` entry already registers `command` (so the merge
391
+ * is idempotent across re-launches). */
392
+ function entryHasCommand(entry, command) {
393
+ if (!entry || typeof entry !== "object") return false;
394
+ const hooks = entry.hooks;
395
+ if (!Array.isArray(hooks)) return false;
396
+ return hooks.some((h) => h && typeof h === "object" && h.command === command);
397
+ }
398
+ /**
399
+ * Idempotently merge a hook running `command` for `event` (default `Stop`) into
400
+ * an existing Claude Code settings object WITHOUT clobbering other hook events or
401
+ * other entries. Returns a new object (never mutates the input). Re-running the
402
+ * launcher with the same command+event does not duplicate the hook.
403
+ */
404
+ function mergeStopHookIntoSettings(existing, command, event = "Stop", timeoutSec, matcher) {
405
+ const base = existing && typeof existing === "object" ? { ...existing } : {};
406
+ const hooks = base.hooks && typeof base.hooks === "object" ? { ...base.hooks } : {};
407
+ const arr = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
408
+ if (!arr.some((e) => entryHasCommand(e, command))) {
409
+ const hook = {
410
+ type: "command",
411
+ command
412
+ };
413
+ if (typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0) hook.timeout = timeoutSec;
414
+ arr.push(matcher ? {
415
+ matcher,
416
+ hooks: [hook]
417
+ } : { hooks: [hook] });
418
+ }
419
+ hooks[event] = arr;
420
+ base.hooks = hooks;
421
+ return base;
422
+ }
423
+ async function decideStopHook(input) {
424
+ const maxBlocks = input.maxBlocks ?? 2;
425
+ let payload = {};
426
+ let parsed = false;
427
+ try {
428
+ const p = JSON.parse(input.stdin);
429
+ if (p && typeof p === "object") {
430
+ payload = p;
431
+ parsed = true;
432
+ }
433
+ } catch {}
434
+ if (!parsed) return { exitCode: 0 };
435
+ if (isSubagentContext(payload)) return { exitCode: 0 };
436
+ const sessionId = typeof payload.session_id === "string" && payload.session_id.length > 0 ? payload.session_id : "";
437
+ if (!sessionId) return { exitCode: 0 };
438
+ const cwdRaw = typeof payload.cwd === "string" && payload.cwd.length > 0 ? payload.cwd : input.fallbackCwd;
439
+ let cwd = cwdRaw;
440
+ try {
441
+ cwd = await promises.realpath(cwdRaw);
442
+ } catch {}
443
+ let enabled;
444
+ try {
445
+ enabled = await input.isEnabledForRepo(cwd);
446
+ } catch {
447
+ return { exitCode: 0 };
448
+ }
449
+ if (!enabled) return { exitCode: 0 };
450
+ let priorBlocks;
451
+ try {
452
+ priorBlocks = await input.budget.count(sessionId);
453
+ } catch {
454
+ return { exitCode: 0 };
455
+ }
456
+ if (priorBlocks >= maxBlocks) return {
457
+ exitCode: 0,
458
+ stderr: `structural gate: reached the ${maxBlocks}-block limit for this prompt; allowing the stop WITHOUT re-running the checks. If the failures from the last block are unfixed, they are shipping — run the checks manually to verify.`
459
+ };
460
+ let resolvedKey = input.gateId;
461
+ let dynamicBaselineKey;
462
+ const planMode = input.planMode === true || payload.plan_mode === true;
463
+ const scanDiff = (diff) => planMode ? stripPlanMemoryDiffHunks(diff) : diff;
464
+ const capturedDiff = async (workdir) => {
465
+ try {
466
+ return {
467
+ diff: await input.captureDiff(workdir),
468
+ captured: true
469
+ };
470
+ } catch {
471
+ return {
472
+ diff: "",
473
+ captured: false
474
+ };
475
+ }
476
+ };
477
+ const runGate = async () => {
478
+ if (input.resolveChecks) {
479
+ const resolved = await input.resolveChecks(cwd).catch(() => null);
480
+ if (!resolved || resolved.checks.length === 0) return null;
481
+ resolvedKey = resolved.descriptorKey;
482
+ dynamicBaselineKey = resolved.baselineKey;
483
+ const workdir = resolved.workdir || cwd;
484
+ const { diff, captured } = await capturedDiff(workdir);
485
+ if (captured && diff.length === 0) return {
486
+ kind: "no-diff",
487
+ diff
488
+ };
489
+ const result = await evaluateStopGate({
490
+ checks: resolved.checks,
491
+ cwd: workdir,
492
+ exec: input.exec,
493
+ diff: captured ? scanDiff(diff) : ""
494
+ });
495
+ return {
496
+ kind: "evaluated",
497
+ failedChecks: [...result.failedChecks],
498
+ weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
499
+ diff
500
+ };
501
+ }
502
+ const { diff, captured } = await capturedDiff(cwd);
503
+ if (captured && diff.length === 0) return {
504
+ kind: "no-diff",
505
+ diff
506
+ };
507
+ const result = await runStopGateForLaunch({
508
+ workspace: cwd,
509
+ gateId: input.gateId,
510
+ exec: input.exec,
511
+ diff: captured ? scanDiff(diff) : ""
512
+ });
513
+ return {
514
+ kind: "evaluated",
515
+ failedChecks: [...result.failedChecks],
516
+ weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
517
+ diff
518
+ };
519
+ };
520
+ const timeoutMs = input.timeoutMs ?? 3e5;
521
+ let timer;
522
+ const raced = await Promise.race([runGate(), new Promise((resolve) => {
523
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
524
+ })]);
525
+ if (timer) clearTimeout(timer);
526
+ if (raced === "timeout") return { exitCode: 0 };
527
+ if (raced === null) return { exitCode: 0 };
528
+ if (raced.kind === "no-diff") return { exitCode: 0 };
529
+ const baselineKey = dynamicBaselineKey ?? JSON.stringify([
530
+ sessionId,
531
+ cwd,
532
+ resolvedKey
533
+ ]);
534
+ const recorded = await input.baseline.get(baselineKey).catch(() => null);
535
+ if (recorded === null) await input.baseline.set(baselineKey, raced.failedChecks).catch(() => {});
536
+ const regressed = regressions(raced.failedChecks, recorded);
537
+ const weakened = raced.weakeningPatterns.length > 0;
538
+ if (regressed.length === 0 && !weakened) {
539
+ await maybeSpawnReview(input, sessionId, cwd, raced.diff);
540
+ return { exitCode: 0 };
541
+ }
542
+ try {
543
+ await input.budget.record(sessionId);
544
+ } catch {
545
+ return { exitCode: 0 };
546
+ }
547
+ const parts = [];
548
+ if (regressed.length > 0) parts.push(`regressed gates: ${regressed.join(", ")}`);
549
+ if (weakened) parts.push(`gate-weakening in the diff: ${raced.weakeningPatterns.join(", ")}`);
550
+ return {
551
+ exitCode: 2,
552
+ stderr: `structural gate failed (block ${priorBlocks + 1}/${maxBlocks}): ${parts.join("; ")}. Fix the failing checks and revert any gate-weakening (no new .skip / as any / lint-disable) before finishing.`
553
+ };
554
+ }
555
+ /**
556
+ * The advisory-review side-effect on a GREEN stop: debounce by diff hash, then
557
+ * fire the detached background reviewer. ADVISORY-ONLY — it returns void, never
558
+ * throws (every step is swallowed), and the caller does not await its result for
559
+ * the exit decision. A no-op when the review layer isn't wired (no debounce /
560
+ * spawn injected, e.g. GH_ROUTER_DISABLE_STOP_REVIEW) or the diff is empty.
561
+ *
562
+ * `markReviewed` runs BEFORE the spawn so a crashing spawn still records the
563
+ * debounce (an identical tree won't re-trigger on the next stop). The review is
564
+ * gated on the diff CHANGING since the last review — without it, every stop of
565
+ * an unchanged tree would re-spend a background gpt-5.6-sol review.
566
+ *
567
+ * The whole body is bounded by a short timeout (the stores are local temp files
568
+ * that complete in well under a millisecond in practice, so the timeout never
569
+ * fires normally — but if the debounce read/write ever stalled, the stop must
570
+ * still proceed promptly; the advisory layer never delays a clean stop).
571
+ */
572
+ const REVIEW_SIDE_EFFECT_BUDGET_MS = 2e3;
573
+ async function maybeSpawnReview(input, sessionId, cwd, diff) {
574
+ if (!input.reviewDebounce || !input.spawnReview) return;
575
+ if (diff.trim().length === 0) return;
576
+ let timer;
577
+ try {
578
+ const work = (async () => {
579
+ const diffHash = createHash("sha256").update(diff).digest("hex");
580
+ if (!await input.reviewDebounce.shouldReview(sessionId, diffHash)) return;
581
+ await input.reviewDebounce.markReviewed(sessionId, diffHash);
582
+ input.spawnReview({
583
+ sessionId,
584
+ cwd,
585
+ diff,
586
+ diffHash
587
+ });
588
+ })();
589
+ await Promise.race([work, new Promise((resolve) => {
590
+ timer = setTimeout(resolve, REVIEW_SIDE_EFFECT_BUDGET_MS);
591
+ })]);
592
+ } catch {} finally {
593
+ if (timer) clearTimeout(timer);
594
+ }
595
+ }
596
+ /**
597
+ * A file-backed `BlockBudget` under `stateDir`, keyed by a hash of the session id
598
+ * (so a session id is never written verbatim to a predictable path). Best-effort:
599
+ * a read miss counts as 0; `record` increments. A write/read error propagates so
600
+ * `decideStopHook` stands down (it can't guarantee termination without the
601
+ * budget).
602
+ */
603
+ function fileBlockBudget(stateDir) {
604
+ const fileFor = (sid) => path.join(stateDir, `block-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
605
+ const readCount = async (sid) => {
606
+ try {
607
+ const raw = await promises.readFile(fileFor(sid), "utf8");
608
+ const n = Number.parseInt(raw.trim(), 10);
609
+ return Number.isFinite(n) && n > 0 ? n : 0;
610
+ } catch {
611
+ return 0;
612
+ }
613
+ };
614
+ return {
615
+ count: readCount,
616
+ async record(sid) {
617
+ const next = await readCount(sid) + 1;
618
+ await promises.mkdir(stateDir, { recursive: true });
619
+ await promises.writeFile(fileFor(sid), String(next), { mode: 384 });
620
+ },
621
+ async reset(sid) {
622
+ await promises.unlink(fileFor(sid)).catch(() => {});
623
+ }
624
+ };
625
+ }
626
+ /**
627
+ * Build the shell command string Claude Code runs for the Stop hook. Invokes the
628
+ * running github-router via its node/bun binary so it works regardless of PATH.
629
+ * Pure (takes the binary + script paths) so the quoting is unit-testable; the
630
+ * cross-platform firing is verified by the gated E2E.
631
+ */
632
+ function buildStopHookCommand(execPath, scriptPath) {
633
+ const q = (s) => `"${s}"`;
634
+ if (scriptPath && scriptPath !== execPath) return `${q(execPath)} ${q(scriptPath)} internal-stop-hook`;
635
+ return `${q(execPath)} internal-stop-hook`;
636
+ }
637
+ /**
638
+ * Build the shell command Claude Code runs for the SessionStart/SessionEnd hooks
639
+ * (registered only when github-router runs inside an ai-or-die Terminal tab). The
640
+ * sidecar path is baked in as a literal `--out` arg — NOT passed via env — so it
641
+ * survives `AIORDIE_CLAUDE_BIND` being stripped from the child's environment and a
642
+ * nested `github-router claude` can't inherit it. Pure (binary + script + out
643
+ * paths) for unit-testable quoting; the live firing is verified end-to-end.
644
+ */
645
+ function buildSessionBindHookCommand(execPath, scriptPath, outPath) {
646
+ const q = (s) => `"${s}"`;
647
+ return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-session-bind --out ${q(outPath)}`;
648
+ }
649
+ /** Command for the `internal-artifact-open` hook (no args — token comes from the
650
+ * mirror creds file, plan from the plans dir; nothing secret in argv). */
651
+ function buildArtifactOpenHookCommand(execPath, scriptPath) {
652
+ const q = (s) => `"${s}"`;
653
+ return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-artifact-open`;
654
+ }
655
+ /**
656
+ * Read-merge-atomic-write the Stop hook into a Claude Code `settings.json` file
657
+ * (the mirrored one). A MISSING file (ENOENT) starts from `{}`; any OTHER read or
658
+ * parse error THROWS (the caller's try/catch warns and continues) rather than
659
+ * overwriting a file we couldn't understand with our defaults. Preserves every
660
+ * other setting, is idempotent, and uses temp+rename so Claude Code's mtime
661
+ * watcher never sees a half-written file. Returns the merged object.
662
+ */
663
+ async function injectStopHookIntoSettingsFile(settingsPath, command, event = "Stop", timeoutSec, matcher) {
664
+ let existing = {};
665
+ let raw;
666
+ try {
667
+ raw = await promises.readFile(settingsPath, "utf8");
668
+ } catch (err) {
669
+ if (err.code !== "ENOENT") throw err;
670
+ raw = void 0;
671
+ }
672
+ if (raw !== void 0) {
673
+ const parsed = JSON.parse(raw);
674
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
675
+ else throw new Error(`settings.json at ${settingsPath} is not a JSON object; refusing to overwrite`);
676
+ }
677
+ const merged = mergeStopHookIntoSettings(existing, command, event, timeoutSec, matcher);
678
+ const tmp = `${settingsPath}.${process.pid}.tmp`;
679
+ await promises.writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
680
+ await promises.rename(tmp, settingsPath);
681
+ return merged;
682
+ }
683
+ //#endregion
684
+ export { decideStopHook as a, launchBaselineKey as c, stopGatePlanMode as d, stopReviewEnabled as f, runGateChecks as h, captureLaunchBaseline as i, stopGateDisabled as l, sealedGateIds as m, buildSessionBindHookCommand as n, fileBlockBudget as o, resolveSealedGate as p, buildStopHookCommand as r, injectStopHookIntoSettingsFile as s, buildArtifactOpenHookCommand as t, stopGateId as u };
685
+
686
+ //# sourceMappingURL=stop-gate-hook-DQc2KV7B.js.map