@dev-loops/core 0.4.0 → 0.6.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 +7 -1
- package/src/claude/asset-generation.mjs +26 -0
- package/src/config/config.mjs +50 -2
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/async-start-contract.mjs +9 -2
- package/src/loop/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +160 -9
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- package/src/loop/ui-e2e-scoping.mjs +162 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-planning plan-file refine + human-review checkpoint (off-tracker).
|
|
3
|
+
*
|
|
4
|
+
* This is the P3 core noise-reduction step. It generalizes the proposal-first
|
|
5
|
+
* intake pattern (emit a local artifact, human-gate it, make zero tracker
|
|
6
|
+
* mutation, stop and ask) to plan files: the refined plan is written in-place to
|
|
7
|
+
* the single canonical plan file, the autonomous docs-grill runs as a step
|
|
8
|
+
* within refinement, and the loop stops at a local human-review checkpoint with
|
|
9
|
+
* the intake state advanced to `plan_refined_ready_for_promotion`.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure: it transforms markdown text and reports a disposition. It
|
|
12
|
+
* performs no GitHub mutation, no network calls, and no filesystem I/O. The
|
|
13
|
+
* caller supplies the section-presence facts it already read (mirroring the
|
|
14
|
+
* `evaluatePlanFileIntakeState` precedent) and the refiner-produced payload, and
|
|
15
|
+
* writes the returned markdown back. That keeps the zero-tracker-mutation
|
|
16
|
+
* guarantee structural: there is no gh/network surface to reach from here.
|
|
17
|
+
*
|
|
18
|
+
* It composes the already-shipped contracts: P2 `evaluatePlanFileIntakeState` +
|
|
19
|
+
* `PLAN_FILE_REFINEMENT_SECTIONS` (this step acts on `new_plan_needs_refinement`
|
|
20
|
+
* and drives the transition to `plan_refined_ready_for_promotion`) and #948's
|
|
21
|
+
* docs-grill: the caller classifies each finding with `classifyDocsGrillFinding`
|
|
22
|
+
* and passes the dispositions in, and this module validates and records them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
evaluatePlanFileIntakeState,
|
|
27
|
+
PLAN_FILE_INTAKE_STATE,
|
|
28
|
+
PLAN_FILE_REFINEMENT_SECTIONS,
|
|
29
|
+
} from "./plan-file-intake-contract.mjs";
|
|
30
|
+
|
|
31
|
+
/** The local human-review checkpoint surface the loop stops at on success. */
|
|
32
|
+
export const PLAN_FILE_REFINE_STOP = Object.freeze({
|
|
33
|
+
/** Refinement wrote the plan in-place; stop for local human review before any promotion. */
|
|
34
|
+
LOCAL_HUMAN_REVIEW: "local_human_review",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The heading the recorded docs-grill findings live under in the plan file. */
|
|
38
|
+
export const DOCS_GRILL_FINDINGS_HEADING = "Docs-grill findings";
|
|
39
|
+
|
|
40
|
+
/** The heading the refiner coverage matrix lives under in the plan file. */
|
|
41
|
+
export const COVERAGE_MATRIX_HEADING = "Coverage matrix";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove an existing `## <heading>` section (heading + body up to the next H2)
|
|
45
|
+
* from markdown so a refine re-run replaces it rather than appending a duplicate.
|
|
46
|
+
* Returns the markdown unchanged when the heading is absent.
|
|
47
|
+
*/
|
|
48
|
+
function stripSection(markdownText, headingText) {
|
|
49
|
+
const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
50
|
+
const headingPattern = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
|
|
51
|
+
const match = headingPattern.exec(markdownText);
|
|
52
|
+
if (!match || match.index === undefined) return markdownText;
|
|
53
|
+
const start = match.index;
|
|
54
|
+
const afterHeading = start + match[0].length;
|
|
55
|
+
const remaining = markdownText.slice(afterHeading);
|
|
56
|
+
const nextHeadingMatch = /^##\s+/imu.exec(remaining);
|
|
57
|
+
const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
|
|
58
|
+
? afterHeading + nextHeadingMatch.index
|
|
59
|
+
: markdownText.length;
|
|
60
|
+
// Drop the section and collapse the blank-line gap it leaves behind.
|
|
61
|
+
return `${markdownText.slice(0, start)}${markdownText.slice(end)}`.replace(/\n{3,}/gu, "\n\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether markdown carries a `## <heading>` section marker. Used to re-derive
|
|
66
|
+
* the section-presence facts from the freshly-written text so the end-state
|
|
67
|
+
* 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/.
|
|
70
|
+
*/
|
|
71
|
+
function hasSection(markdownText, headingText) {
|
|
72
|
+
const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
73
|
+
return new RegExp(`^##\\s+${escaped}\\s*$`, "imu").test(markdownText);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Append a `## <heading>` section with the given body to markdown. */
|
|
77
|
+
function appendSection(markdownText, headingText, body) {
|
|
78
|
+
const trimmed = markdownText.replace(/\s+$/u, "");
|
|
79
|
+
return `${trimmed}\n\n## ${headingText}\n\n${String(body).trim()}\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render recorded docs-grill findings as a markdown body. Each finding lists the
|
|
84
|
+
* disposition `classifyDocsGrillFinding` assigned it. When the grill produced no
|
|
85
|
+
* findings, an explicit "none recorded" line keeps the section idempotent and
|
|
86
|
+
* non-empty so the plan file always carries the grill evidence.
|
|
87
|
+
*/
|
|
88
|
+
function renderGrillFindings(classified) {
|
|
89
|
+
if (classified.length === 0) {
|
|
90
|
+
return "- None recorded; the docs-grill step ran and surfaced no findings.";
|
|
91
|
+
}
|
|
92
|
+
return classified
|
|
93
|
+
.map((entry) => {
|
|
94
|
+
const summary = String(entry.summary ?? "").trim() || "(no summary)";
|
|
95
|
+
return `- [${entry.disposition}] (${entry.kind}) ${summary}`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refine a plan file in-place and advance its intake state, then stop at the
|
|
102
|
+
* local human-review checkpoint.
|
|
103
|
+
*
|
|
104
|
+
* The caller reads the plan file and supplies the section-presence facts plus
|
|
105
|
+
* the refiner payload. On success it writes `refinedMarkdown` back to the same
|
|
106
|
+
* path (the single canonical artifact) and stops; it never promotes here.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} params
|
|
109
|
+
* @param {string} params.markdownText current plan-file markdown
|
|
110
|
+
* @param {boolean} params.baseSectionsValid whether the plan passes the base-section validator
|
|
111
|
+
* @param {boolean} params.hasAcceptanceCriteria whether the plan already carries a non-empty Acceptance criteria section marker (per the intake contract)
|
|
112
|
+
* @param {boolean} params.hasDefinitionOfDone whether the plan already carries a non-empty Definition of done section marker (per the intake contract)
|
|
113
|
+
* @param {object} params.payload refiner-produced refinement output
|
|
114
|
+
* @param {string} params.payload.acceptanceCriteria Acceptance criteria section body
|
|
115
|
+
* @param {string} params.payload.definitionOfDone Definition of done section body
|
|
116
|
+
* @param {string} params.payload.coverageMatrix AC/DoD/Non-goal coverage matrix (markdown table)
|
|
117
|
+
* @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
|
|
118
|
+
* @returns {{
|
|
119
|
+
* ok: boolean,
|
|
120
|
+
* reason?: string,
|
|
121
|
+
* planFileIntakeState?: string,
|
|
122
|
+
* refinedMarkdown?: string,
|
|
123
|
+
* grillDispositions?: object[],
|
|
124
|
+
* stop?: { kind: string },
|
|
125
|
+
* }}
|
|
126
|
+
*/
|
|
127
|
+
export function refinePlanFileInPlace({
|
|
128
|
+
markdownText,
|
|
129
|
+
baseSectionsValid,
|
|
130
|
+
hasAcceptanceCriteria,
|
|
131
|
+
hasDefinitionOfDone,
|
|
132
|
+
payload,
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (typeof markdownText !== "string" || markdownText.length === 0) {
|
|
135
|
+
return { ok: false, reason: "missing_plan_markdown" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Gate on the starting intake state: this step only acts on a base-valid plan
|
|
139
|
+
// that has not yet been refined. A plan already carrying refinement markers, a
|
|
140
|
+
// partially-refined plan, or one failing the base contract is ambiguous here;
|
|
141
|
+
// fail closed without writing or advancing.
|
|
142
|
+
const startState = evaluatePlanFileIntakeState({
|
|
143
|
+
baseSectionsValid,
|
|
144
|
+
hasAcceptanceCriteria,
|
|
145
|
+
hasDefinitionOfDone,
|
|
146
|
+
}).state;
|
|
147
|
+
if (startState !== PLAN_FILE_INTAKE_STATE.NEW_PLAN_NEEDS_REFINEMENT) {
|
|
148
|
+
return { ok: false, reason: "not_in_new_plan_needs_refinement", planFileIntakeState: startState };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The refiner payload must carry the refinement contract: AC, DoD, and the
|
|
152
|
+
// coverage matrix. A missing/empty piece is a failed refine; fail closed.
|
|
153
|
+
if (!payload || typeof payload !== "object") {
|
|
154
|
+
return { ok: false, reason: "missing_refinement_payload", planFileIntakeState: startState };
|
|
155
|
+
}
|
|
156
|
+
const acceptanceCriteria = String(payload.acceptanceCriteria ?? "").trim();
|
|
157
|
+
const definitionOfDone = String(payload.definitionOfDone ?? "").trim();
|
|
158
|
+
const coverageMatrix = String(payload.coverageMatrix ?? "").trim();
|
|
159
|
+
if (!acceptanceCriteria) {
|
|
160
|
+
return { ok: false, reason: "missing_acceptance_criteria", planFileIntakeState: startState };
|
|
161
|
+
}
|
|
162
|
+
if (!definitionOfDone) {
|
|
163
|
+
return { ok: false, reason: "missing_definition_of_done", planFileIntakeState: startState };
|
|
164
|
+
}
|
|
165
|
+
if (!coverageMatrix) {
|
|
166
|
+
return { ok: false, reason: "missing_coverage_matrix", planFileIntakeState: startState };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The docs-grill runs as a step of refinement. The caller (the CLI, which owns
|
|
170
|
+
// I/O and the scripts/ boundary) classifies each finding with #948's
|
|
171
|
+
// `classifyDocsGrillFinding` and passes the dispositions in. This core module
|
|
172
|
+
// validates and renders them but does NOT import the classifier — keeping it
|
|
173
|
+
// free of any scripts/ import so the published @dev-loops/core package does not
|
|
174
|
+
// break for consumers. A missing or invalid disposition fails the grill closed
|
|
175
|
+
// so a malformed grill cannot advance the state.
|
|
176
|
+
const grillDispositions = Array.isArray(payload.grillDispositions) ? payload.grillDispositions : [];
|
|
177
|
+
for (const d of grillDispositions) {
|
|
178
|
+
if (!d || typeof d.disposition !== "string" || d.disposition.length === 0) {
|
|
179
|
+
return { ok: false, reason: "docs_grill_failed", planFileIntakeState: startState };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A managed section body must not contain a top-level `## ` heading: stripSection
|
|
184
|
+
// finds a section's end by scanning to the next `## `, so an embedded H2 in a body
|
|
185
|
+
// would break the strip-then-append idempotency on a re-run (the inner heading and
|
|
186
|
+
// its text would orphan into the document body). Fail closed on such a payload.
|
|
187
|
+
const grillBody = renderGrillFindings(grillDispositions);
|
|
188
|
+
if ([acceptanceCriteria, definitionOfDone, coverageMatrix, grillBody].some((b) => /^##\s/mu.test(String(b)))) {
|
|
189
|
+
return { ok: false, reason: "section_body_contains_heading", planFileIntakeState: startState };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Write the refinement sections in-place. Strip any prior copy first so a
|
|
193
|
+
// re-run replaces rather than duplicates (idempotency), then append the fresh
|
|
194
|
+
// sections in a stable order.
|
|
195
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
196
|
+
let refinedMarkdown = markdownText;
|
|
197
|
+
for (const heading of [acHeading, dodHeading, COVERAGE_MATRIX_HEADING, DOCS_GRILL_FINDINGS_HEADING]) {
|
|
198
|
+
refinedMarkdown = stripSection(refinedMarkdown, heading);
|
|
199
|
+
}
|
|
200
|
+
refinedMarkdown = appendSection(refinedMarkdown, acHeading, acceptanceCriteria);
|
|
201
|
+
refinedMarkdown = appendSection(refinedMarkdown, dodHeading, definitionOfDone);
|
|
202
|
+
refinedMarkdown = appendSection(refinedMarkdown, COVERAGE_MATRIX_HEADING, coverageMatrix);
|
|
203
|
+
refinedMarkdown = appendSection(refinedMarkdown, DOCS_GRILL_FINDINGS_HEADING, grillBody);
|
|
204
|
+
|
|
205
|
+
// Re-derive the section-presence facts from the text the write just produced
|
|
206
|
+
// (not from the inputs) so this check actually verifies the append landed: a
|
|
207
|
+
// correct refine carries the base sections forward and adds both refinement
|
|
208
|
+
// markers, flipping the intake state to ready. A buggy rewrite that dropped a
|
|
209
|
+
// section is caught here and fails closed rather than advancing the state.
|
|
210
|
+
const endState = evaluatePlanFileIntakeState({
|
|
211
|
+
baseSectionsValid,
|
|
212
|
+
hasAcceptanceCriteria: hasSection(refinedMarkdown, acHeading),
|
|
213
|
+
hasDefinitionOfDone: hasSection(refinedMarkdown, dodHeading),
|
|
214
|
+
}).state;
|
|
215
|
+
if (endState !== PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION) {
|
|
216
|
+
return { ok: false, reason: "refine_did_not_reach_ready", planFileIntakeState: endState };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
planFileIntakeState: endState,
|
|
222
|
+
refinedMarkdown,
|
|
223
|
+
grillDispositions,
|
|
224
|
+
// Generalized proposal-first stop: the refined plan is the local artifact,
|
|
225
|
+
// it is written in-place, and the loop stops here for human review before
|
|
226
|
+
// any promotion. No tracker artifact is created or mutated.
|
|
227
|
+
stop: { kind: PLAN_FILE_REFINE_STOP.LOCAL_HUMAN_REVIEW },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
|
|
2
2
|
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
3
|
+
import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
|
|
3
4
|
|
|
4
5
|
export const PR_CHECKPOINT = Object.freeze({
|
|
5
6
|
DRAFT_REVIEW: "draft_review",
|
|
6
7
|
POST_DRAFT_EXTERNAL_REVIEW: "post_draft_external_review",
|
|
7
8
|
FEEDBACK_RESOLUTION: "feedback_resolution",
|
|
8
9
|
CONFLICT_RESOLUTION: "conflict_resolution",
|
|
10
|
+
UI_E2E_SCOPING: "ui_e2e_scoping",
|
|
9
11
|
PRE_APPROVAL_GATE_WINDOW: "pre_approval_gate_window",
|
|
10
12
|
FINAL_APPROVAL_READY: "final_approval_ready",
|
|
11
13
|
PRE_APPROVAL_GATE_NEEDED: "pre_approval_gate_needed",
|
|
@@ -48,6 +50,7 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
|
|
|
48
50
|
RECONCILE_DRAFT_GATE: "reconcile_draft_gate",
|
|
49
51
|
REPORT_BLOCKED: "report_blocked",
|
|
50
52
|
REPORT_DONE: "report_done",
|
|
53
|
+
RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
function normalizeGateComment(summary = null) {
|
|
@@ -119,6 +122,19 @@ function normalizeMergeStateStatus(value) {
|
|
|
119
122
|
return value.trim().toUpperCase();
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
function normalizeMergeable(value) {
|
|
126
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const upper = value.trim().toUpperCase();
|
|
131
|
+
if (upper === "MERGEABLE" || upper === "CONFLICTING" || upper === "UNKNOWN") {
|
|
132
|
+
return upper;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
function normalizeConflictFiles(value) {
|
|
123
139
|
if (!Array.isArray(value)) {
|
|
124
140
|
return [];
|
|
@@ -142,14 +158,18 @@ function hasBlockedMergeStatus(mergeStateStatus) {
|
|
|
142
158
|
return mergeStateStatus !== null && BLOCKED_MERGE_STATE_STATUSES.has(mergeStateStatus);
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
function formatBlockedMergeReason(mergeStateStatus, conflictFiles) {
|
|
161
|
+
function formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable = null) {
|
|
146
162
|
if (mergeStateStatus === "BEHIND") {
|
|
147
163
|
let reason = "Branch must be updated from base before entering any gate.";
|
|
148
164
|
reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
|
|
149
165
|
return reason;
|
|
150
166
|
}
|
|
151
167
|
|
|
152
|
-
let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch, rerun validation, rerun gate detection, and only then resume the normal gate path.";
|
|
168
|
+
let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch (run `node scripts/loop/resolve-pr-conflicts.mjs --push` for the safe additive-CHANGELOG case), rerun validation, rerun gate detection, and only then resume the normal gate path.";
|
|
169
|
+
|
|
170
|
+
if (mergeable === "CONFLICTING") {
|
|
171
|
+
reason += " GitHub mergeable: CONFLICTING.";
|
|
172
|
+
}
|
|
153
173
|
|
|
154
174
|
if (mergeStateStatus !== null) {
|
|
155
175
|
reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
|
|
@@ -218,7 +238,23 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
|
|
|
218
238
|
return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and green or credibly green CI, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
|
|
219
239
|
}
|
|
220
240
|
|
|
221
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Render the (user-authored) rawCallViolations array into a bounded, single-line
|
|
243
|
+
* fragment for the gate failure reason. Collapses whitespace/newlines per entry,
|
|
244
|
+
* caps per-entry length, and caps the number of entries shown so a large or
|
|
245
|
+
* garbled checkpoint cannot bloat or break gate output. Still fails closed —
|
|
246
|
+
* this only formats the reason; the violation count above does the gating.
|
|
247
|
+
*/
|
|
248
|
+
function summarizeRawCallViolations(violations, { maxEntries = 10, maxEntryLen = 200 } = {}) {
|
|
249
|
+
const shown = violations.slice(0, maxEntries).map((v) => {
|
|
250
|
+
const flat = String(v).replace(/\s+/g, " ").trim();
|
|
251
|
+
return flat.length > maxEntryLen ? `${flat.slice(0, maxEntryLen)}…` : flat;
|
|
252
|
+
});
|
|
253
|
+
const more = violations.length - shown.length;
|
|
254
|
+
return more > 0 ? `${shown.join("; ")}; …(+${more} more)` : shown.join("; ");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function evaluateRetrospectiveMergeApproval(checkpoint, { developerMode = false } = {}) {
|
|
222
258
|
if (!checkpoint || typeof checkpoint !== "object") {
|
|
223
259
|
return { approved: false, reason: "No retrospective checkpoint was found." };
|
|
224
260
|
}
|
|
@@ -278,6 +314,37 @@ function evaluateRetrospectiveMergeApproval(checkpoint) {
|
|
|
278
314
|
return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
|
|
279
315
|
}
|
|
280
316
|
|
|
317
|
+
// internalToolingOnly: the loop's own execution must have used internal dev-loops
|
|
318
|
+
// tooling only — no agent-level raw `gh`/`python`/`python3`/`node -e` (issue #982).
|
|
319
|
+
// This is a DEVELOPER-MODE retro step: it enforces the dev-loops maintainers'
|
|
320
|
+
// own dogfooding discipline and is opt-in via `workflow.requireRetrospectiveInternalTooling`
|
|
321
|
+
// (default OFF). CONSUMERS of the extension are never blocked by it — they may
|
|
322
|
+
// legitimately use raw gh/python/node -e in their own workflow — so when the flag
|
|
323
|
+
// is OFF these fields are neither required nor enforced (a complete checkpoint
|
|
324
|
+
// without them passes exactly as it did before #982). When ON it fails closed:
|
|
325
|
+
// a complete checkpoint must explicitly attest a clean tooling record, and an OLD
|
|
326
|
+
// checkpoint missing `internalToolingOnly` fails (not a silent pass). Re-record the
|
|
327
|
+
// retrospective with the new fields to clear it.
|
|
328
|
+
if (developerMode) {
|
|
329
|
+
const internalToolingOnly = br !== null ? br.internalToolingOnly : checkpoint.internalToolingOnly;
|
|
330
|
+
if (internalToolingOnly !== true) {
|
|
331
|
+
return {
|
|
332
|
+
approved: false,
|
|
333
|
+
reason: "Retrospective does not attest internal-tooling-only execution (`internalToolingOnly: true` is required in developer mode; agent-level raw gh/python/node -e is a violation). — re-record the retrospective with internalToolingOnly + rawCallViolations.",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const rawCallViolations = br !== null ? br.rawCallViolations : checkpoint.rawCallViolations;
|
|
337
|
+
if (!Array.isArray(rawCallViolations)) {
|
|
338
|
+
return { approved: false, reason: "Retrospective is missing `rawCallViolations` (array; empty when clean). — re-record the retrospective with internalToolingOnly + rawCallViolations." };
|
|
339
|
+
}
|
|
340
|
+
if (rawCallViolations.length > 0) {
|
|
341
|
+
return {
|
|
342
|
+
approved: false,
|
|
343
|
+
reason: `Retrospective records ${rawCallViolations.length} raw-call violation(s) (agent-level gh/python/node -e): ${summarizeRawCallViolations(rawCallViolations)}.`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
281
348
|
return { approved: true, reason: null };
|
|
282
349
|
}
|
|
283
350
|
|
|
@@ -610,6 +677,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
610
677
|
? "internal_only"
|
|
611
678
|
: (typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null);
|
|
612
679
|
const mergeStateStatus = normalizeMergeStateStatus(input.mergeStateStatus);
|
|
680
|
+
const mergeable = normalizeMergeable(input.mergeable);
|
|
613
681
|
const conflictFiles = normalizeConflictFiles(input.conflictFiles);
|
|
614
682
|
const ciStatus = normalizeCiStatus(input.ciStatus);
|
|
615
683
|
const draftGateRequireCi = input.draftGateRequireCi !== false;
|
|
@@ -617,8 +685,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
617
685
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
618
686
|
const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
|
|
619
687
|
const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
|
|
688
|
+
// Developer-mode flag (#982): only the dev-loops repo dogfooding itself enforces the
|
|
689
|
+
// internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
|
|
690
|
+
const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
|
|
620
691
|
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
621
692
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
693
|
+
// UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
|
|
694
|
+
// e2e suite passed for this head. Inclusion is path-triggered, never annotated.
|
|
695
|
+
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
696
|
+
const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
|
|
622
697
|
const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
|
|
623
698
|
? input.refinementArtifact
|
|
624
699
|
: null;
|
|
@@ -694,7 +769,44 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
694
769
|
});
|
|
695
770
|
}
|
|
696
771
|
|
|
697
|
-
|
|
772
|
+
// Mergeability is a required precondition at every gate (issue #980). GitHub
|
|
773
|
+
// computes `mergeable` asynchronously, so an unsettled UNKNOWN must fail closed
|
|
774
|
+
// to a recheck — never a pass. The detect layer already re-polls a bounded
|
|
775
|
+
// number of times; if it still reads UNKNOWN here, hold gate progression and
|
|
776
|
+
// recheck rather than guess.
|
|
777
|
+
if (mergeable === "UNKNOWN") {
|
|
778
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
779
|
+
pushUnique(forbiddenActions, [
|
|
780
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
781
|
+
PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
|
|
782
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
783
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
784
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
785
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
786
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
787
|
+
]);
|
|
788
|
+
return buildResult({
|
|
789
|
+
repo: input.repo ?? null,
|
|
790
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
791
|
+
currentHeadSha,
|
|
792
|
+
lifecycleState: effectiveLifecycleState,
|
|
793
|
+
loopDisposition: DISPOSITION.PENDING,
|
|
794
|
+
gateBoundary: PR_CHECKPOINT.CONFLICT_RESOLUTION,
|
|
795
|
+
draftGateAlreadySatisfied,
|
|
796
|
+
draftGate,
|
|
797
|
+
preApprovalGate,
|
|
798
|
+
allowedNextActions,
|
|
799
|
+
forbiddenActions,
|
|
800
|
+
nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
|
|
801
|
+
reason: "GitHub has not yet computed mergeability (mergeable=UNKNOWN), so gate progression is held: recheck before proceeding rather than treating an unsettled merge state as clean.",
|
|
802
|
+
mergeStateStatus,
|
|
803
|
+
conflictFiles,
|
|
804
|
+
refinementArtifact,
|
|
805
|
+
copilotReviewRoundCount,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (hasBlockedMergeStatus(mergeStateStatus) || mergeable === "CONFLICTING" || conflictFiles.length > 0) {
|
|
698
810
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]);
|
|
699
811
|
pushUnique(forbiddenActions, [
|
|
700
812
|
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
@@ -723,7 +835,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
723
835
|
allowedNextActions,
|
|
724
836
|
forbiddenActions,
|
|
725
837
|
nextAction: PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS,
|
|
726
|
-
reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles),
|
|
838
|
+
reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable),
|
|
727
839
|
mergeStateStatus,
|
|
728
840
|
conflictFiles,
|
|
729
841
|
refinementArtifact,
|
|
@@ -731,6 +843,45 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
731
843
|
});
|
|
732
844
|
}
|
|
733
845
|
|
|
846
|
+
// UI e2e auto-scoping precondition (#976). Path-triggered + fail-closed:
|
|
847
|
+
// if the PR's changed files touch a rendered artifact (a deck under
|
|
848
|
+
// docs/articles|presentations, or the inspect-run viewer source), it MUST be
|
|
849
|
+
// registered in the shared UI e2e suite AND that suite must have passed for
|
|
850
|
+
// this head. A rendered-artifact change with no registered/passing coverage
|
|
851
|
+
// blocks here with a reason naming the artifact. Distinct seam from the
|
|
852
|
+
// mergeability (#980) and retrospective (#982) preconditions to minimize
|
|
853
|
+
// merge-time conflict. Non-UI changes pass through untouched (required=false).
|
|
854
|
+
const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
|
|
855
|
+
if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
|
|
856
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]);
|
|
857
|
+
pushUnique(forbiddenActions, [
|
|
858
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
859
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
860
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
861
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
862
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
863
|
+
]);
|
|
864
|
+
return buildResult({
|
|
865
|
+
repo: input.repo ?? null,
|
|
866
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
867
|
+
currentHeadSha,
|
|
868
|
+
lifecycleState: effectiveLifecycleState,
|
|
869
|
+
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
870
|
+
gateBoundary: PR_CHECKPOINT.UI_E2E_SCOPING,
|
|
871
|
+
draftGateAlreadySatisfied,
|
|
872
|
+
draftGate,
|
|
873
|
+
preApprovalGate,
|
|
874
|
+
allowedNextActions,
|
|
875
|
+
forbiddenActions,
|
|
876
|
+
nextAction: PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE,
|
|
877
|
+
reason: uiE2eScoping.reason,
|
|
878
|
+
mergeStateStatus,
|
|
879
|
+
conflictFiles,
|
|
880
|
+
refinementArtifact,
|
|
881
|
+
copilotReviewRoundCount,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
734
885
|
if (prDraft || effectiveLifecycleState === STATE.PR_DRAFT) {
|
|
735
886
|
if (refinementArtifactStatus === REFINEMENT_ARTIFACT_STATUS.MISSING) {
|
|
736
887
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
@@ -911,7 +1062,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
911
1062
|
});
|
|
912
1063
|
}
|
|
913
1064
|
if (requireRetrospectiveGate) {
|
|
914
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1065
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
915
1066
|
if (!retrospectiveGate.approved) {
|
|
916
1067
|
return buildRetrospectiveGatePendingResult({
|
|
917
1068
|
input,
|
|
@@ -1183,7 +1334,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1183
1334
|
});
|
|
1184
1335
|
}
|
|
1185
1336
|
if (requireRetrospectiveGate) {
|
|
1186
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1337
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1187
1338
|
if (!retrospectiveGate.approved) {
|
|
1188
1339
|
return buildRetrospectiveGatePendingResult({
|
|
1189
1340
|
input,
|
|
@@ -1349,7 +1500,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1349
1500
|
});
|
|
1350
1501
|
}
|
|
1351
1502
|
if (requireRetrospectiveGate) {
|
|
1352
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1503
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1353
1504
|
if (!retrospectiveGate.approved) {
|
|
1354
1505
|
return buildRetrospectiveGatePendingResult({
|
|
1355
1506
|
input,
|
|
@@ -1509,7 +1660,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1509
1660
|
});
|
|
1510
1661
|
}
|
|
1511
1662
|
if (requireRetrospectiveGate) {
|
|
1512
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1663
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1513
1664
|
if (!retrospectiveGate.approved) {
|
|
1514
1665
|
return buildRetrospectiveGatePendingResult({
|
|
1515
1666
|
input,
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
|
|
5
5
|
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
6
6
|
* human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
|
|
7
|
-
* Code) that inject no native per-subagent run id.
|
|
8
|
-
* dispatching an async subagent.
|
|
7
|
+
* Code) that inject no native per-subagent run id. For those harnesses dev-loops itself mints
|
|
8
|
+
* and sets `DEVLOOPS_RUN_ID` when dispatching an async subagent.
|
|
9
|
+
*
|
|
10
|
+
* Other harnesses may already inject their own run-id var: the Pi runtime injects
|
|
11
|
+
* `PI_SUBAGENT_RUN_ID` (not `DEVLOOPS_RUN_ID`) into each async subagent's child env, so that
|
|
12
|
+
* name is honored as a recognized run-id alias (precedence after the neutral primary). It is
|
|
13
|
+
* an externally-injected Pi-runtime contract var, not a dev-loops-owned var — dev-loops still
|
|
14
|
+
* mints/propagates only the neutral `DEVLOOPS_RUN_ID`.
|
|
9
15
|
*
|
|
10
16
|
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
11
17
|
* which take an injectable `fs` and `root` for testability.
|
|
@@ -17,9 +23,10 @@ import path from "node:path";
|
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
20
|
-
* The neutral `DEVLOOPS_RUN_ID` is the
|
|
26
|
+
* The neutral `DEVLOOPS_RUN_ID` is primary; `PI_SUBAGENT_RUN_ID` is the alias the Pi
|
|
27
|
+
* runtime injects into async-subagent child envs (the only run-id marker present under Pi).
|
|
21
28
|
*/
|
|
22
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
|
|
29
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
|
|
23
30
|
|
|
24
31
|
/** Neutral env var name used when minting/propagating a run id. */
|
|
25
32
|
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|