@dev-loops/core 0.6.0 → 0.7.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 +7 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +247 -98
- package/src/config/extension-defaults.yaml +6 -11
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +137 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +259 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +158 -238
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +52 -8
- package/src/loop/queue-board-sync.mjs +62 -3
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
|
|
1
|
+
import { DISPOSITION, isCopilotRoundCapReached, STATE } from "./copilot-loop-state.mjs";
|
|
2
2
|
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
3
3
|
import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
|
|
4
4
|
|
|
@@ -238,160 +238,6 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
|
|
|
238
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.`;
|
|
239
239
|
}
|
|
240
240
|
|
|
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 } = {}) {
|
|
258
|
-
if (!checkpoint || typeof checkpoint !== "object") {
|
|
259
|
-
return { approved: false, reason: "No retrospective checkpoint was found." };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const state = typeof checkpoint.state === "string" ? checkpoint.state.trim().toLowerCase() : "";
|
|
263
|
-
if (state !== "complete") {
|
|
264
|
-
return { approved: false, reason: `Retrospective is not complete (state: ${state || "missing"}).` };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Read merge approval from behavioralReview (existing format) or top-level (future flat format).
|
|
268
|
-
const br = checkpoint.behavioralReview && typeof checkpoint.behavioralReview === "object"
|
|
269
|
-
? checkpoint.behavioralReview
|
|
270
|
-
: null;
|
|
271
|
-
const mergeApproved = br !== null ? br.mergeApproved : checkpoint.mergeApproved;
|
|
272
|
-
if (mergeApproved !== true) {
|
|
273
|
-
return { approved: false, reason: "Retrospective does not explicitly approve merge (`mergeApproved: true` is required)." };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// followedWorkingAgreement: required boolean (existing checkpoint uses behavioralReview.followedWorkingAgreement).
|
|
277
|
-
const followedWorkingAgreement = br !== null
|
|
278
|
-
? br.followedWorkingAgreement
|
|
279
|
-
: checkpoint.followedWorkingAgreement;
|
|
280
|
-
if (typeof followedWorkingAgreement !== "boolean") {
|
|
281
|
-
return { approved: false, reason: "Retrospective is missing `followedWorkingAgreement` (true/false)." };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// gateQuality: require gateQualityAcceptable=true AND non-empty notes (behavioralReview)
|
|
285
|
-
// or explicit gateQuality string (flat format). Avoid empty-notes bypass.
|
|
286
|
-
const gateQualityAcceptable = br !== null
|
|
287
|
-
? br.gateQualityAcceptable
|
|
288
|
-
: checkpoint.gateQualityAcceptable;
|
|
289
|
-
if (typeof gateQualityAcceptable !== "boolean" || gateQualityAcceptable !== true) {
|
|
290
|
-
return { approved: false, reason: `Retrospective gate quality is not explicitly acceptable (gateQualityAcceptable: ${String(gateQualityAcceptable)}).` };
|
|
291
|
-
}
|
|
292
|
-
const gateQuality = typeof checkpoint.gateQuality === "string" && checkpoint.gateQuality.trim().length > 0
|
|
293
|
-
? checkpoint.gateQuality
|
|
294
|
-
: null;
|
|
295
|
-
if (!gateQuality) {
|
|
296
|
-
return { approved: false, reason: "Retrospective is missing `gateQuality` details; provide a notes field with gate-quality assessment or an explicit gateQuality string." };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// unexpectedFindings: derive from behavioralReview.drifts if flat field absent. Empty array is valid (no findings).
|
|
300
|
-
const unexpectedFindings = typeof checkpoint.unexpectedFindings === "string" && checkpoint.unexpectedFindings.trim().length > 0
|
|
301
|
-
? checkpoint.unexpectedFindings
|
|
302
|
-
: (br !== null && Array.isArray(br.drifts)
|
|
303
|
-
? (br.drifts.length > 0 ? br.drifts.join("; ") : "none")
|
|
304
|
-
: null);
|
|
305
|
-
if (!unexpectedFindings) {
|
|
306
|
-
return { approved: false, reason: "Retrospective is missing `unexpectedFindings` details." };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// mergeRecommendation: require explicit mergeRecommendation field (string).
|
|
310
|
-
const mergeRecommendation = typeof checkpoint.mergeRecommendation === "string" && checkpoint.mergeRecommendation.trim().length > 0
|
|
311
|
-
? checkpoint.mergeRecommendation
|
|
312
|
-
: null;
|
|
313
|
-
if (!mergeRecommendation) {
|
|
314
|
-
return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
|
|
315
|
-
}
|
|
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
|
-
|
|
348
|
-
return { approved: true, reason: null };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function buildRetrospectiveGatePendingResult({
|
|
352
|
-
input,
|
|
353
|
-
currentHeadSha,
|
|
354
|
-
draftGateAlreadySatisfied,
|
|
355
|
-
draftGate,
|
|
356
|
-
preApprovalGate,
|
|
357
|
-
mergeStateStatus,
|
|
358
|
-
conflictFiles,
|
|
359
|
-
reason,
|
|
360
|
-
refinementArtifact = null,
|
|
361
|
-
}) {
|
|
362
|
-
const allowedNextActions = [];
|
|
363
|
-
const forbiddenActions = [];
|
|
364
|
-
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
365
|
-
pushUnique(forbiddenActions, [
|
|
366
|
-
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
367
|
-
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
368
|
-
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
369
|
-
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
370
|
-
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
371
|
-
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
372
|
-
]);
|
|
373
|
-
|
|
374
|
-
return buildResult({
|
|
375
|
-
repo: input.repo ?? null,
|
|
376
|
-
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
377
|
-
currentHeadSha,
|
|
378
|
-
lifecycleState: "retrospective_gate_pending",
|
|
379
|
-
loopDisposition: DISPOSITION.BLOCKED,
|
|
380
|
-
gateBoundary: PR_CHECKPOINT.BLOCKED,
|
|
381
|
-
draftGateAlreadySatisfied,
|
|
382
|
-
draftGate,
|
|
383
|
-
preApprovalGate,
|
|
384
|
-
allowedNextActions,
|
|
385
|
-
forbiddenActions,
|
|
386
|
-
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
387
|
-
reason,
|
|
388
|
-
mergeStateStatus,
|
|
389
|
-
conflictFiles,
|
|
390
|
-
refinementArtifact,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
241
|
/**
|
|
396
242
|
* Blocked result for a PR that would otherwise reach final_approval_ready but
|
|
397
243
|
* still carries a merge-blocking marker in its title (issue #842). The title is
|
|
@@ -551,6 +397,9 @@ function buildResult({
|
|
|
551
397
|
* the current head, including a post-cap head Copilot has not (and will not)
|
|
552
398
|
* re-review. No further Copilot round is permitted, so the formal-request guard
|
|
553
399
|
* must not fire — the pre_approval_gate reviews the post-cap head (per #848).
|
|
400
|
+
* @param {boolean} [params.postConvergenceSignificantChange=false] - significant
|
|
401
|
+
* post-convergence changes on a newer head start a new review cycle and must
|
|
402
|
+
* not be treated as round-cap clean-fallback suppression.
|
|
554
403
|
* @param {string} params.gateBoundary - current gate boundary
|
|
555
404
|
* @returns {boolean}
|
|
556
405
|
*/
|
|
@@ -561,6 +410,7 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
561
410
|
maxCopilotRounds = null,
|
|
562
411
|
sameHeadCleanConverged = false,
|
|
563
412
|
roundCapCleanFallback = false,
|
|
413
|
+
postConvergenceSignificantChange = false,
|
|
564
414
|
gateBoundary,
|
|
565
415
|
}) {
|
|
566
416
|
const gateBoundariesRequiringCopilotFormalRequest = new Set([
|
|
@@ -593,10 +443,12 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
593
443
|
// CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
|
|
594
444
|
// Copilot round is permitted, so forcing a formal request would dead-end the loop;
|
|
595
445
|
// the pre_approval_gate reviews the post-cap head instead (per #848).
|
|
596
|
-
const roundCapReached = maxCopilotRounds
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
446
|
+
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
447
|
+
if (
|
|
448
|
+
roundCapReached
|
|
449
|
+
&& (sameHeadCleanConverged || roundCapCleanFallback)
|
|
450
|
+
&& !postConvergenceSignificantChange
|
|
451
|
+
) {
|
|
600
452
|
return false;
|
|
601
453
|
}
|
|
602
454
|
return true;
|
|
@@ -618,6 +470,110 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
|
|
|
618
470
|
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
619
471
|
]);
|
|
620
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Independent gate-ENTRY re-check (issue #1190): even when the caller's
|
|
475
|
+
* lifecycleState/sameHeadCleanConverged claims a settled Copilot convergence,
|
|
476
|
+
* an outstanding (`requested`/`already-requested`) Copilot review request on
|
|
477
|
+
* the CURRENT head is a second, independent "unsettled" signal — not derived
|
|
478
|
+
* from sameHeadCleanConverged — that must refuse pre_approval_gate /
|
|
479
|
+
* final-approval entry outright.
|
|
480
|
+
*
|
|
481
|
+
* This mirrors the fail-closed predicate that previously only fired at
|
|
482
|
+
* *verdict-post* time (upsert-checkpoint-verdict.mjs, which refuses to post a
|
|
483
|
+
* pre_approval_gate verdict while this same evaluator forbids
|
|
484
|
+
* RUN_PRE_APPROVAL_GATE): asserting it here, at gate *entry*, refuses the
|
|
485
|
+
* pre-approval fan-out up front instead of only after reviewer tokens have
|
|
486
|
+
* already been spent.
|
|
487
|
+
*
|
|
488
|
+
* Skipped when Copilot review is not required at all — `reviewMode:
|
|
489
|
+
* "internal_only"` or `maxCopilotRounds: 0` — preserving the existing #613 /
|
|
490
|
+
* #1210 exemptions (internal-only and light-dispatched-with-disabled-review
|
|
491
|
+
* PRs never need a Copilot round in the first place).
|
|
492
|
+
*/
|
|
493
|
+
const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
|
|
494
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
495
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
496
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
500
|
+
if (!result || typeof result !== "object" || !PRE_APPROVAL_ENTRY_BOUNDARIES.includes(result.gateBoundary)) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
if (input.maxCopilotRounds === 0) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const reviewMode = typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null;
|
|
507
|
+
if (reviewMode === "internal_only") {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
|
|
511
|
+
? input.copilotReviewRequestStatus.trim().toLowerCase()
|
|
512
|
+
: "none";
|
|
513
|
+
if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
// Round-cap exemption (mirrors shouldGuardCopilotReviewRequest, #896/#848):
|
|
517
|
+
// past the cap a lingering requested/already-requested status is for a review
|
|
518
|
+
// that can never come (no further round is permitted), so treating it as
|
|
519
|
+
// "unsettled" here would re-introduce the infinite-wait dead-end the
|
|
520
|
+
// ROUND_CAP_CLEAN_FALLBACK routing exists to prevent. When the cap is reached
|
|
521
|
+
// and the head is clean — either sameHeadCleanConverged or the interpreter's
|
|
522
|
+
// round_cap_clean_fallback state — the pre_approval_gate proceeds unless
|
|
523
|
+
// significant post-convergence changes require a new review cycle.
|
|
524
|
+
const roundCapReached = isCopilotRoundCapReached({
|
|
525
|
+
copilotReviewRoundCount: input.copilotReviewRoundCount,
|
|
526
|
+
maxCopilotRounds: input.maxCopilotRounds,
|
|
527
|
+
});
|
|
528
|
+
const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
|
|
529
|
+
const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
530
|
+
if (
|
|
531
|
+
roundCapReached
|
|
532
|
+
&& (input.sameHeadCleanConverged === true || roundCapCleanFallback)
|
|
533
|
+
&& input.postConvergenceSignificantChange !== true
|
|
534
|
+
) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const allowedNextActions = [];
|
|
539
|
+
const forbiddenActions = [];
|
|
540
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
|
|
541
|
+
// Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
|
|
542
|
+
// result this guard synthesizes) plus the final-approval actions the replaced
|
|
543
|
+
// boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
|
|
544
|
+
// here would let a draft_gate verdict post on a non-draft PR slip through where
|
|
545
|
+
// the replaced result would have refused it.
|
|
546
|
+
pushUnique(forbiddenActions, [
|
|
547
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
548
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
549
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
550
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
551
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
return buildResult({
|
|
555
|
+
repo: input.repo ?? null,
|
|
556
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
557
|
+
currentHeadSha: result.currentHeadSha ?? null,
|
|
558
|
+
lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
559
|
+
loopDisposition: DISPOSITION.PENDING,
|
|
560
|
+
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
561
|
+
draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
|
|
562
|
+
draftGate: result.draftGate,
|
|
563
|
+
preApprovalGate: result.preApprovalGate,
|
|
564
|
+
allowedNextActions,
|
|
565
|
+
forbiddenActions,
|
|
566
|
+
nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
|
|
567
|
+
reason: "A Copilot review request is still outstanding on the current head (independent gate-entry "
|
|
568
|
+
+ "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
|
|
569
|
+
+ "review settles, even though the caller-reported convergence signal claims otherwise.",
|
|
570
|
+
mergeStateStatus: result.mergeStateStatus ?? null,
|
|
571
|
+
conflictFiles: result.conflictFiles ?? [],
|
|
572
|
+
refinementArtifact: result.refinementArtifact ?? null,
|
|
573
|
+
copilotReviewRoundCount: normalizeNonNegativeInteger(input.copilotReviewRoundCount),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
621
577
|
/**
|
|
622
578
|
* Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
|
|
623
579
|
* (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
|
|
@@ -630,6 +586,11 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
|
|
|
630
586
|
export function evaluatePrGateCoordination(input = {}) {
|
|
631
587
|
const result = evaluatePrGateCoordinationCore(input);
|
|
632
588
|
|
|
589
|
+
const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
|
|
590
|
+
if (unsettledReviewResult) {
|
|
591
|
+
return unsettledReviewResult;
|
|
592
|
+
}
|
|
593
|
+
|
|
633
594
|
const prDraft = input.prDraft === true;
|
|
634
595
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
635
596
|
// Draft PRs may legitimately carry a WIP title; the marker only blocks once
|
|
@@ -683,12 +644,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
683
644
|
const draftGateRequireCi = input.draftGateRequireCi !== false;
|
|
684
645
|
const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
|
|
685
646
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
686
|
-
const roundCapReached =
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
// internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
|
|
690
|
-
const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
|
|
691
|
-
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
647
|
+
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
648
|
+
const postConvergenceSignificantChange = input.postConvergenceSignificantChange === true;
|
|
649
|
+
const roundCapNewCycleRequired = roundCapReached && copilotReviewRoundCount > 0 && postConvergenceSignificantChange;
|
|
692
650
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
693
651
|
// UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
|
|
694
652
|
// e2e suite passed for this head. Inclusion is path-triggered, never annotated.
|
|
@@ -849,7 +807,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
849
807
|
// registered in the shared UI e2e suite AND that suite must have passed for
|
|
850
808
|
// this head. A rendered-artifact change with no registered/passing coverage
|
|
851
809
|
// blocks here with a reason naming the artifact. Distinct seam from the
|
|
852
|
-
// mergeability (#980)
|
|
810
|
+
// mergeability (#980) preconditions to minimize
|
|
853
811
|
// merge-time conflict. Non-UI changes pass through untouched (required=false).
|
|
854
812
|
const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
|
|
855
813
|
if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
|
|
@@ -1061,24 +1019,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1061
1019
|
refinementArtifact,
|
|
1062
1020
|
});
|
|
1063
1021
|
}
|
|
1064
|
-
if (requireRetrospectiveGate) {
|
|
1065
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1066
|
-
if (!retrospectiveGate.approved) {
|
|
1067
|
-
return buildRetrospectiveGatePendingResult({
|
|
1068
|
-
input,
|
|
1069
|
-
currentHeadSha,
|
|
1070
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1071
|
-
draftGate,
|
|
1072
|
-
preApprovalGate,
|
|
1073
|
-
mergeStateStatus,
|
|
1074
|
-
conflictFiles,
|
|
1075
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1076
|
-
refinementArtifact,
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
1022
|
if (!draftGate.cleanEvidenceExists) {
|
|
1083
1023
|
return buildDraftGateNeededForMergeResult({
|
|
1084
1024
|
input,
|
|
@@ -1291,11 +1231,11 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1291
1231
|
});
|
|
1292
1232
|
}
|
|
1293
1233
|
|
|
1294
|
-
const roundExhaustionGateEvidenceNote = roundCapReached
|
|
1234
|
+
const roundExhaustionGateEvidenceNote = (roundCapReached && !roundCapNewCycleRequired)
|
|
1295
1235
|
? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds })
|
|
1296
1236
|
: null;
|
|
1297
1237
|
|
|
1298
|
-
if (!sameHeadCleanConverged && !roundCapReached) {
|
|
1238
|
+
if (!sameHeadCleanConverged && (!roundCapReached || roundCapNewCycleRequired)) {
|
|
1299
1239
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1300
1240
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1301
1241
|
return buildResult({
|
|
@@ -1311,7 +1251,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1311
1251
|
allowedNextActions,
|
|
1312
1252
|
forbiddenActions,
|
|
1313
1253
|
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1314
|
-
reason:
|
|
1254
|
+
reason: roundCapNewCycleRequired
|
|
1255
|
+
? "The previous Copilot cycle converged at the round cap, but significant post-convergence changes landed on a newer head; start a new Copilot review cycle and re-request review before `pre_approval_gate`."
|
|
1256
|
+
: "The review loop is between passes, but the current head does not yet have a clean settled Copilot convergence point, so `pre_approval_gate` is still forbidden.",
|
|
1315
1257
|
mergeStateStatus,
|
|
1316
1258
|
conflictFiles,
|
|
1317
1259
|
refinementArtifact,
|
|
@@ -1333,23 +1275,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1333
1275
|
refinementArtifact,
|
|
1334
1276
|
});
|
|
1335
1277
|
}
|
|
1336
|
-
if (requireRetrospectiveGate) {
|
|
1337
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1338
|
-
if (!retrospectiveGate.approved) {
|
|
1339
|
-
return buildRetrospectiveGatePendingResult({
|
|
1340
|
-
input,
|
|
1341
|
-
currentHeadSha,
|
|
1342
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1343
|
-
draftGate,
|
|
1344
|
-
preApprovalGate,
|
|
1345
|
-
mergeStateStatus,
|
|
1346
|
-
conflictFiles,
|
|
1347
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1348
|
-
refinementArtifact,
|
|
1349
|
-
});
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
1278
|
|
|
1354
1279
|
if (!draftGate.cleanEvidenceExists && !roundCapReached) {
|
|
1355
1280
|
return buildDraftGateNeededForMergeResult({
|
|
@@ -1438,6 +1363,35 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1438
1363
|
// blocked states are handled earlier, so genuinely-blocked states still forbid
|
|
1439
1364
|
// pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
|
|
1440
1365
|
if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
1366
|
+
if (roundCapNewCycleRequired) {
|
|
1367
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1368
|
+
pushUnique(forbiddenActions, [
|
|
1369
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1370
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1371
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1372
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1373
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1374
|
+
]);
|
|
1375
|
+
return buildResult({
|
|
1376
|
+
repo: input.repo ?? null,
|
|
1377
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1378
|
+
currentHeadSha,
|
|
1379
|
+
lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
|
|
1380
|
+
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
1381
|
+
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
1382
|
+
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1383
|
+
draftGate,
|
|
1384
|
+
preApprovalGate,
|
|
1385
|
+
allowedNextActions,
|
|
1386
|
+
forbiddenActions,
|
|
1387
|
+
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1388
|
+
reason: `The previous Copilot cycle converged at the round cap (${copilotReviewRoundCount}/${maxCopilotRounds}), but significant post-convergence changes landed on the current head. Open a new cycle and re-request Copilot review before entering \`pre_approval_gate\`.`,
|
|
1389
|
+
mergeStateStatus,
|
|
1390
|
+
conflictFiles,
|
|
1391
|
+
refinementArtifact,
|
|
1392
|
+
copilotReviewRoundCount,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1441
1395
|
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1442
1396
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1443
1397
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
@@ -1499,23 +1453,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1499
1453
|
refinementArtifact,
|
|
1500
1454
|
});
|
|
1501
1455
|
}
|
|
1502
|
-
if (requireRetrospectiveGate) {
|
|
1503
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1504
|
-
if (!retrospectiveGate.approved) {
|
|
1505
|
-
return buildRetrospectiveGatePendingResult({
|
|
1506
|
-
input,
|
|
1507
|
-
currentHeadSha,
|
|
1508
|
-
draftGateAlreadySatisfied: true,
|
|
1509
|
-
draftGate,
|
|
1510
|
-
preApprovalGate,
|
|
1511
|
-
mergeStateStatus,
|
|
1512
|
-
conflictFiles,
|
|
1513
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1514
|
-
refinementArtifact,
|
|
1515
|
-
});
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
1456
|
// Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
|
|
1520
1457
|
// draft_gate evidence must reconcile the draft gate rather than jump to
|
|
1521
1458
|
// final approval. This keeps the core handler consistent with the
|
|
@@ -1659,23 +1596,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1659
1596
|
refinementArtifact,
|
|
1660
1597
|
});
|
|
1661
1598
|
}
|
|
1662
|
-
if (requireRetrospectiveGate) {
|
|
1663
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1664
|
-
if (!retrospectiveGate.approved) {
|
|
1665
|
-
return buildRetrospectiveGatePendingResult({
|
|
1666
|
-
input,
|
|
1667
|
-
currentHeadSha,
|
|
1668
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1669
|
-
draftGate,
|
|
1670
|
-
preApprovalGate,
|
|
1671
|
-
mergeStateStatus,
|
|
1672
|
-
conflictFiles,
|
|
1673
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1674
|
-
refinementArtifact,
|
|
1675
|
-
});
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
1599
|
|
|
1680
1600
|
if (!draftGate.cleanEvidenceExists) {
|
|
1681
1601
|
return buildDraftGateNeededForMergeResult({
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR lifecycle: the 13-state vocabulary + required transitions from
|
|
3
|
+
* skills/docs/pr-lifecycle-contract.md (issue #1193), promoted to a real
|
|
4
|
+
* exported contract surface.
|
|
5
|
+
*
|
|
6
|
+
* This is the single source of truth for the family-local PR lifecycle graph:
|
|
7
|
+
* both scripts/pages/build-state-atlas.mjs (site diagram generator) and
|
|
8
|
+
* scripts/docs/validate-state-machine-conformance.mjs (the L2/L3 conformance
|
|
9
|
+
* harness) import this same table, instead of one importing the other's
|
|
10
|
+
* module (which would pull the whole page generator — eager mermaid diagram
|
|
11
|
+
* rendering, duplicate core module instances via relative imports — into the
|
|
12
|
+
* harness's process at load time).
|
|
13
|
+
*
|
|
14
|
+
* Pure data + one derivation, no imports, no side effects.
|
|
15
|
+
*/
|
|
16
|
+
export const PR_LIFECYCLE_STATES = Object.freeze([
|
|
17
|
+
'draft_local_review_gate',
|
|
18
|
+
'draft_local_remediation',
|
|
19
|
+
'ready_state_needs_copilot_request',
|
|
20
|
+
'waiting_for_copilot_review',
|
|
21
|
+
'copilot_feedback_remediation',
|
|
22
|
+
'copilot_reply_resolve_pending',
|
|
23
|
+
'merge_conflict_resolution',
|
|
24
|
+
'final_local_preapproval_gate',
|
|
25
|
+
'final_gate_remediation',
|
|
26
|
+
'waiting_for_human_pr_approval',
|
|
27
|
+
'waiting_for_merge',
|
|
28
|
+
'terminal_slice_complete',
|
|
29
|
+
'stopped_needs_user_decision',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// '[*]' is the synthetic terminal-marker target (see build-state-atlas.mjs's
|
|
33
|
+
// renderStateDiagram and validate-state-machine-conformance.mjs's realEdges):
|
|
34
|
+
// a row `[state, '[*]']` marks `state` as absorbing without being a real edge.
|
|
35
|
+
const TERMINAL_MARKER = '[*]';
|
|
36
|
+
|
|
37
|
+
export const PR_LIFECYCLE_TRANSITIONS = Object.freeze([
|
|
38
|
+
Object.freeze(['draft_local_review_gate', 'draft_local_remediation']),
|
|
39
|
+
Object.freeze(['draft_local_review_gate', 'ready_state_needs_copilot_request']),
|
|
40
|
+
Object.freeze(['draft_local_review_gate', 'stopped_needs_user_decision']),
|
|
41
|
+
Object.freeze(['draft_local_remediation', 'draft_local_review_gate']),
|
|
42
|
+
Object.freeze(['ready_state_needs_copilot_request', 'waiting_for_copilot_review']),
|
|
43
|
+
Object.freeze(['ready_state_needs_copilot_request', 'stopped_needs_user_decision']),
|
|
44
|
+
Object.freeze(['waiting_for_copilot_review', 'copilot_feedback_remediation']),
|
|
45
|
+
Object.freeze(['copilot_feedback_remediation', 'copilot_reply_resolve_pending']),
|
|
46
|
+
Object.freeze(['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request']),
|
|
47
|
+
Object.freeze(['waiting_for_copilot_review', 'merge_conflict_resolution']),
|
|
48
|
+
Object.freeze(['merge_conflict_resolution', 'waiting_for_copilot_review']),
|
|
49
|
+
Object.freeze(['waiting_for_copilot_review', 'final_local_preapproval_gate']),
|
|
50
|
+
Object.freeze(['final_local_preapproval_gate', 'final_gate_remediation']),
|
|
51
|
+
Object.freeze(['final_local_preapproval_gate', 'waiting_for_human_pr_approval']),
|
|
52
|
+
Object.freeze(['final_gate_remediation', 'final_local_preapproval_gate']),
|
|
53
|
+
Object.freeze(['waiting_for_human_pr_approval', 'waiting_for_merge']),
|
|
54
|
+
Object.freeze(['waiting_for_human_pr_approval', 'draft_local_review_gate']),
|
|
55
|
+
Object.freeze(['waiting_for_merge', 'terminal_slice_complete']),
|
|
56
|
+
Object.freeze(['terminal_slice_complete', TERMINAL_MARKER]),
|
|
57
|
+
Object.freeze(['stopped_needs_user_decision', TERMINAL_MARKER]),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Derived, not hand-listed (lesson from #1157: a hand-copied terminal list can
|
|
61
|
+
// silently drift from the transition table it is supposed to describe). A
|
|
62
|
+
// state is terminal when it has zero real (non-marker) outgoing edges.
|
|
63
|
+
function deriveTerminalStates(states, transitions) {
|
|
64
|
+
const hasRealOutgoing = new Set(
|
|
65
|
+
transitions.filter(([, to]) => to !== TERMINAL_MARKER).map(([from]) => from),
|
|
66
|
+
);
|
|
67
|
+
return states.filter((state) => !hasRealOutgoing.has(state));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const PR_LIFECYCLE_TERMINAL_STATES = Object.freeze(deriveTerminalStates(PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS));
|
|
71
|
+
|
|
72
|
+
// Enum-style access (SCREAMING_SNAKE_CASE key -> the same state string), so
|
|
73
|
+
// handoff scripts can reference `PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST`
|
|
74
|
+
// instead of hardcoding the literal, mirroring the STATE/OUTER_STATE/REVIEWER_STATE
|
|
75
|
+
// convention used by the other loop state machines. Derived from PR_LIFECYCLE_STATES
|
|
76
|
+
// so a new state cannot be added to one without the other.
|
|
77
|
+
export const PR_LIFECYCLE_STATE = Object.freeze(
|
|
78
|
+
Object.fromEntries(PR_LIFECYCLE_STATES.map((state) => [state.toUpperCase(), state])),
|
|
79
|
+
);
|
|
@@ -1304,9 +1304,9 @@ export function resolveAuthoritativeStartupResumeBundle(input = {}) {
|
|
|
1304
1304
|
}
|
|
1305
1305
|
|
|
1306
1306
|
|
|
1307
|
-
const BUILT_IN_DEFAULT_TARGET_PREFERENCE = DEV_LOOP_TARGET_PREFERENCE.
|
|
1307
|
+
const BUILT_IN_DEFAULT_TARGET_PREFERENCE = DEV_LOOP_TARGET_PREFERENCE.PREFER_LOCAL;
|
|
1308
1308
|
|
|
1309
|
-
// DEFAULT_TARGET_PREFERENCE uses the built-in default (
|
|
1309
|
+
// DEFAULT_TARGET_PREFERENCE uses the built-in default (local-first).
|
|
1310
1310
|
// Config-based target preference is resolved by the startup resolver
|
|
1311
1311
|
// (resolveTargetPreference in scripts/loop/resolve-dev-loop-startup.mjs)
|
|
1312
1312
|
// and passed explicitly via input.targetPreference.
|