@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7

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 (47) hide show
  1. package/package.json +12 -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 +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -24,6 +24,7 @@
24
24
  * No imports so this file vendors into the `.claude/hooks/` bundle unchanged
25
25
  * (vendored modules may only import `node:` builtins or relative paths).
26
26
  */
27
+ import path from "node:path";
27
28
 
28
29
  /**
29
30
  * Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
@@ -56,3 +57,75 @@ export function buildMainCheckoutFastForwardCommand(mainCheckout) {
56
57
  // wrong branch. No state change, no git switch.
57
58
  return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
58
59
  }
60
+
61
+ /**
62
+ * Worktree-cleanup timeout (ms) for the post-merge `git worktree remove` half.
63
+ */
64
+ export const WORKTREE_CLEANUP_TIMEOUT_MS = 60_000;
65
+
66
+ /**
67
+ * Build the best-effort post-merge worktree-removal command string (#1627).
68
+ *
69
+ * The dev-loop mandates removing the branch's worktree after merge, but neither
70
+ * the merge procedure nor the post-merge hooks performed it. This builds the
71
+ * shell command that runs the shared `cleanup-worktree.mjs` script FROM the main
72
+ * checkout (the hook's cwd can be inside the worktree being removed, which makes
73
+ * `git worktree remove` fail), and stays non-fatal: the script itself is fail-soft
74
+ * (refuses any path outside tmp/worktrees/dev-loops/, exits 0 on git errors), and
75
+ * the surrounding guard makes a consumer checkout without the script a silent no-op.
76
+ * `prNumber` is shell-escaped as a double-quoted argument; `mainCheckout` and the
77
+ * script path are POSIX single-quoted. Returns an empty string when no PR number
78
+ * (or no meaningful target) is available, so callers can skip cleanly.
79
+ *
80
+ * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
81
+ * @param {string | number | undefined} prNumber - Merged PR number (drives `--pr`).
82
+ * @returns {string} the cleanup command, or "" when `prNumber` is absent.
83
+ */
84
+ export function buildWorktreeCleanupCommand(mainCheckout, prNumber) {
85
+ const pr = String(prNumber ?? "").trim();
86
+ // Validate the PR number is a positive integer BEFORE embedding it into the
87
+ // shell string; a caller passing a non-numeric string (could carry command
88
+ // substitution) is refused by returning "" — defense-in-depth in a public helper.
89
+ if (!/^[0-9]+$/u.test(pr)) {
90
+ return "";
91
+ }
92
+ const quotedMain = shellQuotePath(mainCheckout);
93
+ const script = shellQuotePath(path.join(mainCheckout, "scripts", "loop", "cleanup-worktree.mjs"));
94
+ // Guard the script's existence (consumer no-op) and keep the whole thing
95
+ // non-fatal with `|| true` — removal must never break a merge-completion flow.
96
+ return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain} --pr "${pr}"; fi || true`;
97
+ }
98
+
99
+ /**
100
+ * Overall timeout (ms) for the post-merge actions runner invocation. Generous:
101
+ * the runner itself bounds each declared action by its own timeoutMs/verify
102
+ * budget (each individually capped at the config-schema ceiling), and this is
103
+ * only the outer harness-hook guard against a runner that never returns.
104
+ */
105
+ export const POST_MERGE_ACTIONS_TIMEOUT_MS = 900_000;
106
+
107
+ /**
108
+ * Build the best-effort `postMerge.actions` runner command (#1457): the shared,
109
+ * dependency-free command string both harness hooks (Pi `post-merge-update`,
110
+ * Claude `post-tool-use-merge`) run after a successful merge, for the repo that
111
+ * merged. Existence-guarded (a checkout without the runner script is a silent
112
+ * no-op) and non-fatal (`|| true` — a runner failure must never break a
113
+ * merge-completion flow; the runner itself reports per-action failures in its
114
+ * own JSON result). `mainCheckout` and the script path are POSIX
115
+ * single-quoted; `prNumber` (when a valid positive integer) is passed as a
116
+ * double-quoted `--pr` argument — never interpolated into `run`/`verify`
117
+ * command strings, which the runner executes verbatim from the repo's own
118
+ * `.devloops`.
119
+ *
120
+ * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
121
+ * @param {string | number | undefined} [prNumber] - Merged PR number, when known.
122
+ * @returns {string} the runner command (always non-empty; a missing PR number
123
+ * just omits `--pr`, since `onlyIfChanged` scoping bypasses cleanly without one).
124
+ */
125
+ export function buildPostMergeActionsCommand(mainCheckout, prNumber) {
126
+ const quotedMain = shellQuotePath(mainCheckout);
127
+ const script = shellQuotePath(path.join(mainCheckout, "scripts", "loop", "run-post-merge-actions.mjs"));
128
+ const pr = String(prNumber ?? "").trim();
129
+ const prArg = /^[0-9]+$/u.test(pr) ? ` --pr "${pr}"` : "";
130
+ return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain}${prArg}; fi || true`;
131
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared `## <heading>` markdown-section helpers. A "section" is an H2
3
+ * heading line and everything up to (but not including) the next H2.
4
+ */
5
+
6
+ /** Build the case-insensitive, multiline `^## <heading>$` matcher shared by
7
+ * extractSection/hasSection/stripSection. */
8
+ export function buildSectionHeadingPattern(headingText) {
9
+ // Public export: a non-string or empty heading has no section to match, so
10
+ // return a never-match pattern rather than throwing (non-string) or building
11
+ // a bare `^##\s+\s*$` that matches any H2 (empty). Every in-repo caller passes
12
+ // a canonical heading string; this only hardens the new public boundary.
13
+ if (typeof headingText !== "string" || headingText.length === 0) {
14
+ return /(?!)/u;
15
+ }
16
+ const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
17
+ return new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
18
+ }
19
+
20
+ /**
21
+ * Extract the trimmed body of a `## <headingText>` section from `body`, or
22
+ * null when the heading isn't present.
23
+ */
24
+ export function extractSection(body, headingText) {
25
+ if (typeof body !== "string" || body.length === 0) {
26
+ return null;
27
+ }
28
+ const headingPattern = buildSectionHeadingPattern(headingText);
29
+ const match = headingPattern.exec(body);
30
+ if (!match || match.index === undefined) {
31
+ return null;
32
+ }
33
+ const start = match.index + match[0].length;
34
+ const remaining = body.slice(start);
35
+ const nextHeadingMatch = /^##\s+/imu.exec(remaining);
36
+ const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
37
+ ? start + nextHeadingMatch.index
38
+ : body.length;
39
+ return body.slice(start, end).trim();
40
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared string-normalization primitive used across the loop layer:
3
+ * trim a value and return it, or null when it isn't a non-empty string.
4
+ */
5
+ export function trimmedOrNull(value) {
6
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
7
+ }
@@ -219,16 +219,28 @@ function neutralizeIssueCloseKeywords(text) {
219
219
  * authority. Issue-closing keywords inside the embedded AC/DoD are neutralized
220
220
  * so untrusted plan content cannot smuggle one in.
221
221
  *
222
+ * The plan's `## Size estimate` section (phase 4 of #1480's plan-time size budget —
223
+ * see `validatePhaseSizeEstimate` in `plan-file-refine-contract.mjs`) is carried
224
+ * through verbatim when present, so an over-budget-but-cohesive phase's
225
+ * `oversize: justified` note flows into the PR the fail-closed post-hoc size
226
+ * budget (`check-size-budget.mjs`, wired at draft-exit) later escalates: a human
227
+ * reading that PR's escalated review sees the plan-time reasoning right in the
228
+ * body, not just that the diff came out large. Optional — an already-promoted
229
+ * or hand-authored plan without the section still promotes; the section is
230
+ * simply omitted from the PR body.
231
+ *
222
232
  * @param {object} params
223
233
  * @param {string} params.planDocPath repo-relative path of the committed plan doc
224
234
  * @param {string} params.acceptanceCriteria full Acceptance criteria section body
225
235
  * @param {string} params.definitionOfDone full Definition of done section body
236
+ * @param {string} [params.sizeEstimate] full Size estimate section body, if present
226
237
  * @returns {string}
227
238
  */
228
- export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone } = {}) {
239
+ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone, sizeEstimate } = {}) {
229
240
  const docPath = String(planDocPath ?? "").trim();
230
241
  const ac = String(acceptanceCriteria ?? "").trim();
231
242
  const dod = String(definitionOfDone ?? "").trim();
243
+ const size = String(sizeEstimate ?? "").trim();
232
244
  if (docPath.length === 0) {
233
245
  throw new Error("buildPromotionPrBody requires a planDocPath");
234
246
  }
@@ -252,5 +264,6 @@ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definiti
252
264
  "",
253
265
  safeDod,
254
266
  "",
267
+ ...(size.length > 0 ? ["## Size estimate", "", neutralizeIssueCloseKeywords(size), ""] : []),
255
268
  ].join("\n");
256
269
  }
@@ -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,