@dev-loops/core 1.0.4-pre.1 → 1.0.4-pre.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.4-pre.1",
3
+ "version": "1.0.4-pre.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -72,6 +72,7 @@
72
72
  "./loop/execution-record": "./src/loop/execution-record.mjs",
73
73
  "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
74
74
  "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
75
+ "./loop/review-operation": "./src/loop/review-operation.mjs",
75
76
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
76
77
  "./loop/role-budget-bound": "./src/loop/role-budget-bound.mjs",
77
78
  "./loop/run-context": "./src/loop/run-context.mjs",
@@ -110,6 +110,7 @@ const RefinementConfig = z.strictObject({
110
110
  fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
111
111
  mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
112
112
  maxCopilotRounds: z.number().int().nonnegative().default(5).describe("Automated Copilot review rounds before converging; 0 disables Copilot review."),
113
+ requireCopilotConvergenceAtLatestHead: z.boolean().default(false).describe("Require a converged Copilot review at the latest head. False (default): one converged Copilot review stands for later heads, which pre_approval_gate covers. True: a significant change after convergence opens a new Copilot cycle."),
113
114
  lowSignal: LowSignalConfig.optional().describe("Early-stop policy for low-signal Copilot rounds."),
114
115
  roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
115
116
  });
@@ -1971,6 +1972,20 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
1971
1972
  return Math.min(effectiveLightCap, maxCopilotRounds);
1972
1973
  }
1973
1974
 
1975
+ /**
1976
+ * Resolve the Copilot convergence mode. False (the default) selects the
1977
+ * converged-once rule: one converged Copilot review stands for later heads.
1978
+ * True restores the strict rule: a significant change after convergence opens
1979
+ * a new Copilot cycle, and only a docs-only or integrate-only delta carries.
1980
+ * Loop and merge both read the mode through this resolver. Light-dispatched
1981
+ * PRs use the same value.
1982
+ * @param {DevLoopConfig} config
1983
+ * @returns {boolean}
1984
+ */
1985
+ export function resolveRequireCopilotConvergenceAtLatestHead(config) {
1986
+ return config?.refinement?.requireCopilotConvergenceAtLatestHead === true;
1987
+ }
1988
+
1974
1989
  /** Label that forces full fan-out regardless of change size. */
1975
1990
  export const GATE_FULL_LABEL = "gate:full";
1976
1991
 
