@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.
- package/package.json +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- 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
|
|
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).
|
|
69
|
-
* helper belongs in core once extractSection is lifted out of scripts/.
|
|
134
|
+
* inputs).
|
|
70
135
|
*/
|
|
71
136
|
function hasSection(markdownText, headingText) {
|
|
72
|
-
|
|
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:
|
|
84
|
+
headSha: trimmedOrNull(summary.headSha),
|
|
81
85
|
verdict: typeof summary.verdict === "string" && summary.verdict.trim().length > 0 ? summary.verdict.trim().toLowerCase() : null,
|
|
82
|
-
findingsSummary:
|
|
83
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|