@dev-loops/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Deterministic state machine for the async Copilot review/fix loop.
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.
13
+ */
14
+
15
+ import { normalizeStatusCheckRollupContract } from "./copilot-ci-status.mjs";
16
+
17
+ /** Stable state name constants for the async Copilot review/fix loop. */
18
+ export const STATE = Object.freeze({
19
+ /** No open PR exists for the current work. */
20
+ NO_PR: "no_pr",
21
+ /** PR exists but is in draft state. */
22
+ PR_DRAFT: "pr_draft",
23
+ /** PR is ready-for-review; no Copilot review has been requested or received yet. */
24
+ PR_READY_NO_FEEDBACK: "pr_ready_no_feedback",
25
+ /** Copilot review was requested and is in requested_reviewers; waiting for review activity. */
26
+ WAITING_FOR_COPILOT_REVIEW: "waiting_for_copilot_review",
27
+ /** Unresolved review threads exist that require a fix and/or reply/resolve action. */
28
+ UNRESOLVED_FEEDBACK_PRESENT: "unresolved_feedback_present",
29
+ /**
30
+ * Agent has applied a fix; unresolved threads still exist on GitHub and need
31
+ * reply/resolve before another Copilot pass or re-request is appropriate.
32
+ */
33
+ ALREADY_FIXED_NEEDS_REPLY_RESOLVE: "already_fixed_needs_reply_resolve",
34
+ /**
35
+ * All threads are resolved; Copilot has reviewed at least once and is not
36
+ * currently requested. Ready to re-request a new Copilot pass or confirm done.
37
+ */
38
+ READY_TO_REREQUEST_REVIEW: "ready_to_rerequest_review",
39
+ /**
40
+ * Low-signal heuristic stopped the re-request loop. Round count exceeded
41
+ * threshold with only minimal actionable feedback per round.
42
+ */
43
+ LOW_SIGNAL_CONVERGED: "low_signal_converged",
44
+ /**
45
+ * Copilot review request returned `unavailable`. Must stop/report.
46
+ * Do not sleep or watch as if review were requested.
47
+ */
48
+ REVIEW_REQUEST_UNAVAILABLE: "review_request_unavailable",
49
+ /** CI checks are in progress or no usable CI readiness signal exists yet; wait before proceeding. */
50
+ WAITING_FOR_CI: "waiting_for_ci",
51
+ /**
52
+ * An unexpected failure occurred (bad review-request result, CI failure, etc.)
53
+ * that requires user decision before the loop can continue.
54
+ */
55
+ BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
56
+ /** PR has been merged or closed. Loop is complete. */
57
+ DONE: "done",
58
+ /** Round cap reached with unresolved threads or failing CI; explicit stop. */
59
+ ROUND_CAP_REACHED: "round_cap_reached",
60
+ /** Round cap reached with clean threads and green CI; eligible for pre_approval_gate fallback. */
61
+ ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
62
+ /** Internal tooling only PR; Copilot external review skipped, proceed directly to pre_approval_gate. */
63
+ INTERNAL_TOOLING_DIRECT_GATE: "internal_tooling_direct_gate",
64
+ });
65
+
66
+ /** Stable high-level loop dispositions for completion vs follow-up decisions. */
67
+ export const DISPOSITION = Object.freeze({
68
+ PENDING: "pending",
69
+ UNRESOLVED_FEEDBACK: "unresolved_feedback",
70
+ CLEAN_CONVERGED: "clean_converged",
71
+ BLOCKED: "blocked",
72
+ ACTION_REQUIRED: "action_required",
73
+ DONE: "done",
74
+ /** Internal tooling only: Copilot skipped, ready for pre_approval_gate. */
75
+ DIRECT_GATE: "direct_gate",
76
+ });
77
+
78
+ /**
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.
82
+ */
83
+ export const TRANSITIONS = Object.freeze({
84
+ [STATE.NO_PR]: [],
85
+ [STATE.PR_DRAFT]: [STATE.PR_READY_NO_FEEDBACK],
86
+ [STATE.PR_READY_NO_FEEDBACK]: [STATE.WAITING_FOR_COPILOT_REVIEW],
87
+ [STATE.WAITING_FOR_COPILOT_REVIEW]: [
88
+ STATE.UNRESOLVED_FEEDBACK_PRESENT,
89
+ STATE.READY_TO_REREQUEST_REVIEW,
90
+ STATE.WAITING_FOR_CI,
91
+ ],
92
+ [STATE.UNRESOLVED_FEEDBACK_PRESENT]: [
93
+ STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE,
94
+ STATE.UNRESOLVED_FEEDBACK_PRESENT,
95
+ ],
96
+ [STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE]: [
97
+ STATE.READY_TO_REREQUEST_REVIEW,
98
+ ],
99
+ [STATE.READY_TO_REREQUEST_REVIEW]: [
100
+ STATE.WAITING_FOR_COPILOT_REVIEW,
101
+ STATE.REVIEW_REQUEST_UNAVAILABLE,
102
+ STATE.DONE,
103
+ ],
104
+ [STATE.REVIEW_REQUEST_UNAVAILABLE]: [],
105
+ [STATE.WAITING_FOR_CI]: [
106
+ STATE.PR_READY_NO_FEEDBACK,
107
+ STATE.READY_TO_REREQUEST_REVIEW,
108
+ STATE.BLOCKED_NEEDS_USER_DECISION,
109
+ ],
110
+ [STATE.BLOCKED_NEEDS_USER_DECISION]: [],
111
+ [STATE.DONE]: [],
112
+ [STATE.LOW_SIGNAL_CONVERGED]: [],
113
+ [STATE.ROUND_CAP_REACHED]: [],
114
+ [STATE.ROUND_CAP_CLEAN_FALLBACK]: [],
115
+ [STATE.INTERNAL_TOOLING_DIRECT_GATE]: [STATE.DONE],
116
+ });
117
+
118
+ /** Recommended next action for each state. */
119
+ export const NEXT_ACTIONS = Object.freeze({
120
+ [STATE.NO_PR]: "Create a PR or hand work to Copilot",
121
+ [STATE.PR_DRAFT]: "Move the PR from draft to ready-for-review",
122
+ [STATE.PR_READY_NO_FEEDBACK]: "Request Copilot review via scripts/github/request-copilot-review.mjs",
123
+ [STATE.WAITING_FOR_COPILOT_REVIEW]: "Wait for Copilot review via scripts/github/probe-copilot-review.mjs",
124
+ [STATE.UNRESOLVED_FEEDBACK_PRESENT]: "Address unresolved review feedback, then reply to and resolve each thread on GitHub",
125
+ [STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE]: "Reply to and resolve addressed threads on GitHub via scripts/github/reply-resolve-review-thread.mjs before re-requesting review",
126
+ [STATE.READY_TO_REREQUEST_REVIEW]: "Re-request Copilot review via scripts/github/request-copilot-review.mjs only after smallest honest local validation is green and no known fixable CI-red state remains, or confirm the PR is done",
127
+ [STATE.REVIEW_REQUEST_UNAVAILABLE]: "Report that Copilot review is unavailable and stop; do not sleep or watch as if review were requested",
128
+ [STATE.WAITING_FOR_CI]: "Wait for CI checks to complete or become available",
129
+ [STATE.BLOCKED_NEEDS_USER_DECISION]: "Report the blocked state to the user and stop; do not proceed without explicit authorization",
130
+ [STATE.LOW_SIGNAL_CONVERGED]: "Low-signal heuristic stopped re-request loop: round count exceeded threshold with only minimal actionable feedback; treat as converged",
131
+ [STATE.ROUND_CAP_REACHED]: "Stop: Copilot review round limit reached with unresolved threads or failing CI; do not re-request review",
132
+ [STATE.ROUND_CAP_CLEAN_FALLBACK]: "Round cap reached with clean PR; continue to pre_approval_gate instead of re-requesting Copilot review",
133
+ [STATE.INTERNAL_TOOLING_DIRECT_GATE]: "Internal tooling PR — Copilot external review suppressed; run pre_approval_gate directly",
134
+ [STATE.DONE]: "Loop is complete; confirm merge-readiness or close",
135
+ });
136
+
137
+ const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
138
+
139
+ const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
140
+ const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
141
+ const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
142
+
143
+ function isWaitingCiStatus(status) {
144
+ return status === "pending" || status === "none";
145
+ }
146
+
147
+ function isBlockedCiStatus(status) {
148
+ return status === "failure";
149
+ }
150
+
151
+ export function normalizeCiStatus(rollup) {
152
+ return normalizeStatusCheckRollupContract(rollup).overallStatus;
153
+ }
154
+
155
+ export function buildSnapshotFromPrFacts({
156
+ prData,
157
+ prNumber,
158
+ copilotReviewRequestStatus = "none",
159
+ copilotReviewPresent = false,
160
+ copilotReviewOnCurrentHead = false,
161
+ unresolvedThreadCount = 0,
162
+ actionableThreadCount = 0,
163
+ copilotReviewRoundCount = 0,
164
+ ciStatus,
165
+ lastCopilotRoundMaxSignal = null,
166
+ failureDetails = [],
167
+ excludedFailureDetails = [],
168
+ }) {
169
+ const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
170
+ const prMerged = prState === "MERGED";
171
+ const prClosed = prState === "CLOSED";
172
+
173
+ return normalizeSnapshot({
174
+ prExists: true,
175
+ prNumber: typeof prData?.number === "number" ? prData.number : prNumber,
176
+ prDraft: Boolean(prData?.isDraft),
177
+ prMerged,
178
+ prClosed,
179
+ copilotReviewRequestStatus,
180
+ copilotReviewPresent,
181
+ copilotReviewOnCurrentHead,
182
+ unresolvedThreadCount,
183
+ actionableThreadCount,
184
+ copilotReviewRoundCount,
185
+ lastCopilotRoundMaxSignal,
186
+ ciStatus: ciStatus ?? normalizeCiStatus(prData?.statusCheckRollup),
187
+ failureDetails,
188
+ excludedFailureDetails,
189
+ });
190
+ }
191
+
192
+ function isAutoRerequestEligible(snapshot, state) {
193
+ if (state !== STATE.READY_TO_REREQUEST_REVIEW) return false;
194
+ // A fresh submitted Copilot review on the current head with no unresolved feedback
195
+ // is converged for that head on the automatic path. Auto re-request eligibility
196
+ // re-opens only when the head advances (i.e. review is no longer on current head).
197
+ return !snapshot.copilotReviewOnCurrentHead;
198
+ }
199
+
200
+ /**
201
+ * Normalize a raw snapshot object into a validated, canonical snapshot.
202
+ *
203
+ * Unknown or invalid field values are replaced with safe defaults.
204
+ * Throws if `raw` is not a non-null object.
205
+ *
206
+ * Snapshot schema:
207
+ * - prExists {boolean} — whether a PR was found
208
+ * - prNumber {number|null} — PR number if prExists, otherwise null
209
+ * - prDraft {boolean} — whether the PR is in draft state
210
+ * - prMerged {boolean} — whether the PR has been merged
211
+ * - prClosed {boolean} — whether the PR has been closed without merge
212
+ * - copilotReviewRequestStatus {"requested"|"already-requested"|"unavailable"|"none"|"failed"}
213
+ * — current known Copilot review-request state, or "none" if unknown
214
+ * - copilotReviewPresent {boolean} — whether at least one Copilot review exists on the PR
215
+ * - copilotReviewOnCurrentHead {boolean} — whether a submitted (non-PENDING) Copilot review
216
+ * exists for the current head commit; this alone does not prove the current-head
217
+ * review-request lifecycle is settled, so callers must still check request-state fields
218
+ * - unresolvedThreadCount {number} — total unresolved review-thread count
219
+ * - actionableThreadCount {number} — unresolved threads with non-bot actionable comments
220
+ * - copilotReviewRoundCount {number} — completed Copilot review rounds observed on the PR
221
+ * - ciStatus {"success"|"failure"|"pending"|"none"|"crediblyGreen"} — current CI check rollup status
222
+ * - lastCopilotRoundMaxSignal {"high"|"mid"|"low"|null} — highest signal level across Copilot-authored threads
223
+ * - agentFixStatus {"applied"|null} — agent-provided input: "applied" when code has been fixed
224
+ * - failureDetails {Array<string>} — names of failing visible check-runs from refreshed head-scoped CI evidence
225
+ * - excludedFailureDetails {Array<string>} — names of failing check-runs filtered out by PR-visibility intersection
226
+ *
227
+ * @param {object} raw - raw snapshot input
228
+ * @returns {object} normalized snapshot
229
+ */
230
+ const VALID_SIGNAL_LEVELS = new Set(["high", "mid", "low"]);
231
+
232
+ function hasExplicitCurrentHeadReviewSignal(raw) {
233
+ return Boolean(raw)
234
+ && typeof raw === "object"
235
+ && Object.prototype.hasOwnProperty.call(raw, "copilotReviewOnCurrentHead");
236
+ }
237
+
238
+ export function normalizeSnapshot(raw) {
239
+ if (!raw || typeof raw !== "object") {
240
+ throw new Error("Snapshot must be a non-null object");
241
+ }
242
+
243
+ const prExists = Boolean(raw.prExists);
244
+
245
+ const copilotReviewOnCurrentHead = Boolean(raw.copilotReviewOnCurrentHead);
246
+
247
+ return {
248
+ prExists,
249
+ prNumber: prExists && typeof raw.prNumber === "number" && raw.prNumber > 0
250
+ ? Math.floor(raw.prNumber)
251
+ : null,
252
+ prDraft: Boolean(raw.prDraft),
253
+ prMerged: Boolean(raw.prMerged),
254
+ prClosed: Boolean(raw.prClosed),
255
+ copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
256
+ ? raw.copilotReviewRequestStatus
257
+ : "none",
258
+ copilotReviewPresent: Boolean(raw.copilotReviewPresent) || copilotReviewOnCurrentHead,
259
+ copilotReviewOnCurrentHead,
260
+ unresolvedThreadCount: typeof raw.unresolvedThreadCount === "number" && raw.unresolvedThreadCount >= 0
261
+ ? Math.floor(raw.unresolvedThreadCount)
262
+ : 0,
263
+ actionableThreadCount: typeof raw.actionableThreadCount === "number" && raw.actionableThreadCount >= 0
264
+ ? Math.floor(raw.actionableThreadCount)
265
+ : 0,
266
+ copilotReviewRoundCount: typeof raw.copilotReviewRoundCount === "number" && raw.copilotReviewRoundCount >= 0
267
+ ? Math.floor(raw.copilotReviewRoundCount)
268
+ : 0,
269
+ ciStatus: VALID_CI_STATUSES.has(raw.ciStatus) ? raw.ciStatus : "none",
270
+ lastCopilotRoundMaxSignal: VALID_SIGNAL_LEVELS.has(raw.lastCopilotRoundMaxSignal) ? raw.lastCopilotRoundMaxSignal : null,
271
+ agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
272
+ failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
273
+ excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Return the post-request snapshot that should drive the next wait-cycle interpretation
279
+ * once a Copilot review request has been explicitly issued or confirmed.
280
+ *
281
+ * This keeps the handoff helper on the same shared state-machine contract instead of
282
+ * emitting a watch action that contradicts a same-head clean-convergence interpretation.
283
+ * A confirmed request starts a new wait cycle for the current head, so prior
284
+ * current-head clean-review convergence is cleared for handoff purposes while
285
+ * preserving whether a submitted Copilot review has ever been observed on the PR.
286
+ *
287
+ * @param {object} snapshot
288
+ * @param {string} reviewRequestStatus
289
+ * @returns {object}
290
+ */
291
+ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
292
+ const s = normalizeSnapshot(snapshot);
293
+
294
+ if (!ACTIVE_REQUEST_STATUSES.has(reviewRequestStatus)) {
295
+ return normalizeSnapshot({ ...s, copilotReviewRequestStatus: reviewRequestStatus });
296
+ }
297
+
298
+ return normalizeSnapshot({
299
+ ...s,
300
+ copilotReviewRequestStatus: reviewRequestStatus,
301
+ copilotReviewOnCurrentHead: false,
302
+ copilotReviewPresent: s.copilotReviewPresent,
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Interpret a loop-state snapshot into one current state, allowed next transitions,
308
+ * and a recommended next action.
309
+ *
310
+ * Interpretation is deterministic: the same snapshot always yields the same result.
311
+ * The function normalizes the snapshot before interpreting, so raw inputs are accepted.
312
+ *
313
+ * Key routing guarantees:
314
+ * - unresolvedThreadCount > 0 always routes into fix/reply-resolve flow, never into wait
315
+ * - "unavailable" or "failed" review-request status routes into stop/report states
316
+ * - agentFixStatus "applied" distinguishes fix-needed from already-fixed-needs-reply/resolve
317
+ * - Copilot review request still active (via requested_reviewers or a PENDING current-head Copilot review)
318
+ * routes into waiting_for_copilot_review until that request is conclusively settled for this head
319
+ *
320
+ * @param {object} snapshot - raw or normalized snapshot
321
+ * @param {object} [refinementConfig] - optional refinement config with low-signal heuristic fields
322
+ * @param {boolean} [refinementConfig.stopOnLowSignal]
323
+ * @param {number} [refinementConfig.lowSignalRoundThreshold]
324
+ * @param {number} [refinementConfig.lowSignalMaxComments]
325
+ * @param {number} [refinementConfig.maxCopilotRounds]
326
+ * @returns {{
327
+ * state: string,
328
+ * allowedTransitions: string[],
329
+ * nextAction: string,
330
+ * autoRerequestEligible: boolean,
331
+ * sameHeadCleanConverged: boolean,
332
+ * roundCapCleanEligible: boolean
333
+ * }}
334
+ */
335
+ export function interpretLoopState(snapshot, refinementConfig) {
336
+ const s = normalizeSnapshot(snapshot);
337
+
338
+ let state;
339
+
340
+ if (!s.prExists) {
341
+ state = STATE.NO_PR;
342
+ } else if (s.prMerged || s.prClosed) {
343
+ state = STATE.DONE;
344
+ } else if (s.prDraft) {
345
+ state = STATE.PR_DRAFT;
346
+ } else if (s.copilotReviewRequestStatus === "unavailable") {
347
+ state = STATE.REVIEW_REQUEST_UNAVAILABLE;
348
+ } else if (s.copilotReviewRequestStatus === "failed") {
349
+ state = STATE.BLOCKED_NEEDS_USER_DECISION;
350
+ }
351
+
352
+ // Round-cap enforcement: when maxCopilotRounds is configured and the review-round
353
+ // count has been exhausted, stop re-requests before entering fix/reply-resolve routing.
354
+ // Gating here (before unresolved-thread checks) ensures round cap takes priority over
355
+ // the normal fix loop, including unresolved threads, pending CI, and CI failures.
356
+ // Clean PRs are usually eligible for pre_approval_gate fallback; the only automatic
357
+ // exception is when the head has advanced since the last submitted Copilot review,
358
+ // all prior feedback is resolved, and CI is green/credibly green again. In that case
359
+ // the state re-opens to READY_TO_REREQUEST_REVIEW instead of terminating as clean fallback.
360
+ // Does NOT interrupt an in-flight review request (requested/already-requested).
361
+ const maxRounds = refinementConfig?.maxCopilotRounds;
362
+ const reviewInFlight = s.copilotReviewRequestStatus === "requested"
363
+ || s.copilotReviewRequestStatus === "already-requested";
364
+ if (typeof maxRounds === "number" && maxRounds > 0
365
+ && s.copilotReviewRoundCount >= maxRounds
366
+ && !reviewInFlight
367
+ && state !== STATE.NO_PR && state !== STATE.DONE
368
+ && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
369
+ && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
370
+ const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
371
+ const cleanThreads = s.unresolvedThreadCount === 0;
372
+ const headAdvancedSinceLastSubmittedCopilotReview = s.copilotReviewPresent
373
+ && hasExplicitCurrentHeadReviewSignal(snapshot)
374
+ && !s.copilotReviewOnCurrentHead;
375
+ if (cleanThreads && ciClean && headAdvancedSinceLastSubmittedCopilotReview) {
376
+ state = STATE.READY_TO_REREQUEST_REVIEW;
377
+ } else if (cleanThreads && ciClean) {
378
+ state = STATE.ROUND_CAP_CLEAN_FALLBACK;
379
+ } else {
380
+ state = STATE.ROUND_CAP_REACHED;
381
+ }
382
+ }
383
+
384
+ if (state === undefined) {
385
+ if (s.unresolvedThreadCount > 0 && s.agentFixStatus === "applied") {
386
+ // Agent has fixed the code; threads still need reply/resolve on GitHub
387
+ state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
388
+ } else if (s.unresolvedThreadCount > 0) {
389
+ // Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
390
+ state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
391
+ } else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
392
+ // A current-head Copilot request is still active/pending and must settle before gate progression.
393
+ state = STATE.WAITING_FOR_COPILOT_REVIEW;
394
+ } else if (s.copilotReviewPresent) {
395
+ // Copilot has reviewed at least once; all threads resolved
396
+ if (isBlockedCiStatus(s.ciStatus)) {
397
+ state = STATE.BLOCKED_NEEDS_USER_DECISION;
398
+ } else if (isWaitingCiStatus(s.ciStatus)) {
399
+ state = STATE.WAITING_FOR_CI;
400
+ } else {
401
+ state = STATE.READY_TO_REREQUEST_REVIEW;
402
+ }
403
+ } else {
404
+ // No Copilot review yet; not currently requested
405
+ if (isBlockedCiStatus(s.ciStatus)) {
406
+ state = STATE.BLOCKED_NEEDS_USER_DECISION;
407
+ } else if (isWaitingCiStatus(s.ciStatus)) {
408
+ state = STATE.WAITING_FOR_CI;
409
+ } else {
410
+ state = STATE.PR_READY_NO_FEEDBACK;
411
+ }
412
+ }
413
+ }
414
+
415
+
416
+ // Low-signal heuristic: when configured and last Copilot round signal
417
+ // classification is mid or low (not high), suppress re-request.
418
+ // Falls back to actionableThreadCount heuristic when signal data is null.
419
+ const lowSignalApplied =
420
+ refinementConfig?.stopOnLowSignal === true
421
+ && state === STATE.READY_TO_REREQUEST_REVIEW
422
+ && s.copilotReviewRoundCount > (refinementConfig.lowSignalRoundThreshold ?? 3)
423
+ && s.actionableThreadCount <= (refinementConfig.lowSignalMaxComments ?? 2)
424
+ && (
425
+ s.lastCopilotRoundMaxSignal === null
426
+ || s.lastCopilotRoundMaxSignal !== "high"
427
+ );
428
+
429
+ if (lowSignalApplied) {
430
+ state = STATE.LOW_SIGNAL_CONVERGED;
431
+ }
432
+
433
+ const autoRerequestEligible = isAutoRerequestEligible(s, state);
434
+ const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
435
+ && s.copilotReviewOnCurrentHead
436
+ && s.unresolvedThreadCount === 0
437
+ && s.actionableThreadCount === 0;
438
+
439
+ let nextAction = NEXT_ACTIONS[state];
440
+ if (sameHeadCleanConverged) {
441
+ nextAction = SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION;
442
+ }
443
+
444
+ const roundCapCleanEligible = state === STATE.ROUND_CAP_CLEAN_FALLBACK;
445
+
446
+ return {
447
+ state,
448
+ allowedTransitions: [...TRANSITIONS[state]],
449
+ nextAction,
450
+ autoRerequestEligible,
451
+ sameHeadCleanConverged,
452
+ roundCapCleanEligible,
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Classify a loop interpretation into a higher-level disposition and whether the
458
+ * loop is terminal/stoppable for this head.
459
+ *
460
+ * @param {object} snapshotOrInterpretation - raw snapshot, normalized snapshot, or interpretLoopState() output
461
+ * @returns {{ loopDisposition: string, terminal: boolean }}
462
+ */
463
+ export function summarizeLoopInterpretation(snapshotOrInterpretation, refinementConfig) {
464
+ const interpretation = Array.isArray(snapshotOrInterpretation?.allowedTransitions)
465
+ && typeof snapshotOrInterpretation?.state === "string"
466
+ && typeof snapshotOrInterpretation?.nextAction === "string"
467
+ ? snapshotOrInterpretation
468
+ : interpretLoopState(snapshotOrInterpretation, refinementConfig);
469
+
470
+ let loopDisposition;
471
+
472
+ switch (interpretation.state) {
473
+ case STATE.WAITING_FOR_COPILOT_REVIEW:
474
+ case STATE.WAITING_FOR_CI:
475
+ loopDisposition = DISPOSITION.PENDING;
476
+ break;
477
+ case STATE.UNRESOLVED_FEEDBACK_PRESENT:
478
+ case STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE:
479
+ loopDisposition = DISPOSITION.UNRESOLVED_FEEDBACK;
480
+ break;
481
+ case STATE.REVIEW_REQUEST_UNAVAILABLE:
482
+ case STATE.BLOCKED_NEEDS_USER_DECISION:
483
+ case STATE.ROUND_CAP_REACHED:
484
+ loopDisposition = DISPOSITION.BLOCKED;
485
+ break;
486
+ case STATE.INTERNAL_TOOLING_DIRECT_GATE:
487
+ loopDisposition = DISPOSITION.DIRECT_GATE;
488
+ break;
489
+ case STATE.LOW_SIGNAL_CONVERGED:
490
+ case STATE.ROUND_CAP_CLEAN_FALLBACK:
491
+ case STATE.DONE:
492
+ loopDisposition = DISPOSITION.DONE;
493
+ break;
494
+ case STATE.READY_TO_REREQUEST_REVIEW:
495
+ loopDisposition = interpretation.sameHeadCleanConverged
496
+ ? DISPOSITION.CLEAN_CONVERGED
497
+ : DISPOSITION.ACTION_REQUIRED;
498
+ break;
499
+ default:
500
+ loopDisposition = DISPOSITION.ACTION_REQUIRED;
501
+ break;
502
+ }
503
+
504
+ return {
505
+ loopDisposition,
506
+ terminal: loopDisposition === DISPOSITION.CLEAN_CONVERGED
507
+ || loopDisposition === DISPOSITION.BLOCKED
508
+ || loopDisposition === DISPOSITION.DONE,
509
+ };
510
+ }