@@ -445,8 +445,12 @@ export function interpretLoopState(snapshot, refinementConfig) {
445
445
  // A current-head Copilot request is still active/pending and must settle before gate progression.
446
446
  state = STATE.WAITING_FOR_COPILOT_REVIEW;
447
447
  } else if (s.copilotReviewPresent) {
448
- // Copilot has reviewed at least once; all threads resolved
449
- if (ciBlocks) {
448
+ // Copilot has reviewed at least once; all threads resolved. A later
449
+ // body-only finding on an earlier commit outranks a clean current-head
450
+ // review, and merge refuses on it. A re-request would stop at the
451
+ // same-head clean suppression, so only a copilot-body-disposition record
452
+ // naming that review clears it.
453
+ if (ciBlocks || (s.copilotReviewOnCurrentHead && s.copilotPriorHeadBodyFeedbackUnresolved)) {
450
454
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
451
455
  } else if (ciWaits) {
452
456
  state = STATE.WAITING_FOR_CI;
@@ -168,8 +168,9 @@ export const COPILOT_CONVERGENCE_STATE = Object.freeze({
168
168
  NO_CURRENT_HEAD_REVIEW: "no_current_head_review",
169
169
  });
170
170
 
171
- /** ADR 0012 end states that may satisfy convergence without a current-head review. */
171
+ /** ADR 0012 end states (amended by ADR 0090) that may satisfy convergence without a current-head review. */
172
172
  export const COPILOT_ABSENT_REVIEW_DISPOSITION = Object.freeze({
173
+ CONVERGED_ONCE: "converged_once",
173
174
  ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
174
175
  DOCS_ONLY_SUPPRESSION: "docs_only_suppression",
175
176
  COPILOT_GATE_DISABLED: "copilot_gate_disabled",
@@ -231,6 +232,10 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
231
232
  // array order never silently drops a finding.
232
233
  let latestDisposition = null;
233
234
  let latestAt = null;
235
+ // The id of the review that owns latestDisposition: the review a
236
+ // copilot-body-disposition record must name to clear a finding. Null when two
237
+ // tied reviews both block, so no single record can clear the tie.
238
+ let latestReviewId = null;
234
239
  for (const entry of Array.isArray(reviews) ? reviews : []) {
235
240
  // Shape-tolerant Copilot-login + commit extraction: the merge gate feeds
236
241
  // REST-shaped reviews (user.login/commit_id) while the gate-ENTRY detector
@@ -246,16 +251,20 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
246
251
  const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
247
252
  if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
248
253
  const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
254
+ const reviewId = entry?.id !== null && entry?.id !== undefined ? String(entry.id) : null;
249
255
  const submittedAt = typeof entry?.submittedAt === "string"
250
256
  ? entry.submittedAt
251
257
  : (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
252
258
  if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
253
259
  latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
260
+ latestReviewId = reviewId;
254
261
  latestAt = submittedAt;
255
- } else if (submittedAt !== null && submittedAt === latestAt) {
256
- latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
257
- } else if (submittedAt === null && latestAt === null) {
258
- latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
262
+ } else if (submittedAt === latestAt) {
263
+ // Equal-string tie, or both null.
264
+ ({ disposition: latestDisposition, reviewId: latestReviewId } = foldTiedReview(
265
+ { disposition: latestDisposition, reviewId: latestReviewId },
266
+ { disposition, reviewId },
267
+ ));
259
268
  }
260
269
  // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
261
270
  }
@@ -275,13 +284,13 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
275
284
 
276
285
  const findings = COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS;
277
286
  if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
278
- return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
287
+ return { ok: false, state: findings, disposition: latestDisposition, reviewId: latestReviewId, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
279
288
  }
280
289
  if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
281
- return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
290
+ return { ok: false, state: findings, disposition: latestDisposition, reviewId: latestReviewId, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
282
291
  }
283
292
  // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
284
- return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reason: null };
293
+ return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reviewId: latestReviewId, reason: null };
285
294
  }
286
295
 
287
296
  // Disposition blocking precedence, most-blocking first. Used to fold an
@@ -303,6 +312,19 @@ function moreBlockingDisposition(a, b) {
303
312
  return ra <= rb ? a : b;
304
313
  }
305
314
 
315
+ // Fold one tied current-head review into the running latest. The disposition
316
+ // folds toward the most blocking. The owning review id follows the blocking
317
+ // review; two tied blocking reviews leave no single owner (null).
318
+ const BLOCKING_CONVERGENCE_DISPOSITIONS = new Set([COPILOT_DISPOSITION.CHANGES_RECOMMENDED, COPILOT_DISPOSITION.UNRECOGNIZED]);
319
+ function foldTiedReview(latest, next) {
320
+ if (latest.disposition === null) return next;
321
+ if (BLOCKING_CONVERGENCE_DISPOSITIONS.has(latest.disposition) && BLOCKING_CONVERGENCE_DISPOSITIONS.has(next.disposition)) {
322
+ return { disposition: moreBlockingDisposition(latest.disposition, next.disposition), reviewId: null };
323
+ }
324
+ const disposition = moreBlockingDisposition(latest.disposition, next.disposition);
325
+ return { disposition, reviewId: disposition === latest.disposition ? latest.reviewId : next.reviewId };
326
+ }
327
+
306
328
  /**
307
329
  * Decide whether merge is authorized given the class, the standing
308
330
  * authorization signal, and any fresh per-merge approval.
@@ -382,6 +404,10 @@ export function evaluateMergePreconditions({
382
404
  stableRelease = false,
383
405
  copilotAbsentReviewDisposition = null,
384
406
  copilotBodyDisposition = null,
407
+ // Refusal reason when a Copilot review submitted after the current-head
408
+ // verdict review sits on an earlier commit and is not converged (the latest
409
+ // review decides); null otherwise.
410
+ copilotLaterReviewRefusal = null,
385
411
  } = {}) {
386
412
  const failures = [];
387
413
 
@@ -432,14 +458,19 @@ export function evaluateMergePreconditions({
432
458
  // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
433
459
  // A trusted copilot-body-disposition record resolved for the current head
434
460
  // (`{ headSha, reviewId, ... }`) clears a current-head finding, as it does
435
- // at gate entry.
461
+ // at gate entry, only when it names the review that raised the finding.
436
462
  const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews, absentReviewDisposition: copilotAbsentReviewDisposition });
437
463
  const head = typeof currentHeadSha === "string" ? currentHeadSha.trim().toLowerCase() : "";
438
464
  const bodyCleared = copilotConvergence.state === COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS
439
465
  && head.length > 0
440
- && typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head;
466
+ && typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head
467
+ // The record clears only the review that raised the current-head finding.
468
+ && copilotConvergence.reviewId !== null
469
+ && String(copilotBodyDisposition.reviewId) === copilotConvergence.reviewId;
441
470
  if (!copilotConvergence.ok && !bodyCleared) {
442
471
  failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
472
+ } else if (typeof copilotLaterReviewRefusal === "string") {
473
+ failures.push({ precondition: "copilot_convergence", reason: copilotLaterReviewRefusal });
443
474
  }
444
475
 
445
476
  const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
@@ -510,6 +510,9 @@ function buildResult({
510
510
  * @param {boolean} [params.postConvergenceSignificantChange=false] - significant
511
511
  * post-convergence changes on a newer head start a new review cycle and must
512
512
  * not be treated as round-cap clean-fallback suppression.
513
+ * @param {boolean} [params.postConvergenceReviewSuppressed=false] - the caller
514
+ * verified a carried convergence for this head, so no further Copilot request
515
+ * is due (mirrors applyUnsettledCopilotReviewEntryGuard).
513
516
  * @param {string} params.gateBoundary - current gate boundary
514
517
  * @returns {boolean}
515
518
  */
@@ -521,6 +524,7 @@ export function shouldGuardCopilotReviewRequest({
521
524
  sameHeadCleanConverged = false,
522
525
  roundCapCleanFallback = false,
523
526
  postConvergenceSignificantChange = false,
527
+ postConvergenceReviewSuppressed = false,
524
528
  gateBoundary,
525
529
  }) {
526
530
  const gateBoundariesRequiringCopilotFormalRequest = new Set([
@@ -540,6 +544,11 @@ export function shouldGuardCopilotReviewRequest({
540
544
  if (copilotReviewRequestStatus !== "none") {
541
545
  return false;
542
546
  }
547
+ // A carried convergence settles this head without a new Copilot round; the
548
+ // request tool would return a suppressed status, so a forced request livelocks.
549
+ if (postConvergenceReviewSuppressed === true) {
550
+ return false;
551
+ }
543
552
  // Durable signal: if Copilot was ever formally requested as a reviewer,
544
553
  // the current "none" status is from a fulfilled request (normal cycle),
545
554
  // not from a missing request. Do not guard the happy path.
@@ -669,17 +678,19 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
669
678
  // guard runs, so this exemption cannot mask a genuinely-unreviewed change;
670
679
  // - postConvergenceReviewSuppressed: the caller verified a carried
671
680
  // convergence (or an operator suppression marker) for this head: no
672
- // outstanding request, zero unresolved threads, and a provably docs-only
673
- // or integrate-only delta since Copilot's last submitted review, so the
674
- // prior converged review stands for this head (never derived here from
675
- // other snapshot facts).
681
+ // outstanding request, zero unresolved threads, and a converged latest
682
+ // Copilot review on an earlier head. By default that review carries
683
+ // whatever the delta (converged-once); under the strict setting only a
684
+ // provably docs-only or integrate-only delta carries. The prior converged
685
+ // review then stands for this head (never derived here from other
686
+ // snapshot facts).
676
687
  // Fail closed on ANY non-outstanding status, not only the literal "none":
677
688
  // this is the independent gate-ENTRY re-check, so it must not trust the
678
689
  // caller's status string. A non-canonical/unknown value ("", "unavailable",
679
690
  // "failed", a typo) with a grant-y lifecycleState and no current-head review
680
691
  // fails closed rather than slipping through the "none"-only default. Only a
681
- // driven current-head review (or the impossible-further-round cap state, or an
682
- // operator-verified pure-doc-bump suppression) exempts, so an agent that skips
692
+ // driven current-head review (or the impossible-further-round cap state, or a
693
+ // caller-verified carried convergence) exempts, so an agent that skips
683
694
  // the explicit Copilot round cannot reach a clean pre_approval verdict via any
684
695
  // grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
685
696
  // carried by prior-head rounds).
@@ -891,8 +902,8 @@ function evaluatePrGateCoordinationCore(input = {}) {
891
902
  // Carried convergence: set only when the caller verified, through the
892
903
  // shared carried-convergence predicate the Copilot request tool also uses,
893
904
  // that the prior converged Copilot review still stands for this head (no
894
- // outstanding request, zero unresolved threads, a provably docs-only or
895
- // integrate-only delta), or that an operator suppression marker re-verifies
905
+ // outstanding request, zero unresolved threads, and in strict mode a
906
+ // provably docs-only or integrate-only delta), or that an operator suppression marker re-verifies
896
907
  // on the same terms. Never derived here from other snapshot facts: this
897
908
  // evaluator trusts the caller's verification, so the request tool and the
898
909
  // gate cannot disagree about the same head.
@@ -1812,7 +1823,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1812
1823
  reason: roundCapReached
1813
1824
  ? `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}, so \`pre_approval_gate\` fallback is now the next legal boundary.`
1814
1825
  : (postConvergenceReviewSuppressed && !sameHeadCleanConverged
1815
- ? "The current head carries the prior converged Copilot review: no request is outstanding, no review thread is unresolved, and the delta since Copilot's last submitted review is provably docs-only or integrate-only, so `pre_approval_gate` is now the next legal boundary."
1826
+ ? "The current head carries the prior converged Copilot review (carriedConvergence names the source review): no request is outstanding, no review thread is unresolved, and the carry rule holds (converged-once by default; a provably docs-only or integrate-only delta under refinement.requireCopilotConvergenceAtLatestHead), so `pre_approval_gate` is now the next legal boundary."
1816
1827
  : (ciStatus === "crediblyGreen"
1817
1828
  ? "The current head has a clean settled post-draft review cycle, and its zero-suite CI state is accepted as credibly green, so `pre_approval_gate` is now the next legal boundary."
1818
1829
  : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
@@ -7,7 +7,7 @@
7
7
  * synthesis enter ONLY as a bounded input consumed at the `await_answers`
8
8
  * state (and reflected in the `synthesized` snapshot flag), never as hidden
9
9
  * orchestration inside a deterministic coordinator script (keeps
10
- * OPS-NO-INLINE-INTERPRETER, #1224, clean).
10
+ * OPS-NO-INLINE-INTERPRETER clean).
11
11
  *
12
12
  * Mirrors the shape of `reviewer-loop-state.mjs` / `copilot-loop-state.mjs`:
13
13
  * a frozen STATE vocabulary, a frozen TRANSITIONS adjacency table, a
@@ -17,6 +17,15 @@
17
17
  * Honest handoff: when a gap is genuinely unanswerable (only-`inferred`, no
18
18
  * citation), the machine reaches `needs_human_handoff` naming the question
19
19
  * rather than fabricating an answer to force convergence.
20
+ *
21
+ * Zero-gap provenance (see ADR 0084, which amends ADR 0029): a zero-open-gap
22
+ * `detect_gaps` pass resolves to `grill_clean` only for a `plan` surface
23
+ * (shape-only, no comment surface) or when a `🔬 Grill / refinement results`
24
+ * comment is already recorded on the target; otherwise it stays at
25
+ * `detect_gaps` so the semantic pass still runs and records its own
26
+ * provenance, including a zero-gap outcome. `detectIssueRefinementArtifact`
27
+ * stays the sole shape/completeness predicate; provenance is a separate
28
+ * recorded fact, not a second refinedness detector.
20
29
  */
21
30
 
22
31
  import { trimmedOrNull } from "./normalize.mjs";
@@ -36,7 +45,10 @@ export const GRILL_STATE = Object.freeze({
36
45
  // re_grill, with re_grill either re-entering detect_gaps (a new answerable gap
37
46
  // surfaced) or terminating at grill_clean (fixed point). Any I/O/parse failure
38
47
  // fails closed to blocked_needs_user_decision; any unresolved (uncitable) gap
39
- // terminates honestly at needs_human_handoff.
48
+ // terminates honestly at needs_human_handoff. A zero-open-gap detect_gaps pass
49
+ // terminates at grill_clean only with recorded provenance (plan surface, or a
50
+ // posted results comment); otherwise it stays at detect_gaps for the owed
51
+ // semantic pass (ADR 0084).
40
52
  export const GRILL_TRANSITIONS = Object.freeze({
41
53
  [GRILL_STATE.LOAD_TARGET]: [
42
54
  GRILL_STATE.DETECT_GAPS,
@@ -81,12 +93,76 @@ const GRILL_NEXT_ACTIONS = Object.freeze({
81
93
 
82
94
  const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
83
95
 
96
+ // The exact comment title provenance is keyed on (GRILL-SUBLOOP-RATIONALE-COMMENT).
97
+ const RESULTS_COMMENT_TITLE = "🔬 Grill / refinement results";
98
+ // The exact first-line heading a results comment must carry -- SKILL.md Step 4
99
+ // and the output artifact contract require exactly "## " (one hash pair, one
100
+ // space), never a bare title or a different heading level.
101
+ const RESULTS_COMMENT_HEADING = `## ${RESULTS_COMMENT_TITLE}`;
102
+ // A results comment's recorded bypass line: "bypass: operator-authorized by <handle>",
103
+ // with an optional leading @ before the handle and a case-insensitive "bypass:" key.
104
+ const BYPASS_LINE_RE = /^bypass: operator-authorized by @?([A-Za-z0-9][A-Za-z0-9-]{0,38})\s*$/i;
105
+
84
106
  function normalizeCount(value) {
85
107
  return typeof value === "number" && Number.isFinite(value) && value > 0
86
108
  ? Math.floor(value)
87
109
  : 0;
88
110
  }
89
111
 
112
+ /**
113
+ * Detect recorded grill provenance from a target's comments: a durable
114
+ * `🔬 Grill / refinement results` comment, optionally carrying an
115
+ * `bypass: operator-authorized by <handle>` line. The ephemeral
116
+ * `tmp/issues/issue-<n>/grill/` transcript is never a comment, so it never
117
+ * counts here.
118
+ *
119
+ * @param {Array<string|{body?: string}>} comments
120
+ * @returns {{provenanceRecorded: boolean, bypass: boolean, bypassBy: string|null}}
121
+ */
122
+ export function detectGrillProvenance(comments) {
123
+ if (!Array.isArray(comments)) {
124
+ return { provenanceRecorded: false, bypass: false, bypassBy: null };
125
+ }
126
+
127
+ let provenanceRecorded = false;
128
+ let bypass = false;
129
+ let bypassBy = null;
130
+
131
+ for (const comment of comments) {
132
+ const body = typeof comment === "string"
133
+ ? comment
134
+ : (comment && typeof comment.body === "string" ? comment.body : null);
135
+ if (body === null) continue;
136
+
137
+ const lines = body.split(/\r?\n/);
138
+ // A comment counts as a results comment only when its FIRST non-empty
139
+ // line, trimmed, is EXACTLY the "## " heading -- this rejects a bare
140
+ // title, a different heading level (`###`), a missing space (`##🔬`),
141
+ // the title merely quoted in a code fence, or the title appearing later
142
+ // in an unrelated reply.
143
+ const firstNonEmpty = lines.find((line) => line.trim().length > 0);
144
+ const isResultsComment = firstNonEmpty !== undefined
145
+ && firstNonEmpty.trim() === RESULTS_COMMENT_HEADING;
146
+ if (!isResultsComment) continue;
147
+
148
+ provenanceRecorded = true;
149
+ if (!bypass) {
150
+ // Take the first bypass-line match across all results comments; a
151
+ // later comment's bypass line never overwrites an earlier one.
152
+ for (const line of lines) {
153
+ const match = line.trim().match(BYPASS_LINE_RE);
154
+ if (match) {
155
+ bypass = true;
156
+ bypassBy = match[1];
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return { provenanceRecorded, bypass, bypassBy };
164
+ }
165
+
90
166
  /**
91
167
  * Canonicalize a raw grill snapshot into a deterministic shape.
92
168
  *
@@ -119,19 +195,32 @@ export function normalizeGrillSnapshot(raw) {
119
195
  // post-synthesis re-grill fixed-point signals
120
196
  reGrillRan: Boolean(raw.reGrillRan),
121
197
  reGrillFixedPoint: Boolean(raw.reGrillFixedPoint),
198
+
199
+ // recorded provenance: a posted `🔬 Grill / refinement results` comment
200
+ // (see detectGrillProvenance), and whether it carries a recorded bypass line.
201
+ // A bypass line only ever means anything alongside a recorded comment, so
202
+ // provenanceBypass is forced false when provenanceRecorded is false --
203
+ // never a standalone shortcut to grill_clean.
204
+ provenanceRecorded: Boolean(raw.provenanceRecorded),
205
+ provenanceBypass: Boolean(raw.provenanceRecorded) && Boolean(raw.provenanceBypass),
122
206
  };
123
207
  }
124
208
 
209
+ const PROVENANCE_MISSING_NEXT_ACTION =
210
+ "Run the semantic gap pass on the loaded spec, then post the \"🔬 Grill / refinement results\" comment recording the outcome — including a zero-gap pass, which states that no gaps were found";
211
+
125
212
  /**
126
213
  * Deterministically interpret the current refinement-grill state.
127
214
  *
128
215
  * @param {object} snapshot
129
- * @returns {{state: string, allowedTransitions: string[], nextAction: string}}
216
+ * @returns {{state: string, allowedTransitions: string[], nextAction: string, reason: string|null, bypass: boolean}}
130
217
  */
131
218
  export function interpretRefinementGrillState(snapshot) {
132
219
  const s = normalizeGrillSnapshot(snapshot);
133
220
 
134
221
  let state;
222
+ let reason = null;
223
+ let bypass = false;
135
224
 
136
225
  if (s.loadFailed) {
137
226
  // Fail closed on any load/parse failure, from any point in the loop.
@@ -155,17 +244,40 @@ export function interpretRefinementGrillState(snapshot) {
155
244
  // Bounded answer input present -> apply synthesis.
156
245
  state = GRILL_STATE.SYNTHESIZE;
157
246
  } else if (s.detectRan) {
158
- // Detection ran with no unresolved and no pending answers:
159
- // open gaps -> await answers; zero gaps -> clean fixed point
160
- // (also the already-refined, zero-iteration path).
161
- state = s.openGapCount > 0 ? GRILL_STATE.AWAIT_ANSWERS : GRILL_STATE.GRILL_CLEAN;
247
+ if (s.openGapCount > 0) {
248
+ // Detection ran; answerable gaps still open -> await answers.
249
+ state = GRILL_STATE.AWAIT_ANSWERS;
250
+ } else if (s.surface === "plan") {
251
+ // Local plan files have no comment surface: shape-only, zero-iteration clean.
252
+ state = GRILL_STATE.GRILL_CLEAN;
253
+ reason = "plan_shape_only";
254
+ } else if (s.provenanceBypass) {
255
+ // A recorded bypass line still counts as recorded provenance.
256
+ state = GRILL_STATE.GRILL_CLEAN;
257
+ reason = "provenance_bypass_recorded";
258
+ bypass = true;
259
+ } else if (s.provenanceRecorded) {
260
+ state = GRILL_STATE.GRILL_CLEAN;
261
+ reason = "provenance_recorded";
262
+ } else {
263
+ // Zero open gaps but no recorded provenance: the semantic pass is still
264
+ // owed (ADR 0084) — stay at detect_gaps rather than short-circuiting.
265
+ state = GRILL_STATE.DETECT_GAPS;
266
+ reason = "provenance_missing";
267
+ }
162
268
  } else {
163
269
  state = GRILL_STATE.DETECT_GAPS;
164
270
  }
165
271
 
272
+ const nextAction = reason === "provenance_missing"
273
+ ? PROVENANCE_MISSING_NEXT_ACTION
274
+ : GRILL_NEXT_ACTIONS[state];
275
+
166
276
  return {
167
277
  state,
168
278
  allowedTransitions: [...GRILL_TRANSITIONS[state]],
169
- nextAction: GRILL_NEXT_ACTIONS[state],
279
+ nextAction,
280
+ reason,
281
+ bypass,
170
282
  };
171
283
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * review-operation
3
+ *
4
+ * The ONE operation-scoped authority for the legal dispatchable-angle pool and
5
+ * the reviewer role it authorizes. write-gate-context.mjs's standalone-review
6
+ * angle union and the `dev-loops gate resolve-role` CLI both consume this
7
+ * module instead of reconstructing membership/eligibility rules of their own.
8
+ * The draft/pre-approval/spike dispatch paths keep calling
9
+ * `resolveGateAngleContract` directly for their pool, which this module wraps
10
+ * (see `resolveOperationAnglePool` below) rather than duplicating.
11
+ *
12
+ * `resolveOperationAnglePool` owns only the legal CANDIDATE catalog for an
13
+ * operation. Diff-/tier-/PR-fact-driven SELECTION from that catalog (which
14
+ * angles actually run this round) and any later spec-of-record pruning stay
15
+ * downstream dispatch/planning concerns (see write-gate-context.mjs's
16
+ * `resolveReviewGateAngles`, which imports this module for its union instead
17
+ * of recomputing it).
18
+ *
19
+ * `resolveOperationReviewerRole` is the authoritative exit-code boundary the
20
+ * `dev-loops gate resolve-role` CLI consumes for standalone/defensive role
21
+ * resolution: `ok` is fail-closed true only when the merged config loaded
22
+ * with no errors AND the requested angle is a legal member of the named
23
+ * operation's pool. `status` is a DIAGNOSTIC field only (never a second
24
+ * reviewer decision surface); see skills/docs/gate-review-comment-contract.md
25
+ * and skills/docs/gate-review-sub-loop-contract.md.
26
+ */
27
+ import { resolveGateAngleContract, resolveGateAngles, resolveReviewerRole, resolveRoleModel } from "../config/config.mjs";
28
+ import { GATE_CONFIG_KEY } from "./gate-fanin.mjs";
29
+
30
+ /** The closed review-operation vocabulary. Standalone `review` is only ever selected by name, never inferred. */
31
+ export const REVIEW_OPERATIONS = Object.freeze(["draft_gate", "pre_approval_gate", "review", "spike"]);
32
+
33
+ // GATE_CONFIG_KEY (gate-fanin.mjs) has no `spike` entry — other code relies on
34
+ // its absence of review/spike — so this operation-scoped table adds it locally
35
+ // instead of widening the shared one.
36
+ const OPERATION_GATE_KEY = Object.freeze({ ...GATE_CONFIG_KEY, spike: "spike" });
37
+
38
+ /**
39
+ * Resolve the legal candidate angle pool for a review operation from the
40
+ * fully merged config:
41
+ * - draft_gate / pre_approval_gate / spike: the effective `gates.<key>`
42
+ * pool (`resolveGateAngleContract(config, key).pool` — mandatory/static/
43
+ * additive rules applied, disabled/excluded angles removed).
44
+ * - review: the de-duplicated, order-stable union of the static draft and
45
+ * pre-approval angle sets (`resolveGateAngles`, NOT the additive/tier
46
+ * pool) — standalone review's existing dedicated semantics; it gains no
47
+ * diff-tier or additive selection here.
48
+ * An unrecognized operation throws (arg-identity error, not a config-layer
49
+ * concern; callers validate `--gate` before ever reaching config load).
50
+ * @param {import("../config/config.mjs").DevLoopConfig} config
51
+ * @param {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
52
+ * @returns {string[]}
53
+ */
54
+ export function resolveOperationAnglePool(config, operation) {
55
+ if (!REVIEW_OPERATIONS.includes(operation)) {
56
+ throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
57
+ }
58
+ if (operation === "review") {
59
+ return [...new Set([
60
+ ...(resolveGateAngles(config, "draft") ?? []),
61
+ ...(resolveGateAngles(config, "preApproval") ?? []),
62
+ ])];
63
+ }
64
+ return resolveGateAngleContract(config, OPERATION_GATE_KEY[operation]).pool ?? [];
65
+ }
66
+
67
+ /**
68
+ * @typedef {object} OperationReviewerRoleResult
69
+ * @property {boolean} ok - Fail-closed reviewer safety boundary: `configErrors.length === 0 && pool.includes(angle)`.
70
+ * @property {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
71
+ * @property {string} angle
72
+ * @property {"claude"|"pi"} harness
73
+ * @property {string} persona
74
+ * @property {string|null} prompt
75
+ * @property {string|null} model - Authoritative merged model tier (`resolveRoleModel(..., { kind: "angle" })`).
76
+ * @property {boolean} fallback
77
+ * @property {"config-error"|"non-member"|"fallback"|"prompt-missing"|"resolved"} status - Diagnostic only; never a reviewer decision branch.
78
+ * @property {string[]} warnings
79
+ * @property {Array<unknown>} configErrors
80
+ */
81
+
82
+ /**
83
+ * Resolve one angle's reviewer role for a review operation, authorized
84
+ * against `resolveOperationAnglePool`. This is the shared authority a CLI
85
+ * (`gate resolve-role`), dispatch/planning, or defensive validation all call —
86
+ * none of them may reimplement membership/union/additive/disabled/spike
87
+ * classification locally.
88
+ * @param {{ config: import("../config/config.mjs").DevLoopConfig, errors?: Array<unknown> }} loadResult - the `{ config, errors }` shape `loadDevLoopConfig` returns.
89
+ * @param {{ operation: "draft_gate"|"pre_approval_gate"|"review"|"spike", angle: string, harness: "claude"|"pi" }} params
90
+ * @returns {OperationReviewerRoleResult}
91
+ */
92
+ export function resolveOperationReviewerRole(loadResult, { operation, angle, harness }) {
93
+ const config = loadResult?.config;
94
+ const configErrors = Array.isArray(loadResult?.errors) ? loadResult.errors : [];
95
+ const configErrorPresent = configErrors.length > 0;
96
+ // An unrecognized operation is a closed-vocabulary argument failure, not a
97
+ // config-layer concern; it must throw unconditionally, even when a config
98
+ // error is also on record — never degraded to a misleading config-error
99
+ // result below. Validate before the try so this throw is never swallowed.
100
+ if (!REVIEW_OPERATIONS.includes(operation)) {
101
+ throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
102
+ }
103
+ let pool;
104
+ try {
105
+ pool = resolveOperationAnglePool(config, operation);
106
+ } catch (error) {
107
+ // A config that already failed merged schema validation (non-empty
108
+ // `errors` from loadDevLoopConfig) can still reach here with a gate shape
109
+ // resolveGateConfig itself rejects (e.g. an invalid
110
+ // `gates.<gate>.blockCleanOnFindingSeverities`). That is a config-layer
111
+ // failure the caller's own config-error fail-closed status already
112
+ // covers, so degrade to an empty pool instead of an unhandled exception
113
+ // reaching the reviewer boundary. With no config error on record, this is
114
+ // a real schema-invalid gate shape a caller must see: rethrow.
115
+ if (!configErrorPresent) throw error;
116
+ pool = [];
117
+ }
118
+ const member = pool.includes(angle);
119
+ const ok = !configErrorPresent && member;
120
+
121
+ const role = resolveReviewerRole(config, angle);
122
+ const model = resolveRoleModel(config, { role: angle, harness, kind: "angle" });
123
+
124
+ const warnings = [];
125
+ let status;
126
+ if (configErrorPresent) {
127
+ status = "config-error";
128
+ warnings.push(
129
+ `${configErrors.length} config-layer error(s); the resolved role may be a shipped default and must not be trusted.`,
130
+ );
131
+ } else if (!member) {
132
+ status = "non-member";
133
+ warnings.push(`angle '${angle}' is not a member of the '${operation}' operation's legal candidate pool.`);
134
+ } else if (role.fallback) {
135
+ status = "fallback";
136
+ warnings.push(
137
+ `angle '${angle}' is authorized for '${operation}' but has no dedicated persona/prompt entry; the default-reviewer persona is returned.`,
138
+ );
139
+ } else if (typeof role.prompt !== "string" || role.prompt.trim() === "") {
140
+ status = "prompt-missing";
141
+ warnings.push(
142
+ `angle '${angle}' resolved persona '${role.persona}' but its focus prompt is null/empty; review with no angle-specific focus instruction.`,
143
+ );
144
+ } else {
145
+ status = "resolved";
146
+ }
147
+
148
+ return {
149
+ ok,
150
+ operation,
151
+ angle,
152
+ harness,
153
+ persona: role.persona,
154
+ prompt: role.prompt,
155
+ model,
156
+ fallback: role.fallback,
157
+ status,
158
+ warnings,
159
+ configErrors,
160
+ };
161
+ }