@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.
- package/package.json +5 -2
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +167 -50
- package/src/config/config.mjs +388 -787
- package/src/config/extension-defaults.yaml +14 -9
- package/src/github/comment-id-guard.mjs +39 -1
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/github/gh.mjs +49 -0
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +46 -22
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +266 -435
- package/src/loop/handoff-envelope.mjs +21 -21
- package/src/loop/issue-refinement-artifact.mjs +449 -284
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +49 -49
- package/src/loop/queue-board-sync.mjs +16 -82
- package/src/loop/review-dispatch-plan.mjs +61 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +729 -0
- package/src/loop/steering.mjs +16 -68
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +16 -175
- package/src/projects/move-queue-item.mjs +16 -171
- package/src/projects/projects-access.mjs +202 -0
- package/src/security/secret-scan.mjs +13 -1
|
@@ -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
|
|
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
|
|
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,
|
|
181
|
-
* `apply`, `save`, `list`, ...) in ANY shell segment —
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
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
|
|
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
|
|
683
|
-
// prose mention
|
|
684
|
-
//
|
|
685
|
-
//
|
|
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
|
-
//
|
|
703
|
-
// `;`-delimited, so
|
|
704
|
-
//
|
|
705
|
-
//
|
|
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`).
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
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
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
* pre-computed outer-loop action as an input
|
|
25
|
-
* -
|
|
26
|
-
* - Ownership/idempotency rules remain in conductor-ownership.mjs
|
|
27
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* -
|
|
254
|
-
* -
|
|
255
|
-
* -
|
|
256
|
-
*
|
|
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
|
|
319
|
-
* once a Copilot review request
|
|
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
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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] -
|
|
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
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// in-flight request
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// in-flight request
|
|
418
|
-
//
|
|
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
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
//
|
|
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
|
|
157
|
-
* A..B, and the angle's
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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.
|
|
168
|
-
*
|
|
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
|
|
191
|
-
return {
|
|
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") {
|