@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.
- package/package.json +8 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/issue-refinement-artifact.mjs +186 -42
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -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/public-dev-loop-routing.mjs +11 -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/retrospective-checkpoint.mjs +59 -1
- package/src/loop/review-dispatch-plan.mjs +448 -9
- 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/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +2 -28
- 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
|
|
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,
|
|
@@ -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 =
|
|
87
|
-
const phase =
|
|
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:
|
|
138
|
+
currentHeadSha: trimmedOrNull(evidence.currentHeadSha),
|
|
143
139
|
preApprovalGate: {
|
|
144
140
|
visible: preApprovalGate.visible === true,
|
|
145
|
-
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|