@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.
@@ -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
- function evaluateRetrospectiveMergeApproval(checkpoint) {
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
- if (hasBlockedMergeStatus(mergeStateStatus) || conflictFiles.length > 0) {
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,
@@ -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. The harness sets `DEVLOOPS_RUN_ID` when
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 sole marker.
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";