@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 +2 -1
- package/src/config/config.mjs +15 -0
- package/src/loop/copilot-loop-state.mjs +6 -2
- package/src/loop/merge-approval.mjs +41 -10
- package/src/loop/pr-gate-coordination.mjs +20 -9
- package/src/loop/refinement-grill-state.mjs +120 -8
- package/src/loop/review-operation.mjs +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.4-pre.
|
|
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",
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
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
|
|
682
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
+
}
|