@dev-loops/core 1.0.1 → 1.0.2-slim.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.
@@ -26,7 +26,7 @@ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
26
26
  /**
27
27
  * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
28
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).
29
+ * the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard.
30
30
  * ponytail: single balanced pair only — no full shell tokenization (mismatched/partial quotes stay).
31
31
  * @param {string|null} value @returns {string|null}
32
32
  */
@@ -43,7 +43,7 @@ function stripSurroundingQuotes(value) {
43
43
  * Read an inline `GH_REPO=<value>` env-assignment prefix on a single command segment.
44
44
  * `gh` resolves its target repo from the `GH_REPO` env var, and a segment may set it inline
45
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
46
+ * scope check must treat it the same or an off-cwd redirect evades the guard. Only the
47
47
  * FIRST leading env assignment matching `GH_REPO=` is read (env assignments precede the executable);
48
48
  * the value is quote-normalized. Ambient `process.env.GH_REPO` is out of scope — this is a static
49
49
  * command-string classifier, so only the inline assignment in the string is considered.
@@ -177,16 +177,15 @@ const GIT_GLOBAL_OPTION_RUN =
177
177
  "(?:(?:-C|-c)\\s+\\S+\\s+|--(?:git-dir|work-tree)=\\S+\\s+|--?[A-Za-z][\\w-]*\\s+)*";
178
178
 
179
179
  /**
180
- * Whether `command` contains a `git stash` invocation (any subcommand: bare, `push`, `pop`,
181
- * `apply`, `save`, `list`, ...) in ANY shell segment — including behind the same env-assignment /
182
- * `command`/`env`/`exec` wrapper / binary-path prefix (`GIT_DIR=.git git stash`, `command git
183
- * stash`, `/usr/bin/git stash`) and git global options between `git` and `stash` (`git -C /tmp
184
- * stash`, `git -c name=value stash pop`) that the sibling `gh` classifiers in this file already
185
- * tolerate. Anchored per-segment, so `git stashed`, `git commit -m "git stash"`, or a path literal
186
- * containing "git stash" never match. `refs/stash` is a single ref shared by every worktree over
187
- * this repo's one `.git` directory, so a stash from one worktree can pop into another's — the
188
- * PreToolUse gate blocks it outright on the target repo (see
189
- * `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
180
+ * Whether `command` contains a `git stash` invocation (any subcommand: bare,
181
+ * `push`, `pop`, `apply`, `save`, `list`, ...) in ANY shell segment —
182
+ * including behind an env-assignment/wrapper/path prefix and git global
183
+ * options between `git` and `stash` (mirrors the `gh` classifiers' tolerance
184
+ * in this file). Anchored per-segment, so `git stashed` or a path literal
185
+ * containing "git stash" never match. `refs/stash` is shared by every
186
+ * worktree over this repo's one `.git` directory, so a stash from one
187
+ * worktree can pop into another's — the PreToolUse gate blocks it outright
188
+ * (see `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
190
189
  * @param {string} command @returns {boolean}
191
190
  */
192
191
  export function commandContainsGitStash(command) {
@@ -237,7 +236,7 @@ function extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) {
237
236
  if (repoEqMatch) return stripSurroundingQuotes(repoEqMatch[1]);
238
237
  }
239
238
  // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
240
- // mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass (#1074).
239
+ // mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass.
241
240
  return extractGhRepoEnvAssignment(segment);
242
241
  }
243
242
 
@@ -379,7 +378,7 @@ function extractRepoFlagFromSegment(segment, verb) {
379
378
  }
380
379
  // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
381
380
  // mirroring gh's own precedence). Applied here too so gh pr ready/merge/create scope checks get
382
- // consistent GH_REPO handling — the root-cause fix, not just the external-write path (#1074).
381
+ // consistent GH_REPO handling — the root-cause fix, not just the external-write path.
383
382
  return extractGhRepoEnvAssignment(segment);
384
383
  }
385
384
 
@@ -498,21 +497,20 @@ export function extractRepoFlagFromGhPrMerge(command) {
498
497
  }
499
498
 
500
499
  // ---------------------------------------------------------------------------
501
- // gh api URL-path matchers + the six guard-rule classifiers (#1622).
500
+ // gh api URL-path matchers + the six guard-rule classifiers.
502
501
  // These make the six rules that describe operations the Bash gate could refuse
503
502
  // enforceable at one seam (decideBashGate in hook-decisions.mjs), where raw
504
503
  // `gh api` shapes were previously unclassified (anything expressed as a raw API
505
504
  // call was invisible to the gate).
506
505
  // ---------------------------------------------------------------------------
507
506
 
