@dev-loops/core 1.0.0-rc.6 → 1.0.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 (44) hide show
  1. package/package.json +8 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/commit-msg-guard.mjs +168 -0
  14. package/src/loop/copilot-loop-iterations.mjs +2 -1
  15. package/src/loop/default-branch-guard.mjs +34 -1
  16. package/src/loop/gate-carry-forward.mjs +19 -6
  17. package/src/loop/gate-fanin.mjs +190 -29
  18. package/src/loop/handoff-envelope.mjs +12 -19
  19. package/src/loop/issue-refinement-artifact.mjs +186 -42
  20. package/src/loop/lifecycle-state.mjs +21 -2
  21. package/src/loop/main-checkout-ff.mjs +34 -0
  22. package/src/loop/markdown-sections.mjs +40 -0
  23. package/src/loop/normalize.mjs +7 -0
  24. package/src/loop/plan-file-promote-contract.mjs +14 -1
  25. package/src/loop/plan-file-refine-contract.mjs +92 -8
  26. package/src/loop/policy-constants.mjs +9 -0
  27. package/src/loop/pr-gate-coordination.mjs +65 -12
  28. package/src/loop/public-dev-loop-routing.mjs +11 -15
  29. package/src/loop/queue-board-sync.mjs +1 -26
  30. package/src/loop/queue-driver.mjs +14 -1
  31. package/src/loop/refinement-grill-state.mjs +3 -5
  32. package/src/loop/retrospective-checkpoint.mjs +59 -1
  33. package/src/loop/review-dispatch-plan.mjs +448 -9
  34. package/src/loop/reviewer-loop-state.mjs +8 -13
  35. package/src/loop/run-post-merge-actions.mjs +148 -0
  36. package/src/loop/size-budget-merge-gate.mjs +121 -0
  37. package/src/loop/tracker-pr-state.mjs +5 -15
  38. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  39. package/src/loop/ui-review-drive.mjs +3 -1
  40. package/src/loop/ui-review-report.mjs +2 -5
  41. package/src/loop/ui-review-teardown.mjs +3 -1
  42. package/src/projects/list-queue-items.mjs +1 -27
  43. package/src/projects/move-queue-item.mjs +2 -28
  44. package/src/security/secret-scan.mjs +330 -0
@@ -27,6 +27,7 @@ import {
27
27
  PLAN_FILE_INTAKE_STATE,
28
28
  PLAN_FILE_REFINEMENT_SECTIONS,
29
29
  } from "./plan-file-intake-contract.mjs";
30
+ import { buildSectionHeadingPattern, extractSection } from "./markdown-sections.mjs";
30
31
 
31
32
  /** The local human-review checkpoint surface the loop stops at on success. */
