@dev-loops/core 0.6.0 → 0.7.1

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.
@@ -14,6 +14,53 @@ export const TARGET_REPO_SLUG = "mfittko/dev-loops";
14
14
  /** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
15
15
  export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
16
16
 
17
+ /**
18
+ * Shell command separators that terminate one segment and begin the next.
19
+ * Newline (`\n`) and carriage return (`\r`) are full command terminators in bash
20
+ * (equivalent to `;`), and the Claude Code Bash tool accepts multi-line command
21
+ * strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
22
+ * the gate. Used by all segment-splitting sites (DRY).
23
+ */
24
+ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
25
+
26
+ /**
27
+ * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
28
+ * A repo flag value may reach us quoted (`--repo 'owner/name'`); the scope check compares against
29
+ * the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard (#1074).
30
+ * ponytail: single balanced pair only — no full shell tokenization (mismatched/partial quotes stay).
31
+ * @param {string|null} value @returns {string|null}
32
+ */
33
+ function stripSurroundingQuotes(value) {
34
+ if (value == null || value.length < 2) return value;
35
+ const first = value[0];
36
+ if ((first === "'" || first === '"') && value[value.length - 1] === first) {
37
+ return value.slice(1, -1);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Read an inline `GH_REPO=<value>` env-assignment prefix on a single command segment.
44
+ * `gh` resolves its target repo from the `GH_REPO` env var, and a segment may set it inline
45
+ * (`GH_REPO=owner/name gh issue create …`) — same targeting intent as `--repo owner/name`, so the
46
+ * scope check must treat it the same or an off-cwd redirect evades the guard (#1074). Only the
47
+ * FIRST leading env assignment matching `GH_REPO=` is read (env assignments precede the executable);
48
+ * the value is quote-normalized. Ambient `process.env.GH_REPO` is out of scope — this is a static
49
+ * command-string classifier, so only the inline assignment in the string is considered.
50
+ * @param {string} segment @returns {string|null}
51
+ */
52
+ function extractGhRepoEnvAssignment(segment) {
53
+ if (!segment) return null;
54
+ for (const token of segment.trim().split(/\s+/)) {
55
+ const assign = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
56
+ if (!assign) break; // first non-assignment token ends the env-assignment prefix
57
+ if (assign[1] === "GH_REPO") {
58
+ return trimToNull(stripSurroundingQuotes(assign[2]));
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
17
64
  /** @param {string|null|undefined} value @returns {string|null} */
18
65
  export function trimToNull(value) {
19
66
  const trimmed = `${value ?? ""}`.trim();
@@ -50,7 +97,7 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
50
97
  return null;
51
98
  }
52
99
 
53
- function isGhPrMergeCommand(segment) {
100
+ function segmentIsGhPrMerge(segment) {
54
101
  if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
55
102
  return false;
56
103
  }
@@ -81,36 +128,178 @@ export function isMergeCapableCommand(command) {
81
128
  return false;
82
129
  }
83
130
  return normalized
84
- .split(/\s*(?:&&|\|\||;|\|)\s*/)
85
- .some((segment) => isGhPrMergeCommand(segment) || isGitMergeCompletionCommand(segment));
131
+ .split(SHELL_SEGMENT_SEPARATOR)
132
+ .some((segment) => segmentIsGhPrMerge(segment) || isGitMergeCompletionCommand(segment));
86
133
  }
87
134
 
88
135
  /** @param {string} command @returns {string} */
89
136
  export function firstShellSegment(command) {
90
- return command.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? "";
137
+ return command.trim().split(SHELL_SEGMENT_SEPARATOR)[0]?.trim() ?? "";
91
138
  }
92
139
 
93
- /** @param {string} command @returns {boolean} */
94
- export function isGhPrReadyCommand(command) {
95
- const segment = firstShellSegment(command);
96
- if (!segment || !/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
97
- return false;
140
+ /** Split a compound shell command into its individual segments. */
141
+ function shellSegments(command) {
142
+ return command.trim().split(SHELL_SEGMENT_SEPARATOR).map((s) => s.trim()).filter(Boolean);
143
+ }
144
+
145
+ /**
146
+ * Leading prefix a `gh pr <verb>` segment may carry before the `gh` executable:
147
+ * a run of `NAME=value` env assignments, optional `command`/`env`/`exec` wrapper
148
+ * words, and an absolute/relative path on the gh binary (`/usr/bin/gh`).
149
+ *
150
+ * Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
151
+ * `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
152
+ * `\gh` forms are deliberately out of scope.
153
+ */
154
+ const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
155
+
156
+ /**
157
+ * Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
158
+ * Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
159
+ * `command gh issue create`, and `/usr/bin/gh pr create` are all matched. The same regex
160
+ * is reused to strip the matched prefix (`segment.replace(re, "")`), so remainder
161
+ * extraction stays consistent across all matcher/extractor call sites. The `gh` prefix
162
+ * requirement means node-wrapper commands (`node scripts/github/comment-issue.mjs …`) never
163
+ * match — their first token is `node`, not `gh`.
164
+ */
165
+ function ghSubcmdVerbRegex(subcmd, verb) {
166
+ return new RegExp(`^${GH_PR_VERB_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
167
+ }
168
+
169
+ /** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
170
+ function ghPrVerbRegex(verb) {
171
+ return ghSubcmdVerbRegex("pr", verb);
172
+ }
173
+
174
+ /**
175
+ * Return the first segment in the command that is a `gh <subcmd> <verb>` call (ignoring
176
+ * --help/-h), or null. Scans ALL segments so compound commands are caught.
177
+ */
178
+ function findGhSubcmdVerbSegment(command, subcmd, verb) {
179
+ const re = ghSubcmdVerbRegex(subcmd, verb);
180
+ for (const segment of shellSegments(command)) {
181
+ if (!re.test(segment)) continue;
182
+ const remainder = segment.replace(re, "").trim();
183
+ if (!remainder) return segment;
184
+ const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
185
+ if (!args.includes("--help") && !args.includes("-h")) return segment;
98
186
  }
99
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
100
- if (!remainder) {
101
- return true;
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Extract the `--repo`/`-R` flag value from an already-isolated `gh <subcmd> <verb>` segment.
192
+ * @param {string} segment @param {string} subcmd @param {string} verb @returns {string|null}
193
+ */
194
+ function extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) {
195
+ const re = ghSubcmdVerbRegex(subcmd, verb);
196
+ if (!segment || !re.test(segment)) return null;
197
+ const remainder = segment.replace(re, "").trim();
198
+ if (!remainder) return null;
199
+ const tokens = remainder.split(/\s+/);
200
+ for (let i = 0; i < tokens.length; i++) {
201
+ const token = tokens[i];
202
+ const lower = token.toLowerCase();
203
+ if (lower === "-r" || lower === "--repo") {
204
+ if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) return stripSurroundingQuotes(tokens[i + 1]);
205
+ }
206
+ const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
207
+ if (repoEqMatch) return stripSurroundingQuotes(repoEqMatch[1]);
208
+ }
209
+ // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
210
+ // mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass (#1074).
211
+ return extractGhRepoEnvAssignment(segment);
212
+ }
213
+
214
+ /**
215
+ * Return one `{ segment, explicitRepo }` entry for EVERY `gh <subcmd> <verb>` segment (ignoring
216
+ * --help/-h). Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
217
+ * @param {string} command @param {string} subcmd @param {string} verb
218
+ * @returns {{ segment: string, explicitRepo: string|null }[]}
219
+ */
220
+ function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
221
+ const re = ghSubcmdVerbRegex(subcmd, verb);
222
+ const out = [];
223
+ for (const segment of shellSegments(command)) {
224
+ if (!re.test(segment)) continue;
225
+ const remainder = segment.replace(re, "").trim();
226
+ if (remainder) {
227
+ const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
228
+ if (args.includes("--help") || args.includes("-h")) continue;
229
+ }
230
+ out.push({ segment, explicitRepo: extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) });
102
231
  }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * The raw external-write verb forms that must be blocked when originating from a subagent:
237
+ * ad-hoc GitHub issue/PR creation and comments run directly via `gh` (not the sanctioned node
238
+ * wrappers). Each entry is `[subcmd, verb]`.
239
+ */
240
+ const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
241
+ ["issue", "create"],
242
+ ["issue", "comment"],
243
+ ["pr", "comment"],
244
+ ]);
245
+
246
+ /**
247
+ * Whether `command` contains a raw `gh issue create`, `gh issue comment`, or `gh pr comment`
248
+ * invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
249
+ * blocks these when they originate from a subagent context. Node-wrapper commands
250
+ * (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
251
+ * @param {string} command @returns {boolean}
252
+ */
253
+ export function commandContainsRawExternalWrite(command) {
254
+ return EXTERNAL_WRITE_VERB_FORMS.some(([subcmd, verb]) => findGhSubcmdVerbSegment(command, subcmd, verb) !== null);
255
+ }
256
+
257
+ /**
258
+ * Return `{ segment, explicitRepo }` for every raw external-write segment across all three verb
259
+ * forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
260
+ * lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
261
+ * later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
262
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
263
+ */
264
+ export function extractRepoFlagsFromExternalWriteSegments(command) {
265
+ return EXTERNAL_WRITE_VERB_FORMS.flatMap(([subcmd, verb]) =>
266
+ extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb),
267
+ );
268
+ }
269
+
270
+ /**
271
+ * Return the first segment in the command that is a `gh pr <verb>` call (ignoring --help/-h),
272
+ * or null. Scans ALL segments so compound commands (`echo ok && gh pr merge 1`) are caught.
273
+ */
274
+ function findGhPrVerbSegment(command, verb) {
275
+ return findGhSubcmdVerbSegment(command, "pr", verb);
276
+ }
277
+
278
+ /**
279
+ * Generic `gh pr <verb>` detector — checks the FIRST shell segment only.
280
+ *
281
+ * Used by the Pi extension's post-execute handler (`onUserBash`) to record that `gh pr ready`
282
+ * actually ran. First-segment-only is correct for that use: `false && gh pr ready 42` short-
283
+ * circuits so ready never executes, and the extension should not record a spurious invocation.
284
+ *
285
+ * For the Claude Code PreToolUse gate (block before execution), use
286
+ * `commandContainsGhPrReady`/`commandContainsGhPrMerge` instead — those scan ALL segments.
287
+ */
288
+ function isGhPrVerbCommand(command, verb) {
289
+ const re = ghPrVerbRegex(verb);
290
+ const segment = firstShellSegment(command);
291
+ if (!segment || !re.test(segment)) return false;
292
+ const remainder = segment.replace(re, "").trim();
293
+ if (!remainder) return true;
103
294
  const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
104
295
  return !args.includes("--help") && !args.includes("-h");
105
296
  }
106
297
 
107
- /** @param {string} command @returns {number|null} */
108
- export function extractPrNumberFromGhPrReady(command) {
109
- const segment = firstShellSegment(command);
110
- if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
111
- return null;
112
- }
113
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
298
+ /** Extract PR number from a single already-isolated segment (shared by both first- and all-segment paths). */
299
+ function extractPrNumberFromSegment(segment, verb) {
300
+ const re = ghPrVerbRegex(verb);
301
+ if (!segment || !re.test(segment)) return null;
302
+ const remainder = segment.replace(re, "").trim();
114
303
  if (!remainder) {
115
304
  return null;
116
305
  }
@@ -135,13 +324,11 @@ export function extractPrNumberFromGhPrReady(command) {
135
324
  return null;
136
325
  }
137
326
 
138
- /** @param {string} command @returns {string|null} */
139
- export function extractRepoFlagFromGhPrReady(command) {
140
- const segment = firstShellSegment(command);
141
- if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
142
- return null;
143
- }
144
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
327
+ /** Extract repo flag from a single already-isolated segment. */
328
+ function extractRepoFlagFromSegment(segment, verb) {
329
+ const re = ghPrVerbRegex(verb);
330
+ if (!segment || !re.test(segment)) return null;
331
+ const remainder = segment.replace(re, "").trim();
145
332
  if (!remainder) {
146
333
  return null;
147
334
  }
@@ -151,13 +338,130 @@ export function extractRepoFlagFromGhPrReady(command) {
151
338
  const lower = token.toLowerCase();
152
339
  if (lower === "-r" || lower === "--repo") {
153
340
  if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
154
- return tokens[i + 1];
341
+ return stripSurroundingQuotes(tokens[i + 1]);
155
342
  }
156
343
  }
157
344
  const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
158
345
  if (repoEqMatch) {
159
- return repoEqMatch[1];
346
+ return stripSurroundingQuotes(repoEqMatch[1]);
160
347
  }
161
348
  }
162
- return null;
349
+ // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
350
+ // mirroring gh's own precedence). Applied here too so gh pr ready/merge/create scope checks get
351
+ // consistent GH_REPO handling — the root-cause fix, not just the external-write path (#1074).
352
+ return extractGhRepoEnvAssignment(segment);
353
+ }
354
+
355
+ /** First-segment extractor for `gh pr <verb>` PR number — Pi extension public API. */
356
+ function extractPrNumberFromGhPrVerb(command, verb) {
357
+ return extractPrNumberFromSegment(firstShellSegment(command), verb);
358
+ }
359
+
360
+ /** First-segment extractor for `gh pr <verb>` --repo flag — Pi extension public API. */
361
+ function extractRepoFlagFromGhPrVerb(command, verb) {
362
+ return extractRepoFlagFromSegment(firstShellSegment(command), verb);
363
+ }
364
+
365
+ /** @param {string} command @returns {boolean} */
366
+ export function isGhPrReadyCommand(command) {
367
+ return isGhPrVerbCommand(command, "ready");
368
+ }
369
+
370
+ /** @param {string} command @returns {number|null} */
371
+ export function extractPrNumberFromGhPrReady(command) {
372
+ return extractPrNumberFromGhPrVerb(command, "ready");
373
+ }
374
+
375
+ /** @param {string} command @returns {string|null} */
376
+ export function extractRepoFlagFromGhPrReady(command) {
377
+ return extractRepoFlagFromGhPrVerb(command, "ready");
378
+ }
379
+
380
+ /**
381
+ * Whether `command` contains a `gh pr merge` invocation in the FIRST shell segment,
382
+ * ignoring `--help`/`-h`. Used by the Pi extension's post-execute handler.
383
+ * For the Claude Code PreToolUse gate, use `commandContainsGhPrMerge` instead.
384
+ * @param {string} command @returns {boolean}
385
+ */
386
+ export function isGhPrMergeCommand(command) {
387
+ return isGhPrVerbCommand(command, "merge");
388
+ }
389
+
390
+ /**
391
+ * Whether `command` contains a `gh pr ready` invocation in ANY shell segment.
392
+ * For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively
393
+ * regardless of shell short-circuit semantics (`false && gh pr ready 42` is still blocked).
394
+ * @param {string} command @returns {boolean}
395
+ */
396
+ export function commandContainsGhPrReady(command) {
397
+ return findGhPrVerbSegment(command, "ready") !== null;
398
+ }
399
+
400
+ /**
401
+ * Whether `command` contains a `gh pr merge` invocation in ANY shell segment.
402
+ * For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively.
403
+ * @param {string} command @returns {boolean}
404
+ */
405
+ export function commandContainsGhPrMerge(command) {
406
+ return findGhPrVerbSegment(command, "merge") !== null;
407
+ }
408
+
409
+ /** Extract PR number from `gh pr ready` in any shell segment — PreToolUse gate use only. */
410
+ export function extractPrNumberFromGhPrReadyAnywhere(command) {
411
+ return extractPrNumberFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
412
+ }
413
+
414
+ /** @param {string} command @returns {string|null} */
415
+ export function extractRepoFlagFromGhPrReadyAnywhere(command) {
416
+ return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
417
+ }
418
+
419
+ /** Extract PR number from `gh pr merge` in any shell segment — PreToolUse gate use only. */
420
+ export function extractPrNumberFromGhPrMergeAnywhere(command) {
421
+ return extractPrNumberFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
422
+ }
423
+
424
+ /** @param {string} command @returns {string|null} */
425
+ export function extractRepoFlagFromGhPrMergeAnywhere(command) {
426
+ return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
427
+ }
428
+
429
+ /**
430
+ * Whether `command` contains a raw `gh pr create` invocation in ANY shell segment.
431
+ * PreToolUse gate use only — blocks raw `gh pr create` so PR creation flows through the
432
+ * canonical wrapper (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always
433
+ * creates a draft and self-assigns. The wrapper runs `gh pr create` inside a node child
434
+ * process, so its Bash command string (`node …/create-pr.mjs …`) never matches this — only a
435
+ * literal `gh pr create` in the agent's shell command does.
436
+ * @param {string} command @returns {boolean}
437
+ */
438
+ export function commandContainsGhPrCreate(command) {
439
+ return findGhPrVerbSegment(command, "create") !== null;
440
+ }
441
+
442
+ /** Extract repo flag from `gh pr create` in any shell segment — PreToolUse gate use only. */
443
+ export function extractRepoFlagFromGhPrCreateAnywhere(command) {
444
+ return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "create"), "create");
445
+ }
446
+
447
+ /**
448
+ * Return one `{ segment, explicitRepo }` entry for EVERY `gh pr create` segment (ignoring
449
+ * --help/-h), not just the first. PreToolUse gate use only: the create-scope decision must
450
+ * consider every create segment, so a leading out-of-scope create can't shield a later
451
+ * in-scope raw create (`gh pr create --repo other/repo && gh pr create --fill`).
452
+ * `explicitRepo` is the segment's `--repo`/`-R` value or null when none is present.
453
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
454
+ */
455
+ export function extractRepoFlagsFromGhPrCreateSegments(command) {
456
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
457
+ }
458
+
459
+ /** @param {string} command @returns {number|null} */
460
+ export function extractPrNumberFromGhPrMerge(command) {
461
+ return extractPrNumberFromGhPrVerb(command, "merge");
462
+ }
463
+
464
+ /** @param {string} command @returns {string|null} */
465
+ export function extractRepoFlagFromGhPrMerge(command) {
466
+ return extractRepoFlagFromGhPrVerb(command, "merge");
163
467
  }
@@ -697,13 +697,6 @@ export const OUTER_TERMINAL_STATES = Object.freeze([
697
697
  OUTER_STATE.NEEDS_RECONCILE,
698
698
  ]);
699
699
 
700
- export const OUTER_NONTERMINAL_STATES = Object.freeze([
701
- OUTER_STATE.CONTINUE_CURRENT_WAIT,
702
- OUTER_STATE.HANDOFF_TO_COPILOT_LOOP,
703
- OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP,
704
- OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER,
705
- ]);
706
-
707
700
  const OUTER_TERMINAL_STATE_SET = new Set(OUTER_TERMINAL_STATES);
708
701
  const ALL_OUTER_STATES = Object.freeze([...OUTER_STATE_VALUES]);
709
702
 
@@ -714,26 +707,6 @@ export const OUTER_GRAPH = Object.freeze({
714
707
  terminalStates: OUTER_TERMINAL_STATES,
715
708
  });
716
709
 
717
- export const OUTER_STATE_TO_OUTER_ACTION = Object.freeze({
718
- [OUTER_STATE.CONTINUE_CURRENT_WAIT]: "continue_wait",
719
- [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "reenter_copilot_loop",
720
- [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: "reenter_reviewer_loop",
721
- [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: "continue_wait",
722
- [OUTER_STATE.STOP_NEEDS_HUMAN]: "stop",
723
- [OUTER_STATE.DONE_TERMINAL]: "done",
724
- [OUTER_STATE.NEEDS_RECONCILE]: "stop",
725
- });
726
-
727
- export const OUTER_STATE_TO_ROUTING_OUTCOME = Object.freeze({
728
- [OUTER_STATE.CONTINUE_CURRENT_WAIT]: ROUTING_OUTCOME.CONTINUE_CURRENT_WAIT,
729
- [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
730
- [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_REVIEWER_LOOP,
731
- [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: ROUTING_OUTCOME.STAY_WITH_CURRENT_LIVE_OWNER,
732
- [OUTER_STATE.STOP_NEEDS_HUMAN]: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
733
- [OUTER_STATE.DONE_TERMINAL]: ROUTING_OUTCOME.DONE_TERMINAL,
734
- [OUTER_STATE.NEEDS_RECONCILE]: ROUTING_OUTCOME.NEEDS_RECONCILE,
735
- });
736
-
737
710
  export const OUTER_NEXT_ACTIONS = Object.freeze({
738
711
  [OUTER_STATE.CONTINUE_CURRENT_WAIT]: "Remain in outer wait and re-inspect after the bounded interval.",
739
712
  [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "Re-enter the Copilot loop.",
@@ -148,6 +148,30 @@ function isBlockedCiStatus(status) {
148
148
  return status === "failure";
149
149
  }
150
150
 
151
+ /**
152
+ * Single source of truth for whether the Copilot review round cap has been
153
+ * reached (issue #1126). `copilot-pr-handoff.mjs` enforces the cap by calling
154
+ * `interpretLoopState`, which uses this predicate internally; every other
155
+ * caller that needs the same "cap reached" boolean (gate coordination,
156
+ * detect-pr-gate-coordination-state) MUST call this function too rather than
157
+ * re-deriving `copilotReviewRoundCount >= maxCopilotRounds` locally, so the
158
+ * two never disagree at the cap boundary.
159
+ *
160
+ * `copilotReviewRoundCount` counts COMPLETED rounds, so `>=` means every
161
+ * permitted round has already happened. `maxCopilotRounds` of `null`/`0`/
162
+ * non-number means the cap does not apply (unlimited or disabled).
163
+ *
164
+ * @param {object} params
165
+ * @param {number} params.copilotReviewRoundCount
166
+ * @param {number|null} [params.maxCopilotRounds]
167
+ * @returns {boolean}
168
+ */
169
+ export function isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds }) {
170
+ return typeof maxCopilotRounds === "number" && maxCopilotRounds > 0
171
+ && typeof copilotReviewRoundCount === "number"
172
+ && copilotReviewRoundCount >= maxCopilotRounds;
173
+ }
174
+
151
175
  export function normalizeCiStatus(rollup) {
152
176
  return normalizeStatusCheckRollupContract(rollup).overallStatus;
153
177
  }
@@ -375,8 +399,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
375
399
  const maxRounds = refinementConfig?.maxCopilotRounds;
376
400
  const reviewInFlight = s.copilotReviewRequestStatus === "requested"
377
401
  || s.copilotReviewRequestStatus === "already-requested";
378
- if (typeof maxRounds === "number" && maxRounds > 0
379
- && s.copilotReviewRoundCount >= maxRounds
402
+ if (isCopilotRoundCapReached({ copilotReviewRoundCount: s.copilotReviewRoundCount, maxCopilotRounds: maxRounds })
380
403
  && state !== STATE.NO_PR && state !== STATE.DONE
381
404
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
382
405
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
@@ -24,6 +24,98 @@
24
24
  const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
25
25
  const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
26
 
27
+ /**
28
+ * Canonical fail-closed signal for when a child/agent cannot perform real
29
+ * parallel fan-out (e.g. the harness does not honor the subagent tool at child
30
+ * depth). The flow MUST fail closed with this message and route the gate review
31
+ * to the conductor rather than silently degrading to a single-agent inline
32
+ * review (which requireFanoutProvenance is designed to reject). Documented as a
33
+ * contract in docs/gate-review-sub-loop-contract.md.
34
+ */
35
+ export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
36
+
37
+ /**
38
+ * Build a fail-closed Error carrying the route-to-conductor contract signal.
39
+ * Callers throw this (or check `.routeToConductor === true`) when real fan-out
40
+ * cannot be performed. `detail` is appended for diagnostics but the stable,
41
+ * matchable prefix is always {@link FANOUT_UNAVAILABLE_MESSAGE}.
42
+ *
43
+ * @param {string} [detail] — optional diagnostic suffix (e.g. why fan-out failed)
44
+ * @returns {Error & { routeToConductor: true, code: "FANOUT_UNAVAILABLE" }}
45
+ */
46
+ export function fanoutUnavailableError(detail) {
47
+ const suffix = typeof detail === "string" && detail.trim().length > 0 ? ` (${detail.trim()})` : "";
48
+ const error = new Error(`${FANOUT_UNAVAILABLE_MESSAGE}${suffix}`);
49
+ return Object.assign(error, { routeToConductor: /** @type {const} */ (true), code: /** @type {const} */ ("FANOUT_UNAVAILABLE") });
50
+ }
51
+
52
+ /**
53
+ * Count DISTINCT reviewer identities actually recorded in a `perAngle` array.
54
+ * An entry contributes an identity via `reviewer` (preferred) or `dispatchId`;
55
+ * entries carrying neither are not countable reviewers (a bare `{angle}` proves
56
+ * nothing about who reviewed it). Pure.
57
+ *
58
+ * @param {unknown} perAngle
59
+ * @returns {number}
60
+ */
61
+ export function countDistinctReviewers(perAngle) {
62
+ if (!Array.isArray(perAngle)) return 0;
63
+ const ids = new Set();
64
+ for (const e of perAngle) {
65
+ if (!e || typeof e !== "object" || Array.isArray(e)) continue;
66
+ const id = typeof e.reviewer === "string" && e.reviewer.trim().length > 0
67
+ ? e.reviewer.trim()
68
+ : typeof e.dispatchId === "string" && e.dispatchId.trim().length > 0
69
+ ? e.dispatchId.trim()
70
+ : null;
71
+ if (id) ids.add(id);
72
+ }
73
+ return ids.size;
74
+ }
75
+
76
+ /**
77
+ * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
78
+ * string when the provenance is malformed or self-inconsistent, or null when it
79
+ * is well-formed and consistent. Shared by the write path (write-gate-findings-log)
80
+ * and the enforcement read path (buildPreMergeGateCheck) so both agree.
81
+ *
82
+ * Consistency rule (documented in docs/gate-review-sub-loop-contract.md):
83
+ * - `distinctReviewers` must be a non-negative integer.
84
+ * - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
85
+ * - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
86
+ * actually recorded in `perAngle` — you cannot claim more reviewers than you
87
+ * recorded dispatch entries for.
88
+ *
89
+ * HONEST CAVEAT: this makes recorded provenance internally consistent and raises
90
+ * the bar, but the provenance is self-reported (written by the same agent whose
91
+ * independence it claims), so it remains forgeable by a determined single agent.
92
+ * Un-forgeable recording is the Pi-harness bridge (subagent tool at child depth).
93
+ *
94
+ * @param {unknown} prov
95
+ * @returns {string|null}
96
+ */
97
+ export function provenanceConsistencyError(prov) {
98
+ if (!prov || typeof prov !== "object" || Array.isArray(prov)) {
99
+ return "provenance must be an object";
100
+ }
101
+ const p = /** @type {Record<string, unknown>} */ (prov);
102
+ if (!Number.isInteger(p.distinctReviewers) || /** @type {number} */ (p.distinctReviewers) < 0) {
103
+ return "provenance.distinctReviewers must be a non-negative integer";
104
+ }
105
+ if (!Array.isArray(p.perAngle)) {
106
+ return "provenance.perAngle must be an array";
107
+ }
108
+ const claimed = /** @type {number} */ (p.distinctReviewers);
109
+ if (claimed > 0 && p.perAngle.length === 0) {
110
+ return "provenance.perAngle must be non-empty when distinctReviewers > 0";
111
+ }
112
+ const recorded = countDistinctReviewers(p.perAngle);
113
+ if (claimed > recorded) {
114
+ return `provenance.distinctReviewers (${claimed}) exceeds distinct recorded reviewer identities (${recorded})`;
115
+ }
116
+ return null;
117
+ }
118
+
27
119
  /**
28
120
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
29
121
  * Mirrors the config default (gates.maxFanoutReviewers).