508
- /** gh api value-taking flags (short forms). Each consumes the following token. Lowercase (compared
509
- * against token.toLowerCase()) — covers every value-taking short flag gh api accepts so a flag
510
- * placed BEFORE the endpoint skips its value and the real endpoint is still read (#1622):
511
- * -X/--method, -m/--method, -f/--field, -F/--raw-field (both case-fold to -f), -q/--jq, -p/--preview,
512
- * -t/--template, -r/--repo. `-h` (help) is intentionally EXCLUDED: it is a boolean help flag that
513
- * consumes no value, and case-folding it together with `-H` (header) made a mid-command `-h`
514
- * swallow the real endpoint and bypass the write-path deny (#1622). `-H` is matched as an exact
515
- * token in the scanner so it stays a value-taking flag despite the case-fold. */
507
+ /** gh api value-taking flags (short forms); each consumes the following token. Lowercase-compared,
508
+ * covering every value-taking short flag gh api accepts so a flag placed BEFORE the endpoint skips
509
+ * its value and the real endpoint is still read: -X/--method, -m/--method, -f/--field, -F/--raw-field
510
+ * (both case-fold to -f), -q/--jq, -p/--preview, -t/--template, -r/--repo. `-h` (help) is EXCLUDED —
511
+ * it takes no value, and folding it with `-H` (header) would let a mid-command `-h` swallow the real
512
+ * endpoint and bypass the write-path deny. `-H` stays an exact-token value-taking flag
513
+ * despite the case-fold. */
516
514
  const GH_API_VALUE_FLAGS = new Set(["-x", "-m", "-f", "-r", "-q", "-p", "-t"]);
517
515
  /** gh api value-taking flags (long forms). Each consumes the following token. */