32
33
  export const PLAN_FILE_REFINE_STOP = Object.freeze({
@@ -40,14 +41,79 @@ export const DOCS_GRILL_FINDINGS_HEADING = "Docs-grill findings";
40
41
  /** The heading the refiner coverage matrix lives under in the plan file. */
41
42
  export const COVERAGE_MATRIX_HEADING = "Coverage matrix";
42
43
 
44
+ /** The heading the per-phase size estimate lives under in the plan file. */
45
+ export const SIZE_ESTIMATE_HEADING = "Size estimate";
46
+
47
+ /** Size-budget tiers this contract accepts — the same enum check-size-budget.mjs's
48
+ * `resolveFileTier` resolves a changed file into (Phase 1 of #1480). */
49
+ const VALID_SIZE_TIERS = new Set(["default", "t1", "t3"]);
50
+
51
+ // Fallback default-tier soft-LOC threshold, used only when the caller omits
52
+ // `sizeSoftLoc`. Mirrors check-size-budget.mjs's exported `DEFAULT_TIER_DEFAULTS.softLoc`
53
+ // value exactly (400) — duplicated as a literal rather than imported because this module
54
+ // is published as @dev-loops/core and must not import a scripts/ CLI module (same
55
+ // import-boundary rule the docs-grill classifier note above documents). A repo-configured
56
+ // `gates.size.tiers.default.softLoc` should always be threaded through by the caller
57
+ // instead of relying on this fallback.
58
+ const DEFAULT_SIZE_SOFT_LOC = 400;
59
+
60
+ /**
61
+ * Parse a refiner-authored size-estimate payload into the rendered section body,
62
+ * validating it against the SAME vocabulary/thresholds check-size-budget.mjs's
63
+ * `computeSizeBudget` uses for the actual post-hoc diff measurement: `logicLoc`,
64
+ * tier (`default`|`t1`|`t3`), and the default tier's `softLoc` escalation threshold.
65
+ *
66
+ * This is the plan-time counterpart of that post-hoc computation (see the issue's
67
+ * "Refinement budget (plan time)" design intent): an over-`softLoc` estimate must
68
+ * carry an explicit, non-empty `oversizeJustification` — the refiner looked for a
69
+ * seam to split the phase and, finding none, records why the phase is cohesive.
70
+ * A missing justification on an over-budget estimate fails closed rather than
71
+ * silently accepting an unexplained oversize phase.
72
+ *
73
+ * @param {object} sizeEstimate
74
+ * @param {number} sizeEstimate.logicLoc estimated logic LOC for the phase (non-negative integer)
75
+ * @param {string} [sizeEstimate.tier] "default" (implicit) | "t1" | "t3"
76
+ * @param {string} [sizeEstimate.oversizeJustification] required non-empty text when logicLoc exceeds softLoc
77
+ * @param {number} [softLoc] default-tier soft-LOC escalation threshold (falls back to DEFAULT_SIZE_SOFT_LOC)
78
+ * @returns {{ ok: boolean, reason?: string, logicLoc?: number, tier?: string, softLoc?: number, overBudget?: boolean, oversizeNote?: string|null, body?: string }}
79
+ */
80
+ export function validatePhaseSizeEstimate(sizeEstimate, softLoc = DEFAULT_SIZE_SOFT_LOC) {
81
+ if (!sizeEstimate || typeof sizeEstimate !== "object") {
82
+ return { ok: false, reason: "missing_size_estimate" };
83
+ }
84
+ const { logicLoc, tier = "default", oversizeJustification } = sizeEstimate;
85
+ if (!Number.isInteger(logicLoc) || logicLoc < 0) {
86
+ return { ok: false, reason: "invalid_size_estimate_loc" };
87
+ }
88
+ if (!VALID_SIZE_TIERS.has(tier)) {
89
+ return { ok: false, reason: "invalid_size_estimate_tier" };
90
+ }
91
+ const effectiveSoftLoc = typeof softLoc === "number" && softLoc > 0 ? softLoc : DEFAULT_SIZE_SOFT_LOC;
92
+ const overBudget = logicLoc > effectiveSoftLoc;
93
+ const justification = typeof oversizeJustification === "string" ? oversizeJustification.trim() : "";
94
+ if (overBudget && justification.length === 0) {
95
+ // Fail closed: this is the refiner's prompt to look for a seam and split the
96
+ // phase before proceeding; a cohesive phase must say so explicitly instead.
97
+ return { ok: false, reason: "size_estimate_oversize_not_justified" };
98
+ }
99
+ const oversizeNote = overBudget ? justification : null;
100
+ const body = [
101
+ `- Estimated logic LOC: ${logicLoc}`,
102
+ `- Tier: ${tier}`,
103
+ overBudget
104
+ ? `- Oversize: justified — ${oversizeNote}`
105
+ : `- Oversize: n/a (within default tier's softLoc budget of ${effectiveSoftLoc})`,
106
+ ].join("\n");
107
+ return { ok: true, logicLoc, tier, softLoc: effectiveSoftLoc, overBudget, oversizeNote, body };
108
+ }
109
+
43
110
  /**
44
111
  * Remove an existing `## <heading>` section (heading + body up to the next H2)
45
112
  * from markdown so a refine re-run replaces it rather than appending a duplicate.
46
113
  * Returns the markdown unchanged when the heading is absent.
47
114
  */
48
115
  function stripSection(markdownText, headingText) {
49
- const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
50
- const headingPattern = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
116
+ const headingPattern = buildSectionHeadingPattern(headingText);
51
117
  const match = headingPattern.exec(markdownText);
52
118
  if (!match || match.index === undefined) return markdownText;
53
119
  const start = match.index;
@@ -65,12 +131,10 @@ function stripSection(markdownText, headingText) {
65
131
  * Whether markdown carries a `## <heading>` section marker. Used to re-derive
66
132
  * the section-presence facts from the freshly-written text so the end-state
67
133
  * check verifies the append actually happened (rather than re-asserting the
68
- * inputs). ponytail: local copy of the section-detect regex; the shared section
69
- * helper belongs in core once extractSection is lifted out of scripts/.
134
+ * inputs).
70
135
  */
71
136
  function hasSection(markdownText, headingText) {
72
- const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
73
- return new RegExp(`^##\\s+${escaped}\\s*$`, "imu").test(markdownText);
137
+ return extractSection(markdownText, headingText) !== null;
74
138
  }
75
139
 
76
140
  /** Append a `## <heading>` section with the given body to markdown. */
@@ -114,13 +178,16 @@ function renderGrillFindings(classified) {
114
178
  * @param {string} params.payload.acceptanceCriteria Acceptance criteria section body
115
179
  * @param {string} params.payload.definitionOfDone Definition of done section body
116
180
  * @param {string} params.payload.coverageMatrix AC/DoD/Non-goal coverage matrix (markdown table)
181
+ * @param {object} params.payload.sizeEstimate per-phase size estimate (see `validatePhaseSizeEstimate`); an over-`sizeSoftLoc` estimate must carry a non-empty `oversizeJustification` or the refine fails closed, prompting a seam search
117
182
  * @param {object[]} [params.payload.grillDispositions] docs-grill dispositions the caller pre-classified via #948's `classifyDocsGrillFinding`; each entry is `{ kind, summary, disposition }` and a null/invalid `disposition` fails the grill closed
183
+ * @param {number} [params.sizeSoftLoc] default-tier soft-LOC escalation threshold the size estimate is checked against — the caller threads this from `gates.size.tiers.default.softLoc` (falls back to check-size-budget.mjs's own default when config carries none)
118
184
  * @returns {{
119
185
  * ok: boolean,
120
186
  * reason?: string,
121
187
  * planFileIntakeState?: string,
122
188
  * refinedMarkdown?: string,
123
189
  * grillDispositions?: object[],
190
+ * sizeEstimate?: { logicLoc: number, tier: string, softLoc: number, overBudget: boolean, oversizeNote: string|null },
124
191
  * stop?: { kind: string },
125
192
  * }}
126
193
  */
@@ -130,6 +197,7 @@ export function refinePlanFileInPlace({
130
197
  hasAcceptanceCriteria,
131
198
  hasDefinitionOfDone,
132
199
  payload,
200
+ sizeSoftLoc,
133
201
  } = {}) {
134
202
  if (typeof markdownText !== "string" || markdownText.length === 0) {
135
203
  return { ok: false, reason: "missing_plan_markdown" };
@@ -166,6 +234,14 @@ export function refinePlanFileInPlace({
166
234
  return { ok: false, reason: "missing_coverage_matrix", planFileIntakeState: startState };
167
235
  }
168
236
 
237
+ // Plan-time size estimate (phase 4 of #1480): the same fail-closed check as an
238
+ // over-budget PR, run at plan time instead of on a real diff. `validatePhaseSizeEstimate`
239
+ // owns the vocabulary/threshold and the oversize-without-justification fail-closed reason.
240
+ const sizeEstimateResult = validatePhaseSizeEstimate(payload.sizeEstimate, sizeSoftLoc);
241
+ if (!sizeEstimateResult.ok) {
242
+ return { ok: false, reason: sizeEstimateResult.reason, planFileIntakeState: startState };
243
+ }
244
+
169
245
  // The docs-grill runs as a step of refinement. The caller (the CLI, which owns
170
246
  // I/O and the scripts/ boundary) classifies each finding with #948's
171
247
  // `classifyDocsGrillFinding` and passes the dispositions in. This core module
@@ -185,7 +261,7 @@ export function refinePlanFileInPlace({
185
261
  // would break the strip-then-append idempotency on a re-run (the inner heading and
186
262
  // its text would orphan into the document body). Fail closed on such a payload.
187
263
  const grillBody = renderGrillFindings(grillDispositions);
188
- if ([acceptanceCriteria, definitionOfDone, coverageMatrix, grillBody].some((b) => /^##\s/mu.test(String(b)))) {
264
+ if ([acceptanceCriteria, definitionOfDone, coverageMatrix, sizeEstimateResult.body, grillBody].some((b) => /^##\s/mu.test(String(b)))) {
189
265
  return { ok: false, reason: "section_body_contains_heading", planFileIntakeState: startState };
190
266
  }
191
267
 
@@ -194,11 +270,12 @@ export function refinePlanFileInPlace({
194
270
  // sections in a stable order.
195
271
  const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
196
272
  let refinedMarkdown = markdownText;
197
- for (const heading of [acHeading, dodHeading, COVERAGE_MATRIX_HEADING, DOCS_GRILL_FINDINGS_HEADING]) {
273
+ for (const heading of [acHeading, dodHeading, SIZE_ESTIMATE_HEADING, COVERAGE_MATRIX_HEADING, DOCS_GRILL_FINDINGS_HEADING]) {
198
274
  refinedMarkdown = stripSection(refinedMarkdown, heading);
199
275
  }
200
276
  refinedMarkdown = appendSection(refinedMarkdown, acHeading, acceptanceCriteria);
201
277
  refinedMarkdown = appendSection(refinedMarkdown, dodHeading, definitionOfDone);
278
+ refinedMarkdown = appendSection(refinedMarkdown, SIZE_ESTIMATE_HEADING, sizeEstimateResult.body);
202
279
  refinedMarkdown = appendSection(refinedMarkdown, COVERAGE_MATRIX_HEADING, coverageMatrix);
203
280
  refinedMarkdown = appendSection(refinedMarkdown, DOCS_GRILL_FINDINGS_HEADING, grillBody);
204
281
 
@@ -221,6 +298,13 @@ export function refinePlanFileInPlace({
221
298
  planFileIntakeState: endState,
222
299
  refinedMarkdown,
223
300
  grillDispositions,
301
+ sizeEstimate: {
302
+ logicLoc: sizeEstimateResult.logicLoc,
303
+ tier: sizeEstimateResult.tier,
304
+ softLoc: sizeEstimateResult.softLoc,
305
+ overBudget: sizeEstimateResult.overBudget,
306
+ oversizeNote: sizeEstimateResult.oversizeNote,
307
+ },
224
308
  // Generalized proposal-first stop: the refined plan is the local artifact,
225
309
  // it is written in-place, and the loop stops here for human review before
226
310
  // any promotion. No tracker artifact is created or mutated.
@@ -12,3 +12,12 @@ export const COPILOT_FIRST_DURABLE_WAIT_TIMEOUT_MS = 3_600_000;
12
12
 
13
13
  /** Copilot review wait: external healthy-wait budget */
14
14
  export const COPILOT_REVIEW_WAIT_TIMEOUT_MS = 1_800_000;
15
+
16
+ /** Gate review verdict vocabulary shared by tracker/public-routing normalizers. */
17
+ export const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
18
+
19
+ /** Normalize a raw gate review verdict to a member of GATE_REVIEW_VERDICT_SET, or null. */
20
+ export function normalizeGateReviewVerdict(value) {
21
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
22
+ return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
23
+ }
@@ -1,6 +1,8 @@
1
1
  import { DISPOSITION, isCopilotRoundCapReached, STATE } from "./copilot-loop-state.mjs";
2
2
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
3
3
  import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
4
+ import { evaluateUiDesignerReviewScoping } from "./ui-designer-review-scoping.mjs";
5
+ import { trimmedOrNull } from "./normalize.mjs";
4
6
 
5
7
  export const PR_CHECKPOINT = Object.freeze({
6
8
  DRAFT_REVIEW: "draft_review",
@@ -8,6 +10,7 @@ export const PR_CHECKPOINT = Object.freeze({
8
10
  FEEDBACK_RESOLUTION: "feedback_resolution",
9
11
  CONFLICT_RESOLUTION: "conflict_resolution",
10
12
  UI_E2E_SCOPING: "ui_e2e_scoping",
13
+ DESIGNER_REVIEW_SCOPING: "designer_review_scoping",
11
14
  PRE_APPROVAL_GATE_WINDOW: "pre_approval_gate_window",
12
15
  FINAL_APPROVAL_READY: "final_approval_ready",
13
16
  PRE_APPROVAL_GATE_NEEDED: "pre_approval_gate_needed",
@@ -61,6 +64,7 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
61
64
  REPORT_BLOCKED: "report_blocked",
62
65
  REPORT_DONE: "report_done",
63
66
  RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
67
+ RECORD_DESIGNER_REVIEW: "record_designer_review",
64
68
  });
65
69
 
66
70
  function normalizeGateComment(summary = null) {
@@ -77,14 +81,10 @@ function normalizeGateComment(summary = null) {
77
81
 
78
82
  return {
79
83
  visible: summary.visible === true,
80
- headSha: typeof summary.headSha === "string" && summary.headSha.trim().length > 0 ? summary.headSha.trim() : null,
84
+ headSha: trimmedOrNull(summary.headSha),
81
85
  verdict: typeof summary.verdict === "string" && summary.verdict.trim().length > 0 ? summary.verdict.trim().toLowerCase() : null,
82
- findingsSummary: typeof summary.findingsSummary === "string" && summary.findingsSummary.trim().length > 0
83
- ? summary.findingsSummary.trim()
84
- : null,
85
- nextAction: typeof summary.nextAction === "string" && summary.nextAction.trim().length > 0
86
- ? summary.nextAction.trim()
87
- : null,
86
+ findingsSummary: trimmedOrNull(summary.findingsSummary),
87
+ nextAction: trimmedOrNull(summary.nextAction),
88
88
  contractComplete: summary.contractComplete === true,
89
89
  };
90
90
  }
@@ -676,9 +676,7 @@ export function evaluatePrGateCoordination(input = {}) {
676
676
  }
677
677
 
678
678
  function evaluatePrGateCoordinationCore(input = {}) {
679
- const currentHeadSha = typeof input.currentHeadSha === "string" && input.currentHeadSha.trim().length > 0
680
- ? input.currentHeadSha.trim()
681
- : null;
679
+ const currentHeadSha = trimmedOrNull(input.currentHeadSha);
682
680
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
683
681
  const loopDisposition = typeof input.loopDisposition === "string" ? input.loopDisposition.trim().toLowerCase() : null;
684
682
  const prDraft = input.prDraft === true;
@@ -730,6 +728,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
730
728
  // e2e suite passed for this head. Inclusion is path-triggered, never annotated.
731
729
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
732
730
  const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
731
+ // Designer/vision recorded-evidence scoping (#1443, ADR 0041 UI half). See
732
+ // the designer-review scoping block below. Evidence reuses the loop's
733
+ // existing outcome + artifact-bundle record; exempt when a light/spike
734
+ // relaxed-gate carve-out applies.
735
+ const designerReviewEvidence = input.designerReviewEvidence ?? null;
736
+ const designerReviewExempt = input.designerReviewExempt === true;
733
737
  const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
734
738
  ? input.refinementArtifact
735
739
  : null;
@@ -918,6 +922,48 @@ function evaluatePrGateCoordinationCore(input = {}) {
918
922
  });
919
923
  }
920
924
 
925
+ // Designer/vision recorded-evidence precondition (#1443, ADR 0041 UI half).
926
+ // Path-triggered + fail-closed, modeled on the UI e2e scoping block above: if
927
+ // the PR's changed files touch a rendered artifact (docs/articles|presentations
928
+ // HTML), it MUST carry recorded designer/vision review evidence (the loop's
929
+ // existing outcome + artifact bundle) and the recorded outcome MUST be
930
+ // `ui_review_satisfied`. A rendered-artifact change with missing or unsatisfied
931
+ // recorded evidence blocks here, naming the artifact. Light/spike relaxed-gate
932
+ // carve-outs exempt the requirement. Non-UI changes pass through untouched.
933
+ const uiDesignerScoping = evaluateUiDesignerReviewScoping(changedFiles, {
934
+ designerReviewEvidence,
935
+ designerReviewExempt,
936
+ });
937
+ if (uiDesignerScoping.required && !uiDesignerScoping.satisfied) {
938
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RECORD_DESIGNER_REVIEW]);
939
+ pushUnique(forbiddenActions, [
940
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
941
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
942
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
943
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
944
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
945
+ ]);
946
+ return buildResult({
947
+ repo: input.repo ?? null,
948
+ pr: Number.isInteger(input.pr) ? input.pr : null,
949
+ currentHeadSha,
950
+ lifecycleState: effectiveLifecycleState,
951
+ loopDisposition: DISPOSITION.ACTION_REQUIRED,
952
+ gateBoundary: PR_CHECKPOINT.DESIGNER_REVIEW_SCOPING,
953
+ draftGateAlreadySatisfied,
954
+ draftGate,
955
+ preApprovalGate,
956
+ allowedNextActions,
957
+ forbiddenActions,
958
+ nextAction: PR_CHECKPOINT_ACTION.RECORD_DESIGNER_REVIEW,
959
+ reason: uiDesignerScoping.reason,
960
+ mergeStateStatus,
961
+ conflictFiles,
962
+ refinementArtifact,
963
+ copilotReviewRoundCount,
964
+ });
965
+ }
966
+
921
967
  if (prDraft || effectiveLifecycleState === STATE.PR_DRAFT) {
922
968
  if (refinementArtifactStatus === REFINEMENT_ARTIFACT_STATUS.MISSING) {
923
969
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -1313,7 +1359,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
1313
1359
  ? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi })
1314
1360
  : null;
1315
1361
 
1316
- if (!sameHeadCleanConverged && !postConvergenceReviewSuppressed && (!roundCapReached || roundCapNewCycleRequired)) {
1362
+ // reviewMode "internal_only" (which also folds in maxCopilotRounds:0 via
1363
+ // copilotReviewDisabled) suppresses Copilot: the handoff routes such a PR
1364
+ // straight to pre_approval_gate, so this branch must not force a re-request
1365
+ // for a missing Copilot convergence point that will never exist. The
1366
+ // sibling PR_READY_NO_FEEDBACK branch already honors internal_only; this
1367
+ // reconciles READY_TO_REREQUEST_REVIEW with it (issue 1771). Non-suppressed
1368
+ // external-review PRs keep reviewMode null and hit the guard unchanged.
1369
+ if (!sameHeadCleanConverged && !postConvergenceReviewSuppressed && reviewMode !== "internal_only" && (!roundCapReached || roundCapNewCycleRequired)) {
1317
1370
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
1318
1371
  pushUnique(forbiddenActions, postDraftForbidden);
1319
1372
  return buildResult({
@@ -1331,7 +1384,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1331
1384
  nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
1332
1385
  reason: roundCapNewCycleRequired
1333
1386
  ? "The previous Copilot cycle converged at the round cap, but significant post-convergence changes landed on a newer head; start a new Copilot review cycle and re-request review before `pre_approval_gate`."
1334
- : "The review loop is between passes, but the current head does not yet have a clean settled Copilot convergence point, so `pre_approval_gate` is still forbidden.",
1387
+ : "The review loop is between passes, but the current head does not yet have a clean settled Copilot convergence point, so `pre_approval_gate` is still forbidden. If a Copilot round just landed at this head, it may not be visible to the evaluator yet; re-check after a short propagation wait before treating this as a needed re-request.",
1335
1388
  mergeStateStatus,
1336
1389
  conflictFiles,
1337
1390
  refinementArtifact,
@@ -2,12 +2,16 @@ import {
2
2
  evaluateRetrospectiveGate,
3
3
  normalizeRetrospectiveCheckpointState,
4
4
  normalizeCheckpointCycleIdentity,
5
+ normalizeRetroProvenance,
5
6
  resolveCheckpointStateFromArtifact,
7
+ RETROSPECTIVE_PROVENANCE,
6
8
  } from "./retrospective-checkpoint.mjs";
7
9
  import {
8
10
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
9
11
  PERSISTENT_INTERNAL_WAIT_TIMEOUT_POLICY,
10
12
  } from "./timeout-policy.mjs";
13
+ import { trimmedOrNull } from "./normalize.mjs";
14
+ import { normalizeGateReviewVerdict } from "./policy-constants.mjs";
11
15
  import {
12
16
  DEV_LOOP_ACTOR,
13
17
  DEV_LOOP_ARTIFACT_STATE,
@@ -41,7 +45,9 @@ export * from "./public-dev-loop-routing-contract.mjs";
41
45
  // package export (see skills/docs/retrospective-checkpoint-contract.md).
42
46
  export {
43
47
  normalizeCheckpointCycleIdentity,
48
+ normalizeRetroProvenance,
44
49
  resolveCheckpointStateFromArtifact,
50
+ RETROSPECTIVE_PROVENANCE,
45
51
  };
46
52
 
47
53
  const COPILOT_ISSUE_ASSIGNEE = "copilot-swe-agent";
@@ -57,7 +63,6 @@ const ISSUE_READINESS_SET = new Set(Object.values(DEV_LOOP_ISSUE_READINESS));
57
63
  const ISSUE_ASSIGNMENT_STATE_SET = new Set(Object.values(DEV_LOOP_ISSUE_ASSIGNMENT_STATE));
58
64
  const VARIATION_MODE_SET = new Set(DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedModeValues);
59
65
  const TARGET_PREFERENCE_SET = new Set(DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedTargetPreferenceValues);
60
- const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
61
66
  const ALLOWED_MODE_VALUES_TEXT = DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedModeValues.join(", ");
62
67
  const ALLOWED_TARGET_PREFERENCE_VALUES_TEXT = DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedTargetPreferenceValues.join(", ");
63
68
  const LINKED_PR_READY_FOR_FOLLOWUP_LOOP_STATE = "linked_pr_ready_for_followup";
@@ -83,8 +88,8 @@ function normalizeTarget(target) {
83
88
  const pr = Number.isInteger(target.pr) && target.pr > 0 ? target.pr : null;
84
89
  const hasLinkedPr = Object.hasOwn(target, "linkedPr") && target.linkedPr !== null && target.linkedPr !== undefined;
85
90
  const linkedPr = Number.isInteger(target.linkedPr) && target.linkedPr > 0 ? target.linkedPr : null;
86
- const branch = typeof target.branch === "string" && target.branch.trim().length > 0 ? target.branch.trim() : null;
87
- const phase = typeof target.phase === "string" && target.phase.trim().length > 0 ? target.phase.trim() : null;
91
+ const branch = trimmedOrNull(target.branch);
92
+ const phase = trimmedOrNull(target.phase);
88
93
 
89
94
  if (kind === DEV_LOOP_TARGET_KIND.ISSUE && issue === null) {
90
95
  return null;
@@ -116,15 +121,6 @@ function normalizeActor(value) {
116
121
  return ACTOR_SET.has(normalized) ? normalized : null;
117
122
  }
118
123
 
119
- function normalizeSha(value) {
120
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
121
- }
122
-
123
- function normalizeGateReviewVerdict(value) {
124
- const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
125
- return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
126
- }
127
-
128
124
  function normalizeGateReviewEvidence(evidence) {
129
125
  if (evidence === undefined || evidence === null) {
130
126
  return null;
@@ -139,10 +135,10 @@ function normalizeGateReviewEvidence(evidence) {
139
135
  }
140
136
 
141
137
  return {
142
- currentHeadSha: normalizeSha(evidence.currentHeadSha),
138
+ currentHeadSha: trimmedOrNull(evidence.currentHeadSha),
143
139
  preApprovalGate: {
144
140
  visible: preApprovalGate.visible === true,
145
- headSha: normalizeSha(preApprovalGate.headSha),
141
+ headSha: trimmedOrNull(preApprovalGate.headSha),
146
142
  verdict: normalizeGateReviewVerdict(preApprovalGate.verdict),
147
143
  },
148
144
  };
@@ -197,7 +193,7 @@ function normalizeOptionalLoopState(value) {
197
193
  }
198
194
 
199
195
  function normalizeAsyncRunId(value) {
200
- const asString = normalizeSha(value);
196
+ const asString = trimmedOrNull(value);
201
197
  if (asString !== null) return asString;
202
198
  return null;
203
199
  }
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { runChild as coreRunChild } from "../cli/primitives.mjs";
5
4
  import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
5
+ import { ghGraphql } from "../github/gh.mjs";
6
6
 
7
7
  const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
8
8
 
@@ -315,31 +315,6 @@ const LIST_ORG_PROJECTS = [
315
315
  "}"
316
316
  ].join("\n");
317
317
 
318
- async function ghGraphql(query, vars, env, runChild) {
319
- const child = runChild ?? coreRunChild;
320
- const fieldArgs = [];
321
- for (const [key, value] of Object.entries(vars)) {
322
- fieldArgs.push("--field", `${key}=${value}`);
323
- }
324
- const result = await child(
325
- "gh",
326
- ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
327
- env,
328
- );
329
- if (result.code !== 0) {
330
- const detail = result.stderr.trim() || `exit code ${result.code}`;
331
- throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
332
- }
333
- const payload = JSON.parse(result.stdout);
334
- if (payload.errors && payload.errors.length > 0) {
335
- throw Object.assign(
336
- new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
337
- { code: "GRAPHQL_ERROR" },
338
- );
339
- }
340
- return payload;
341
- }
342
-
343
318
  async function resolveOwner(login, env, runChild) {
344
319
  const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
345
320
  if (userPayload?.data?.user?.id) {
@@ -27,6 +27,7 @@ import {
27
27
  REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
28
28
  EMPTY_NEXT_UP_MESSAGE,
29
29
  } from "./queue-board-ordering.mjs";
30
+ import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
30
31
 
31
32
  export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
32
33
  mergeAuthorized: false,
@@ -237,7 +238,19 @@ export async function runQueue(repoRoot, repo, options = {}) {
237
238
  if (entryResult.pr) {
238
239
  await doTransition(entry, "waiting_review", queue, repoRoot, opts, { pr: entryResult.pr });
239
240
  await doTransition(entry, "gates_passing", queue, repoRoot, opts);
240
- if (opts.mergeAuthorized) {
241
+ // Size-budget merge gate (phase 3 of the fail-closed PR size budget): consulted IN
242
+ // ADDITION TO opts.mergeAuthorized, never in its place. Opt-in per
243
+ // entry — only engaged when the orchestrator's entryResult carries
244
+ // a `sizeBudget` object (the size-budget-aware evaluation actually
245
+ // ran for this PR); an orchestrator that has not been updated to
246
+ // evaluate the size budget sees unchanged behavior. When engaged,
247
+ // an escalated/T1 PR without a human APPROVED review (zero
248
+ // unresolved CHANGES_REQUESTED) is never auto-merged, even under a
249
+ // standing mergeAuthorized authorization — it routes to the same
250
+ // "final_approval_ready" column an unauthorized merge would.
251
+ const sizeBudgetBlocksMerge = entryResult.sizeBudget
252
+ && resolveSizeBudgetHumanApprovalRequired(entryResult.sizeBudget) === true;
253
+ if (opts.mergeAuthorized && !sizeBudgetBlocksMerge) {
241
254
  await doTransition(entry, "merging", queue, repoRoot, opts);
242
255
  await doTransition(entry, "done", queue, repoRoot, opts, { retrospectiveWritten: true });
243
256
  await syncColumn(entry.target, columnFor("done"));
@@ -19,6 +19,8 @@
19
19
  * rather than fabricating an answer to force convergence.
20
20
  */
21
21
 
22
+ import { trimmedOrNull } from "./normalize.mjs";
23
+
22
24
  export const GRILL_STATE = Object.freeze({
23
25
  LOAD_TARGET: "load_target",
24
26
  DETECT_GAPS: "detect_gaps",
@@ -85,10 +87,6 @@ function normalizeCount(value) {
85
87
  : 0;
86
88
  }
87
89
 
88
- function normalizeStringOrNull(value) {
89
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
90
- }
91
-
92
90
  /**
93
91
  * Canonicalize a raw grill snapshot into a deterministic shape.
94
92
  *
@@ -102,7 +100,7 @@ export function normalizeGrillSnapshot(raw) {
102
100
 
103
101
  return {
104
102
  surface: VALID_SURFACES.has(raw.surface) ? raw.surface : "issue",
105
- targetRef: normalizeStringOrNull(raw.targetRef),
103
+ targetRef: trimmedOrNull(raw.targetRef),
106
104
 
107
105
  loaded: Boolean(raw.loaded),
108
106
  loadFailed: Boolean(raw.loadFailed),
@@ -55,6 +55,54 @@ export const RETROSPECTIVE_QUALIFYING_GATES = Object.freeze([
55
55
  "issue_intake",
56
56
  ]);
57
57
 
58
+ /**
59
+ * Provenance context values for a recorded retrospective (issue #1870).
60
+ *
61
+ * A retrospective MUST be produced by a FRESH-CONTEXT, independent dispatch
62
+ * (analogous to a gate reviewer) seeded with the cycle's full agent/subagent
63
+ * tool-call/action/result record — never written inline by the same working
64
+ * context that did the work. A self-authored retro reflects the working
65
+ * agent's own blind spots back at it; it validates consistency, not
66
+ * conformance, so an inline retro fails the checkpoint.
67
+ */
68
+ export const RETROSPECTIVE_PROVENANCE = Object.freeze({
69
+ /** Dispatched as a fresh, independent context (the only accepting value). */
70
+ CONTEXT_FRESH: "fresh",
71
+ /** Self-authored by the working context — rejected, fails closed. */
72
+ CONTEXT_INLINE: "inline",
73
+ /** The retro was seeded with the full agent/subagent tool-call record. */
74
+ SEEDED_FROM_RECORD: "agent_tool_call_record",
75
+ });
76
+
77
+ /**
78
+ * Normalizes a retrospective provenance record from a durable checkpoint
79
+ * artifact. Returns the normalized provenance only when it pins a valid
80
+ * fresh-context pass over the full tool-call record:
81
+ * - `context` must normalize to "fresh" (trimmed, case-insensitive; an
82
+ * "inline"/self-authored retro is rejected — it fails closed, never accepted)
83
+ * - `seededFrom` must be exactly "agent_tool_call_record" (the retro audited
84
+ * the cycle's actual behavior, not a summary)
85
+ * - `recordSource` must be a non-blank string (the transcript/journal path
86
+ * the retro was seeded with)
87
+ *
88
+ * Returns null for anything else — absent, malformed, inline, or partial.
89
+ *
90
+ * @param {unknown} value
91
+ * @returns {{context: "fresh", seededFrom: "agent_tool_call_record", recordSource: string}|null}
92
+ */
93
+ export function normalizeRetroProvenance(value) {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
95
+ return null;
96
+ }
97
+ const context = typeof value.context === "string" ? value.context.trim().toLowerCase() : "";
98
+ const seededFrom = typeof value.seededFrom === "string" ? value.seededFrom.trim().toLowerCase() : "";
99
+ const recordSource = typeof value.recordSource === "string" ? value.recordSource.trim() : "";
100
+ if (context !== RETROSPECTIVE_PROVENANCE.CONTEXT_FRESH || seededFrom !== RETROSPECTIVE_PROVENANCE.SEEDED_FROM_RECORD || recordSource.length === 0) {
101
+ return null;
102
+ }
103
+ return { context: RETROSPECTIVE_PROVENANCE.CONTEXT_FRESH, seededFrom: RETROSPECTIVE_PROVENANCE.SEEDED_FROM_RECORD, recordSource };
104
+ }
105
+
58
106
  /**
59
107
  * Normalizes an external retrospective checkpoint-state input to one of the
60
108
  * stable RETROSPECTIVE_CHECKPOINT_STATE values. Returns null when the value is
@@ -149,7 +197,17 @@ export function resolveCheckpointStateFromArtifact(artifact, { hasNewerMergeSinc
149
197
  return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.SKIPPED;
150
198
  }
151
199
  if (rawState === "complete") {
152
- return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
200
+ if (hasNewerMergeSinceCheckpoint) {
201
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
202
+ }
203
+ // Fresh-context provenance is mandatory (issue #1870): a `complete` record
204
+ // without provenance that pins a fresh-context pass over the full
205
+ // tool-call record — including legacy inline/self-authored retros — fails
206
+ // closed to MISSING. The old inline self-review path is disallowed.
207
+ if (normalizeRetroProvenance(artifact.provenance) === null) {
208
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
209
+ }
210
+ return RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
153
211
  }
154
212
  // Malformed/unrecognized durable state — fail closed.
155
213
  return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;