518
516
  const GH_API_VALUE_LONG_FLAGS = new Set([
@@ -588,7 +586,7 @@ function targetGhApiPathRegex(suffix) {
588
586
  /** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
589
587
  * yielding the bare `/repos/<slug>/…` path that the write-path anchors match. gh api accepts both a
590
588
  * bare `repos/<slug>/…`/`issues/…` path and an absolute https:// URL, so both must reach the same
591
- * anchors or an absolute-URL write bypasses the deny (#1622). */
589
+ * anchors or an absolute-URL write bypasses the deny. */
592
590
  function normalizeGhApiEndpoint(endpoint) {
593
591
  if (!endpoint) return endpoint;
594
592
  return endpoint.replace(/^https?:\/\/[^/]+/, "").replace(/^\//, "").replace(/\/+$/, "");
@@ -679,14 +677,10 @@ export function commandContainsCopilotRequestBypass(command) {
679
677
  */
680
678
  export function commandContainsCopilotSummonComment(command) {
681
679
  if (!findGhSubcmdVerbSegment(command, "pr", "comment")) return false;
682
- // A bare summon is `/copilot` or `/copilot re-review` on its own (optionally quoted)never a
683
- // prose mention like `see /copilot for more` / `see /copilot docs`. A bare `/copilot` must run to
684
- // the end of the (quoted) body; the explicit `re-review` form allows trailing modifiers
685
- // (`/copilot re-review now`) so appending a word cannot defeat the summon deny (#1622).
686
- // A summon is `/copilot`/`/copilot re-review` at the START of the quoted body — anchored on the
687
- // opening quote so a trailing prose mention (`--body "see /copilot"` / `"thanks /copilot"`) is
688
- // NOT misread as a bare summon, and an in-prose `/copilot re-review` (`"see ... re-review in
689
- // docs"`) is likewise not a summon. Only `gh pr comment` segments reach here (guard above).
680
+ // A summon is `/copilot`/`/copilot re-review` anchored at the START of the quoted body — a
681
+ // trailing prose mention (`--body "see /copilot"`) or in-prose `/copilot re-review` never
682
+ // matches. The `re-review` form allows trailing modifiers (`/copilot re-review now`) so
683
+ // appending a word cannot defeat the deny. Only `gh pr comment` segments reach here.
690
684
  return /(["'])\s*\/copilot(?:\s+re-review\b(?:\s+[^\s"']+)*|\s*(?:["']|$))/i.test(command);
691
685
  }
692
686
 
@@ -699,17 +693,10 @@ export function commandContainsCopilotSummonComment(command) {
699
693
  */
700
694
  export function commandContainsDetachedWaitTool(command) {
701
695
  const whole = command.trim();
702
- // while/until/seq polling loop with both a sleep and a gh or loop-state call. The loop body is
703
- // `;`-delimited, so this is checked against the whole command (a per-segment split would
704
- // separate the `while` head from the `sleep`/`gh` body calls and miss the pattern).
705
- // A polling loop is detected wherever the `while`/`until`/`seq` head appears (a leading expression
706
- // like `gh pr view 1 && while ...` must not silence the deny) as long as the body carries both a
707
- // `sleep` and a gh/loop-state call. `gh` must be a standalone token (followed by whitespace/end) —
708
- // a bare mention of `gh` inside another word (`grep gh-notes`) is not a GitHub call.
709
- // while/until/for loop heads (a bare `seq` sequence generator is not a loop head on its own —
710
- // `seq | while read` is caught by the `while` head), with `sleep` and a gh/loop-state *call*.
711
- // loop-state must sit at a command-head position (`; lo`, `&& lo`, start), not be a substring of
712
- // a grep/echo target (no false-deny on `grep loop-state x`).
696
+ // Checked on the WHOLE command (not per-segment): the `while`/`until`/`for` loop body is
697
+ // `;`-delimited, so a per-segment split would separate the loop head from its `sleep`/`gh`
698
+ // body calls and miss the pattern. `gh` must be a standalone token (not `grep gh-notes`), and
699
+ // `loop-state` must sit at a command-head position (not a substring inside `grep loop-state x`).
713
700
  if (/(?:while|until|for)\b/i.test(whole) && /\bsleep\b/.test(whole) && /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)) {
714
701
  return true;
715
702
  }
@@ -730,11 +717,9 @@ function interpreterRegex(bin) {
730
717
 
731
718
  /**
732
719
  * OPS-NO-INLINE-INTERPRETER: an inline interpreter — `node -e`/`--eval`/`-p`, `python3 -c`, or a
733
- * heredoc fed to node/python (`node - <<EOF`, `python3 - <<EOF`). Ported from the long-orphaned
734
- * inline-interpreter classifier in the retrospective-tooling check (zero production callers). Sanctioned
735
- * output parsing uses `--jq`/`--silent`, never an inline interpreter. Actor-independent: the rule bars
736
- * "Coordinator and agent flows" (both actors). Script-path invocations (running a `.mjs` file,
737
- * `python3 script.py`) never match.
720
+ * heredoc fed to node/python (`node - <<EOF`, `python3 - <<EOF`). Sanctioned output parsing uses
721
+ * `--jq`/`--silent`, never an inline interpreter. Actor-independent (bars both coordinator and
722
+ * agent flows). Script-path invocations (running a `.mjs` file, `python3 script.py`) never match.
738
723
  * @param {string} command @returns {boolean}
739
724
  */
740
725
  export function commandContainsInlineInterpreter(command) {
@@ -748,7 +733,7 @@ export function commandContainsInlineInterpreter(command) {
748
733
  const tokens = code.split(/\s+/).filter(Boolean);
749
734
  // Node value-taking flags (short + long) each consume the following token. Consuming them lets
750
735
  // a value-taking flag BEFORE the interpreter flag (`node --require ./setup.js -e "..."`) route
751
- // on to `-e`/`--eval`/`-p` instead of breaking the scan at the flag's value (#1622).
736
+ // on to `-e`/`--eval`/`-p` instead of breaking the scan at the flag's value.
752
737
  const NODE_VALUE_FLAGS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file", "--conditions", "-C", "--cwd"]);
753
738
  for (let i = 0; i < tokens.length; i++) {
754
739
  const t = tokens[i];
@@ -84,7 +84,7 @@ if (process.env.CLAUDECODE === "1") {
84
84
  errors.push("missing required trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com>");
85
85
  }
86
86
  if (!/^Claude-Session:\s*\S+/imu.test(message)) {
87
- errors.push("missing required trailer: Claude-Session: <url>");
87
+ errors.push("missing required trailer: Claude-Session: <url> (e.g. Claude-Session: https://claude.ai/code/session_abc123)");
88
88
  }
89
89
  }
90
90
 
@@ -1,30 +1,22 @@
1
1
  /**
2
- * Conductor routing contract: deterministic routing and handoff decisions
3
- * above family-local state machines.
4
- *
5
- * This module provides:
6
- * - ROUTING_OUTCOME: closed routing outcome taxonomy constants
7
- * - LOOP_FAMILY: loop family identifier constants
8
- * - SOURCE_MODE: confidence/source mode constants
9
- * - ENTRYPOINT: handoff entrypoint identifier constants
10
- * - STOP_REASON: stop reason code constants (for outer-loop backward compat)
11
- * - evaluateConductorRouting: shared evaluator/policy entrypoint
2
+ * Conductor routing contract: deterministic routing and handoff decisions above
3
+ * family-local state machines. See skills/docs/conductor-routing-contract.md.
12
4
  *
13
5
  * Contract guarantees:
14
- * - One deterministic routing outcome per normalized input set
6
+ * - One deterministic routing outcome per normalized input set.
15
7
  * - Ambiguous, conflicting, or insufficient inputs return `needs_reconcile`
16
- * rather than a guessed handoff
17
- * - The evaluator is purely functional; no I/O or side effects
18
- * - Callers use evaluateConductorRouting as the single routing authority
8
+ * rather than a guessed handoff.
9
+ * - The evaluator is purely functional; no I/O or side effects.
10
+ * - evaluateConductorRouting is the single routing authority.
19
11
  *
20
- * Integration boundary (see skills/docs/conductor-routing-contract.md):
21
- * - This module starts after active-run identity and ownership are already resolved
22
- * - It consumes already-detected family-local lifecycle states as inputs
23
- * - It derives the routing outcome directly from states; it does not take a
24
- * pre-computed outer-loop action as an input
25
- * - It emits routing decisions and handoff envelopes; it does not perform handoff
26
- * - Ownership/idempotency rules remain in conductor-ownership.mjs (#32)
27
- * - Family-local state machine semantics remain in copilot-loop-state.mjs etc. (#26)
12
+ * Integration boundary:
13
+ * - Starts after active-run identity and ownership are already resolved; it
14
+ * consumes already-detected family-local lifecycle states as inputs.
15
+ * - Derives the routing outcome directly from states; it does not take a
16
+ * pre-computed outer-loop action as an input.
17
+ * - Emits routing decisions and handoff envelopes; it does not perform handoff.
18
+ * - Ownership/idempotency rules remain in conductor-ownership.mjs; family-local
19
+ * state machine semantics remain in copilot-loop-state.mjs etc.
28
20
  */
29
21
 
30
22
  // ---------------------------------------------------------------------------
@@ -580,7 +572,7 @@ function routeFromStates({
580
572
  * @param {{ repo: string, pr: number }} input.target
581
573
  * Explicit target identity (already resolved by the caller).
582
574
  * @param {string} [input.ownershipState]
583
- * Settled ownership/idempotency classification from conductor-ownership (#32).
575
+ * Settled ownership/idempotency classification from conductor-ownership.
584
576
  * "live_owner" → stay_with_current_live_owner (no new handoff this cycle).
585
577
  * "duplicate_local_owners" → needs_reconcile.
586
578
  * Other values or omission → routing continues from states.
@@ -1,15 +1,9 @@
1
1
  /**
2
2
  * Deterministic state machine for the async Copilot review/fix loop.
3
3
  *
4
- * This module provides:
5
- * - STATE: stable state name constants
6
- * - TRANSITIONS: legal next-state graph for each state
7
- * - normalizeSnapshot: validate and canonicalize a raw loop-state snapshot
8
- * - interpretLoopState: map a snapshot to one current state + allowed transitions + next action
9
- *
10
- * The state machine owns workflow control.
11
- * Agent judgment (accept/defer a comment, confirm a fix, decide on another Copilot pass)
12
- * becomes an explicit bounded input (agentFixStatus) rather than hidden orchestration behavior.
4
+ * The state machine owns workflow control. Agent judgment (accept/defer a
5
+ * comment, confirm a fix, decide on another Copilot pass) enters as an explicit
6
+ * bounded input (agentFixStatus), never as hidden orchestration behavior.
13
7
  */
14
8
 
15
9
  import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
@@ -76,9 +70,8 @@ export const DISPOSITION = Object.freeze({
76
70
  });
77
71
 
78
72
  /**
79
- * Legal transitions for each state.
80
- * Each entry lists the states that are reachable from the given state.
81
- * The agent layer selects among allowed transitions; the state machine enforces the graph.
73
+ * Legal transitions for each state. The agent layer selects among allowed
74
+ * transitions; the state machine enforces the graph.
82
75
  */
83
76
  export const TRANSITIONS = Object.freeze({
84
77
  [STATE.NO_PR]: [],
@@ -168,12 +161,9 @@ function isBlockedCiStatus(status) {
168
161
 
169
162
  /**
170
163
  * Single source of truth for whether the Copilot review round cap has been
171
- * reached (issue #1126). `copilot-pr-handoff.mjs` enforces the cap by calling
172
- * `interpretLoopState`, which uses this predicate internally; every other
173
- * caller that needs the same "cap reached" boolean (gate coordination,
174
- * detect-pr-gate-coordination-state) MUST call this function too rather than
175
- * re-deriving `copilotReviewRoundCount >= maxCopilotRounds` locally, so the
176
- * two never disagree at the cap boundary.
164
+ * reached. Every caller that needs the "cap reached" boolean MUST call this
165
+ * rather than re-derive `copilotReviewRoundCount >= maxCopilotRounds`, so no two
166
+ * callers disagree at the cap boundary.
177
167
  *
178
168
  * `copilotReviewRoundCount` counts COMPLETED rounds, so `>=` means every
179
169
  * permitted round has already happened. `maxCopilotRounds` of `null`/`0`/
@@ -211,9 +201,9 @@ export function buildSnapshotFromPrFacts({
211
201
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
212
202
  const prMerged = prState === "MERGED";
213
203
  const prClosed = prState === "CLOSED";
214
- // Default derivation excludes the loop's own gate-evidence check (#1358) so a
215
- // caller that never threads an explicit ciStatus (e.g. gate-coordination
216
- // detection) still never treats it as a blocking CI failure.
204
+ // Default derivation excludes the loop's own gate-evidence check so a caller
205
+ // that never threads an explicit ciStatus (e.g. gate-coordination detection)
206
+ // still never treats it as a blocking CI failure.
217
207
  const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
218
208
 
219
209
  return normalizeSnapshot({
@@ -245,30 +235,17 @@ function isAutoRerequestEligible(snapshot, state) {
245
235
 
246
236
  /**
247
237
  * Normalize a raw snapshot object into a validated, canonical snapshot.
238
+ * Unknown or invalid field values are replaced with safe defaults. Throws if
239
+ * `raw` is not a non-null object.
248
240
  *
249
- * Unknown or invalid field values are replaced with safe defaults.
250
- * Throws if `raw` is not a non-null object.
251
- *
252
- * Snapshot schema:
253
- * - prExists {boolean} whether a PR was found
254
- * - prNumber {number|null} PR number if prExists, otherwise null
255
- * - prDraft {boolean} whether the PR is in draft state
256
- * - prMerged {boolean} whether the PR has been merged
257
- * - prClosed {boolean} — whether the PR has been closed without merge
258
- * - copilotReviewRequestStatus {"requested"|"already-requested"|"unavailable"|"none"|"failed"}
259
- * — current known Copilot review-request state, or "none" if unknown
260
- * - copilotReviewPresent {boolean} — whether at least one Copilot review exists on the PR
261
- * - copilotReviewOnCurrentHead {boolean} — whether a submitted (non-PENDING) Copilot review
262
- * exists for the current head commit; this alone does not prove the current-head
263
- * review-request lifecycle is settled, so callers must still check request-state fields
264
- * - unresolvedThreadCount {number} — total unresolved review-thread count
265
- * - actionableThreadCount {number} — unresolved threads with non-bot actionable comments
266
- * - copilotReviewRoundCount {number} — completed Copilot review rounds observed on the PR
267
- * - ciStatus {"success"|"failure"|"pending"|"none"|"crediblyGreen"} — current CI check rollup status
268
- * - lastCopilotRoundMaxSignal {"high"|"mid"|"low"|null} — highest signal level across Copilot-authored threads
269
- * - agentFixStatus {"applied"|null} — agent-provided input: "applied" when code has been fixed
270
- * - failureDetails {Array<string>} — names of failing visible check-runs from refreshed head-scoped CI evidence
271
- * - excludedFailureDetails {Array<string>} — names of failing check-runs filtered out by PR-visibility intersection
241
+ * Non-obvious field semantics:
242
+ * - copilotReviewOnCurrentHead: a submitted (non-PENDING) Copilot review exists
243
+ * for the current head. This alone does NOT prove the current-head
244
+ * review-request lifecycle is settled; callers must still check request-state.
245
+ * - copilotReviewRoundCount: COMPLETED Copilot review rounds observed on the PR.
246
+ * - agentFixStatus: agent-provided input; "applied" when code has been fixed.
247
+ * - failureDetails vs excludedFailureDetails: visible failing check-runs vs
248
+ * check-runs filtered out by the PR-visibility intersection.
272
249
  *
273
250
  * @param {object} raw - raw snapshot input
274
251
  * @returns {object} normalized snapshot
@@ -315,14 +292,12 @@ export function normalizeSnapshot(raw) {
315
292
  }
316
293
 
317
294
  /**
318
- * Return the post-request snapshot that should drive the next wait-cycle interpretation
319
- * once a Copilot review request has been explicitly issued or confirmed.
295
+ * Return the post-request snapshot that drives the next wait-cycle
296
+ * interpretation once a Copilot review request is issued or confirmed.
320
297
  *
321
- * This keeps the handoff helper on the same shared state-machine contract instead of
322
- * emitting a watch action that contradicts a same-head clean-convergence interpretation.
323
- * A confirmed request starts a new wait cycle for the current head, so prior
324
- * current-head clean-review convergence is cleared for handoff purposes while
325
- * preserving whether a submitted Copilot review has ever been observed on the PR.
298
+ * A confirmed active request starts a new wait cycle for the current head, so
299
+ * prior current-head clean-review convergence is cleared while whether a
300
+ * submitted Copilot review was ever observed on the PR is preserved.
326
301
  *
327
302
  * @param {object} snapshot
328
303
  * @param {string} reviewRequestStatus
@@ -363,7 +338,7 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
363
338
  * @param {number} [refinementConfig.lowSignalRoundThreshold]
364
339
  * @param {number} [refinementConfig.lowSignalMaxComments]
365
340
  * @param {number} [refinementConfig.maxCopilotRounds]
366
- * @param {boolean} [refinementConfig.preApprovalRequireCi] - #1337: default true. When false,
341
+ * @param {boolean} [refinementConfig.preApprovalRequireCi] - default true. When false,
367
342
  * the pre-approval CI precondition is opted out, so a non-draft PR with a pending/none/failure
368
343
  * CI verdict is not routed to waiting_for_ci / blocked_needs_user_decision (it is past the draft gate).
369
344
  * @returns {{
@@ -378,13 +353,11 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
378
353
  export function interpretLoopState(snapshot, refinementConfig) {
379
354
  const s = normalizeSnapshot(snapshot);
380
355
 
381
- // Pre-approval CI opt-out (#1337): when `gates.preApproval.requireCi` is false,
382
- // the CI verdict must not gate progression at the pre-approval boundary. A
383
- // non-draft PR is past the draft gate, so this is the applicable knob — treat
384
- // pending/none/failure CI as non-blocking here so a repo with no CI is not
385
- // routed to WAITING_FOR_CI / BLOCKED_NEEDS_USER_DECISION before the downstream
386
- // gate-coordination guards (which already honor this flag) are ever reached.
387
- // Default true preserves current behavior for every caller that does not thread it.
356
+ // Pre-approval CI opt-out: when `gates.preApproval.requireCi` is false, the CI
357
+ // verdict must not gate progression at the pre-approval boundary. A non-draft
358
+ // PR is past the draft gate, so treat pending/none/failure CI as non-blocking
359
+ // here (a repo with no CI is not routed to WAITING_FOR_CI /
360
+ // BLOCKED_NEEDS_USER_DECISION). Default true preserves prior behavior.
388
361
  const preApprovalRequireCi = refinementConfig?.preApprovalRequireCi !== false;
389
362
  const ciBlocks = preApprovalRequireCi && isBlockedCiStatus(s.ciStatus);
390
363
  const ciWaits = preApprovalRequireCi && isWaitingCiStatus(s.ciStatus);
@@ -403,35 +376,19 @@ export function interpretLoopState(snapshot, refinementConfig) {
403
376
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
404
377
  }
405
378
 
406
- // Round-cap enforcement: when maxCopilotRounds is configured and the review-round
407
- // count has been exhausted, stop re-requests before entering fix/reply-resolve routing.
408
- // Gating here (before unresolved-thread checks) lets a CLEAN PR at the cap terminate as
409
- // ROUND_CAP_CLEAN_FALLBACK ahead of the normal fix/wait routing. It does NOT blanket-
410
- // override that routing: a NOT-clean PR (unresolved threads or non-green CI) with an
411
- // in-flight request deliberately falls through to the normal fix/wait routing below
412
- // (see the `!reviewInFlight` branch), and only a not-clean PR with no in-flight request
413
- // hard-stops at ROUND_CAP_REACHED.
414
- //
415
- // Precedence at the cap: copilotReviewRoundCount counts COMPLETED rounds, so at
416
- // `>= maxRounds` every permitted Copilot round is already done and any lingering
417
- // in-flight request (requested/already-requested) is for a forbidden over-cap round.
418
- // A stale Copilot reviewer assignment must therefore NOT block the clean fallback:
419
- // when threads are clean and CI is green, route to ROUND_CAP_CLEAN_FALLBACK even if
420
- // copilotReviewRequestStatus is requested/already-requested. Otherwise a lingering
421
- // assignment would dead-end the loop at WAITING_FOR_COPILOT_REVIEW waiting for a
422
- // review that can never come (no further round is permitted past the cap). The
423
- // pre_approval_gate (current-head clean evidence, enforced elsewhere) reviews any
424
- // post-cap head change, so this proceeds without skipping review of new code.
425
- //
426
- // An in-flight request only still blocks the cap block when the PR is NOT clean
427
- // (unresolved threads or non-green CI) — that legitimately stays in the fix/wait
428
- // routing below rather than terminating as a clean fallback.
429
- //
430
- // Head-advanced handling: even when the head has advanced past the last submitted
431
- // Copilot review with clean threads and green CI, re-requesting another Copilot pass
432
- // is forbidden at the cap, so this routes to ROUND_CAP_CLEAN_FALLBACK (not
433
- // READY_TO_REREQUEST_REVIEW, which would trigger an illegal auto re-request). The
434
- // pre_approval_gate handles the current head.
379
+ // Round-cap enforcement, gated before the fix/reply-resolve routing below.
380
+ // copilotReviewRoundCount counts COMPLETED rounds, so at `>= maxRounds` every
381
+ // permitted round is done and any lingering in-flight request is for a
382
+ // forbidden over-cap round. Precedence at the cap:
383
+ // - clean PR (clean threads + green CI): ROUND_CAP_CLEAN_FALLBACK, even with
384
+ // a lingering in-flight request or an advanced head no further round is
385
+ // permitted, so never re-open for re-request or wait on Copilot. Re-opening
386
+ // would dead-end at WAITING_FOR_COPILOT_REVIEW on a review that can never
387
+ // come. The pre_approval_gate (enforced elsewhere) reviews post-cap head
388
+ // changes, so this skips no review of new code.
389
+ // - not clean, no in-flight request: hard stop at ROUND_CAP_REACHED.
390
+ // - not clean WITH an in-flight request: fall through to the normal
391
+ // fix/reply-resolve/wait routing below (no forced clean fallback).
435
392
  const maxRounds = refinementConfig?.maxCopilotRounds;
436
393
  const reviewInFlight = s.copilotReviewRequestStatus === "requested"
437
394
  || s.copilotReviewRequestStatus === "already-requested";
@@ -442,16 +399,11 @@ export function interpretLoopState(snapshot, refinementConfig) {
442
399
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
443
400
  const cleanThreads = s.unresolvedThreadCount === 0;
444
401
  if (cleanThreads && ciClean) {
445
- // Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
446
- // lingering Copilot reviewer assignment or an advanced head — no further Copilot
447
- // round is permitted, so never re-open for re-request or wait on Copilot here.
448
402
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
449
403
  } else if (!reviewInFlight) {
450
- // Not clean and no in-flight request: hard stop at the cap.
451
404
  state = STATE.ROUND_CAP_REACHED;
452
405
  }
453
- // Not clean WITH an in-flight request: leave state undecided so the normal
454
- // fix/reply-resolve/wait routing below handles it (do not force a clean fallback).
406
+ // Not clean WITH an in-flight request: leave state undecided for the routing below.
455
407
  }
456
408
 
457
409
  if (state === undefined) {
@@ -1,22 +1,30 @@
1
1
  /**
2
- * Gate carry-forward: a pure, fail-closed seam that decides whether a clean gate
3
- * angle verdict recorded at head A may be CARRIED FORWARD to head B without
4
- * re-running that angle's reviewer.
2
+ * Gate carry-forward: a pure, fail-closed seam that decides whether an angle's
3
+ * verdict recorded at head A (clean OR findings-present) may be CARRIED FORWARD
4
+ * to head B without re-running that angle's reviewer.
5
5
  *
6
6
  * Motivation: fresh-context-per-head re-fans ALL gate angles on every head bump,
7
7
  * even when the delta between the two heads provably cannot affect most angles
8
8
  * (e.g. a doc-only follow-up commit cannot change what a code-correctness angle
9
- * would find). Carry-forward lets the gate reuse the prior clean verdict for such
10
- * angles — but ONLY when it is provably safe.
9
+ * would find). Carry-forward lets the gate reuse the prior verdict for such
10
+ * angles — but ONLY when it is provably safe. This holds for a findings-present
11
+ * prior verdict too: a fixer push that never touches an angle's
12
+ * surface must not force that angle's OPEN findings to be re-litigated from
13
+ * scratch — the caller carries the prior findings forward unchanged, still
14
+ * open, still blocking. Carry-forward NEVER converts a finding into an
15
+ * approval; it only ever skips re-running a reviewer whose surface the delta
16
+ * provably did not touch.
11
17
  *
12
- * FAIL-CLOSED is paramount. An angle carries forward ONLY when EVERY changed file
13
- * in the delta A..B is provably OUTSIDE that angle's declared review surface. The
14
- * default in every uncertain case (non-clean prior verdict, empty/unavailable
15
- * delta, an unclassifiable file, an angle with no declared surface, a mandatory /
16
- * always-run angle) is MUST-RE-RUN. Carry-forward never fabricates a verdict: the
17
- * caller records the carried verdict with provenance pointing at the PRIOR head's
18
- * reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
19
- * did not touch), clearly marked as carried — see
18
+ * FAIL-CLOSED is paramount. An angle carries forward ONLY when its prior verdict
19
+ * is carry-forward-eligible (clean or findings_present) AND EVERY changed file in
20
+ * the delta A..B is provably OUTSIDE that angle's declared review surface. The
21
+ * default in every uncertain case (an ineligible prior verdict e.g. "blocked"
22
+ * or missing, empty/unavailable delta, an unclassifiable file, an angle with no
23
+ * declared surface, a mandatory / always-run angle) is MUST-RE-RUN. Carry-forward
24
+ * never fabricates a verdict: the caller records the carried verdict (and, for a
25
+ * findings-present carry, the carried findings) with provenance pointing at the
26
+ * PRIOR head's reviewer (that reviewer genuinely reviewed this angle's surface,
27
+ * which the delta did not touch), clearly marked as carried — see
20
28
  * skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
21
29
  * `carriedFromHead` provenance field.
22
30
  *
@@ -57,7 +65,7 @@ import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifie
57
65
  * @type {Record<string, string[]>}
58
66
  */
59
67
  const KIND_TO_CATEGORIES = {
60
- // #1442: a docs file is PROSE_PRESENT when it lands on the prose surface, so
68
+ // A docs file is PROSE_PRESENT when it lands on the prose surface, so
61
69
  // deslop's carry-forward surface is the `docs` kind (a non-docs delta never
62
70
  // re-runs a clean deslop verdict).
63
71
  docs: ["DOCS_ONLY", "PROSE_PRESENT"],
@@ -153,10 +161,16 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
153
161
  /**
154
162
  * Pure, deterministic, FAIL-CLOSED carry-forward decision for a single angle.
155
163
  *
156
- * Given a prior CLEAN verdict recorded at head A, the changed files of the delta
157
- * A..B, and the angle's declared review surface, decide whether the clean verdict
158
- * may be carried forward to head B (carryForward: true) or the angle MUST re-run
159
- * (carryForward: false). Defaults to must-re-run in every uncertain case.
164
+ * Given a prior carry-forward-eligible verdict recorded at head A (clean OR
165
+ * findings_present), the changed files of the delta A..B, and the angle's
166
+ * declared review surface, decide whether that verdict (and, for a
167
+ * findings-present angle, its open findings) may be carried forward to head B
168
+ * (carryForward: true) or the angle MUST re-run (carryForward: false).
169
+ * Defaults to must-re-run in every uncertain case. This function never
170
+ * inspects or mutates findings content — it only decides whether the delta
171
+ * proves the angle's surface untouched; the caller is responsible for
172
+ * carrying the actual prior findings through unchanged (never converting an
173
+ * open finding into an approval) when it honors `carryForward: true`.
160
174
  *
161
175
  * @param {object} input
162
176
  * @param {string} input.angle
@@ -164,11 +178,18 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
164
178
  * derived from {@link angleReviewSurface} when omitted.
165
179
  * @param {string[]} input.changedFiles — repo-relative paths changed between head
166
180
  * A and head B (the delta, NOT the full PR diff against base).
167
- * @param {string} input.prevVerdict — the angle's verdict at head A. Only "clean"
168
- * is carry-forward-eligible.
181
+ * @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
182
+ * "findings_present" are carry-forward-eligible; anything else (e.g.
183
+ * "blocked", missing) is not.
169
184
  * @returns {{ carryForward: boolean, reason: string }}
170
185
  */
171
186
 
187
+ // The only per-angle prior verdicts eligible to carry forward — matches the
188
+ // two verdict values gate-fanin's VALID_VERDICTS actually produces per angle
189
+ // (packages/core/src/loop/gate-fanin.mjs). Any other value (e.g. "blocked",
190
+ // undefined, a typo) fails closed to must-re-run.
191
+ const CARRY_FORWARD_ELIGIBLE_VERDICTS = new Set(["clean", "findings_present"]);
192
+
172
193
  // A path whose change rewrites the dev-loop review system itself — the angle
173
194
  // pool, mandatory floor, and reviewer personas/prompts — rather than a
174
195
  // reviewed surface. A clean verdict produced under the OLD config cannot
@@ -187,8 +208,11 @@ export function isDevLoopConfigSourcePath(filePath) {
187
208
  }
188
209
 
189
210
  export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
190
- if (prevVerdict !== "clean") {
191
- return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
211
+ if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
212
+ return {
213
+ carryForward: false,
214
+ reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not carry-forward-eligible (clean or findings_present)`,
215
+ };
192
216
  }
193
217
  const surface = angleSurface ?? angleReviewSurface(angle);
194
218
  if (surface.kind === "always") {