@dev-loops/core 0.5.0 → 0.7.1
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 +4 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/asset-generation.mjs +26 -0
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/async-start-contract.mjs +9 -2
- 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 +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +94 -237
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/ui-e2e-scoping.mjs +162 -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,11 +1,13 @@
|
|
|
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
|
+
import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
|
|
3
4
|
|
|
4
5
|
export const PR_CHECKPOINT = Object.freeze({
|
|
5
6
|
DRAFT_REVIEW: "draft_review",
|
|
6
7
|
POST_DRAFT_EXTERNAL_REVIEW: "post_draft_external_review",
|
|
7
8
|
FEEDBACK_RESOLUTION: "feedback_resolution",
|
|
8
9
|
CONFLICT_RESOLUTION: "conflict_resolution",
|
|
10
|
+
UI_E2E_SCOPING: "ui_e2e_scoping",
|
|
9
11
|
PRE_APPROVAL_GATE_WINDOW: "pre_approval_gate_window",
|
|
10
12
|
FINAL_APPROVAL_READY: "final_approval_ready",
|
|
11
13
|
PRE_APPROVAL_GATE_NEEDED: "pre_approval_gate_needed",
|
|
@@ -48,6 +50,7 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
|
|
|
48
50
|
RECONCILE_DRAFT_GATE: "reconcile_draft_gate",
|
|
49
51
|
REPORT_BLOCKED: "report_blocked",
|
|
50
52
|
REPORT_DONE: "report_done",
|
|
53
|
+
RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
function normalizeGateComment(summary = null) {
|
|
@@ -235,160 +238,6 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
|
|
|
235
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.`;
|
|
236
239
|
}
|
|
237
240
|
|
|
238
|
-
/**
|
|
239
|
-
* Render the (user-authored) rawCallViolations array into a bounded, single-line
|
|
240
|
-
* fragment for the gate failure reason. Collapses whitespace/newlines per entry,
|
|
241
|
-
* caps per-entry length, and caps the number of entries shown so a large or
|
|
242
|
-
* garbled checkpoint cannot bloat or break gate output. Still fails closed —
|
|
243
|
-
* this only formats the reason; the violation count above does the gating.
|
|
244
|
-
*/
|
|
245
|
-
function summarizeRawCallViolations(violations, { maxEntries = 10, maxEntryLen = 200 } = {}) {
|
|
246
|
-
const shown = violations.slice(0, maxEntries).map((v) => {
|
|
247
|
-
const flat = String(v).replace(/\s+/g, " ").trim();
|
|
248
|
-
return flat.length > maxEntryLen ? `${flat.slice(0, maxEntryLen)}…` : flat;
|
|
249
|
-
});
|
|
250
|
-
const more = violations.length - shown.length;
|
|
251
|
-
return more > 0 ? `${shown.join("; ")}; …(+${more} more)` : shown.join("; ");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function evaluateRetrospectiveMergeApproval(checkpoint, { developerMode = false } = {}) {
|
|
255
|
-
if (!checkpoint || typeof checkpoint !== "object") {
|
|
256
|
-
return { approved: false, reason: "No retrospective checkpoint was found." };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const state = typeof checkpoint.state === "string" ? checkpoint.state.trim().toLowerCase() : "";
|
|
260
|
-
if (state !== "complete") {
|
|
261
|
-
return { approved: false, reason: `Retrospective is not complete (state: ${state || "missing"}).` };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Read merge approval from behavioralReview (existing format) or top-level (future flat format).
|
|
265
|
-
const br = checkpoint.behavioralReview && typeof checkpoint.behavioralReview === "object"
|
|
266
|
-
? checkpoint.behavioralReview
|
|
267
|
-
: null;
|
|
268
|
-
const mergeApproved = br !== null ? br.mergeApproved : checkpoint.mergeApproved;
|
|
269
|
-
if (mergeApproved !== true) {
|
|
270
|
-
return { approved: false, reason: "Retrospective does not explicitly approve merge (`mergeApproved: true` is required)." };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// followedWorkingAgreement: required boolean (existing checkpoint uses behavioralReview.followedWorkingAgreement).
|
|
274
|
-
const followedWorkingAgreement = br !== null
|
|
275
|
-
? br.followedWorkingAgreement
|
|
276
|
-
: checkpoint.followedWorkingAgreement;
|
|
277
|
-
if (typeof followedWorkingAgreement !== "boolean") {
|
|
278
|
-
return { approved: false, reason: "Retrospective is missing `followedWorkingAgreement` (true/false)." };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// gateQuality: require gateQualityAcceptable=true AND non-empty notes (behavioralReview)
|
|
282
|
-
// or explicit gateQuality string (flat format). Avoid empty-notes bypass.
|
|
283
|
-
const gateQualityAcceptable = br !== null
|
|
284
|
-
? br.gateQualityAcceptable
|
|
285
|
-
: checkpoint.gateQualityAcceptable;
|
|
286
|
-
if (typeof gateQualityAcceptable !== "boolean" || gateQualityAcceptable !== true) {
|
|
287
|
-
return { approved: false, reason: `Retrospective gate quality is not explicitly acceptable (gateQualityAcceptable: ${String(gateQualityAcceptable)}).` };
|
|
288
|
-
}
|
|
289
|
-
const gateQuality = typeof checkpoint.gateQuality === "string" && checkpoint.gateQuality.trim().length > 0
|
|
290
|
-
? checkpoint.gateQuality
|
|
291
|
-
: null;
|
|
292
|
-
if (!gateQuality) {
|
|
293
|
-
return { approved: false, reason: "Retrospective is missing `gateQuality` details; provide a notes field with gate-quality assessment or an explicit gateQuality string." };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// unexpectedFindings: derive from behavioralReview.drifts if flat field absent. Empty array is valid (no findings).
|
|
297
|
-
const unexpectedFindings = typeof checkpoint.unexpectedFindings === "string" && checkpoint.unexpectedFindings.trim().length > 0
|
|
298
|
-
? checkpoint.unexpectedFindings
|
|
299
|
-
: (br !== null && Array.isArray(br.drifts)
|
|
300
|
-
? (br.drifts.length > 0 ? br.drifts.join("; ") : "none")
|
|
301
|
-
: null);
|
|
302
|
-
if (!unexpectedFindings) {
|
|
303
|
-
return { approved: false, reason: "Retrospective is missing `unexpectedFindings` details." };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// mergeRecommendation: require explicit mergeRecommendation field (string).
|
|
307
|
-
const mergeRecommendation = typeof checkpoint.mergeRecommendation === "string" && checkpoint.mergeRecommendation.trim().length > 0
|
|
308
|
-
? checkpoint.mergeRecommendation
|
|
309
|
-
: null;
|
|
310
|
-
if (!mergeRecommendation) {
|
|
311
|
-
return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// internalToolingOnly: the loop's own execution must have used internal dev-loops
|
|
315
|
-
// tooling only — no agent-level raw `gh`/`python`/`python3`/`node -e` (issue #982).
|
|
316
|
-
// This is a DEVELOPER-MODE retro step: it enforces the dev-loops maintainers'
|
|
317
|
-
// own dogfooding discipline and is opt-in via `workflow.requireRetrospectiveInternalTooling`
|
|
318
|
-
// (default OFF). CONSUMERS of the extension are never blocked by it — they may
|
|
319
|
-
// legitimately use raw gh/python/node -e in their own workflow — so when the flag
|
|
320
|
-
// is OFF these fields are neither required nor enforced (a complete checkpoint
|
|
321
|
-
// without them passes exactly as it did before #982). When ON it fails closed:
|
|
322
|
-
// a complete checkpoint must explicitly attest a clean tooling record, and an OLD
|
|
323
|
-
// checkpoint missing `internalToolingOnly` fails (not a silent pass). Re-record the
|
|
324
|
-
// retrospective with the new fields to clear it.
|
|
325
|
-
if (developerMode) {
|
|
326
|
-
const internalToolingOnly = br !== null ? br.internalToolingOnly : checkpoint.internalToolingOnly;
|
|
327
|
-
if (internalToolingOnly !== true) {
|
|
328
|
-
return {
|
|
329
|
-
approved: false,
|
|
330
|
-
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.",
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
const rawCallViolations = br !== null ? br.rawCallViolations : checkpoint.rawCallViolations;
|
|
334
|
-
if (!Array.isArray(rawCallViolations)) {
|
|
335
|
-
return { approved: false, reason: "Retrospective is missing `rawCallViolations` (array; empty when clean). — re-record the retrospective with internalToolingOnly + rawCallViolations." };
|
|
336
|
-
}
|
|
337
|
-
if (rawCallViolations.length > 0) {
|
|
338
|
-
return {
|
|
339
|
-
approved: false,
|
|
340
|
-
reason: `Retrospective records ${rawCallViolations.length} raw-call violation(s) (agent-level gh/python/node -e): ${summarizeRawCallViolations(rawCallViolations)}.`,
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return { approved: true, reason: null };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function buildRetrospectiveGatePendingResult({
|
|
349
|
-
input,
|
|
350
|
-
currentHeadSha,
|
|
351
|
-
draftGateAlreadySatisfied,
|
|
352
|
-
draftGate,
|
|
353
|
-
preApprovalGate,
|
|
354
|
-
mergeStateStatus,
|
|
355
|
-
conflictFiles,
|
|
356
|
-
reason,
|
|
357
|
-
refinementArtifact = null,
|
|
358
|
-
}) {
|
|
359
|
-
const allowedNextActions = [];
|
|
360
|
-
const forbiddenActions = [];
|
|
361
|
-
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
362
|
-
pushUnique(forbiddenActions, [
|
|
363
|
-
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
364
|
-
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
365
|
-
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
366
|
-
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
367
|
-
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
368
|
-
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
369
|
-
]);
|
|
370
|
-
|
|
371
|
-
return buildResult({
|
|
372
|
-
repo: input.repo ?? null,
|
|
373
|
-
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
374
|
-
currentHeadSha,
|
|
375
|
-
lifecycleState: "retrospective_gate_pending",
|
|
376
|
-
loopDisposition: DISPOSITION.BLOCKED,
|
|
377
|
-
gateBoundary: PR_CHECKPOINT.BLOCKED,
|
|
378
|
-
draftGateAlreadySatisfied,
|
|
379
|
-
draftGate,
|
|
380
|
-
preApprovalGate,
|
|
381
|
-
allowedNextActions,
|
|
382
|
-
forbiddenActions,
|
|
383
|
-
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
384
|
-
reason,
|
|
385
|
-
mergeStateStatus,
|
|
386
|
-
conflictFiles,
|
|
387
|
-
refinementArtifact,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
241
|
/**
|
|
393
242
|
* Blocked result for a PR that would otherwise reach final_approval_ready but
|
|
394
243
|
* still carries a merge-blocking marker in its title (issue #842). The title is
|
|
@@ -548,6 +397,9 @@ function buildResult({
|
|
|
548
397
|
* the current head, including a post-cap head Copilot has not (and will not)
|
|
549
398
|
* re-review. No further Copilot round is permitted, so the formal-request guard
|
|
550
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.
|
|
551
403
|
* @param {string} params.gateBoundary - current gate boundary
|
|
552
404
|
* @returns {boolean}
|
|
553
405
|
*/
|
|
@@ -558,6 +410,7 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
558
410
|
maxCopilotRounds = null,
|
|
559
411
|
sameHeadCleanConverged = false,
|
|
560
412
|
roundCapCleanFallback = false,
|
|
413
|
+
postConvergenceSignificantChange = false,
|
|
561
414
|
gateBoundary,
|
|
562
415
|
}) {
|
|
563
416
|
const gateBoundariesRequiringCopilotFormalRequest = new Set([
|
|
@@ -590,10 +443,12 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
590
443
|
// CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
|
|
591
444
|
// Copilot round is permitted, so forcing a formal request would dead-end the loop;
|
|
592
445
|
// the pre_approval_gate reviews the post-cap head instead (per #848).
|
|
593
|
-
const roundCapReached = maxCopilotRounds
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
446
|
+
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
447
|
+
if (
|
|
448
|
+
roundCapReached
|
|
449
|
+
&& (sameHeadCleanConverged || roundCapCleanFallback)
|
|
450
|
+
&& !postConvergenceSignificantChange
|
|
451
|
+
) {
|
|
597
452
|
return false;
|
|
598
453
|
}
|
|
599
454
|
return true;
|
|
@@ -680,13 +535,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
680
535
|
const draftGateRequireCi = input.draftGateRequireCi !== false;
|
|
681
536
|
const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
|
|
682
537
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
683
|
-
const roundCapReached =
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
// internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
|
|
687
|
-
const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
|
|
688
|
-
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
538
|
+
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
539
|
+
const postConvergenceSignificantChange = input.postConvergenceSignificantChange === true;
|
|
540
|
+
const roundCapNewCycleRequired = roundCapReached && copilotReviewRoundCount > 0 && postConvergenceSignificantChange;
|
|
689
541
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
542
|
+
// UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
|
|
543
|
+
// e2e suite passed for this head. Inclusion is path-triggered, never annotated.
|
|
544
|
+
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
545
|
+
const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
|
|
690
546
|
const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
|
|
691
547
|
? input.refinementArtifact
|
|
692
548
|
: null;
|
|
@@ -836,6 +692,45 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
836
692
|
});
|
|
837
693
|
}
|
|
838
694
|
|
|
695
|
+
// UI e2e auto-scoping precondition (#976). Path-triggered + fail-closed:
|
|
696
|
+
// if the PR's changed files touch a rendered artifact (a deck under
|
|
697
|
+
// docs/articles|presentations, or the inspect-run viewer source), it MUST be
|
|
698
|
+
// registered in the shared UI e2e suite AND that suite must have passed for
|
|
699
|
+
// this head. A rendered-artifact change with no registered/passing coverage
|
|
700
|
+
// blocks here with a reason naming the artifact. Distinct seam from the
|
|
701
|
+
// mergeability (#980) preconditions to minimize
|
|
702
|
+
// merge-time conflict. Non-UI changes pass through untouched (required=false).
|
|
703
|
+
const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
|
|
704
|
+
if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
|
|
705
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]);
|
|
706
|
+
pushUnique(forbiddenActions, [
|
|
707
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
708
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
709
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
710
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
711
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
712
|
+
]);
|
|
713
|
+
return buildResult({
|
|
714
|
+
repo: input.repo ?? null,
|
|
715
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
716
|
+
currentHeadSha,
|
|
717
|
+
lifecycleState: effectiveLifecycleState,
|
|
718
|
+
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
719
|
+
gateBoundary: PR_CHECKPOINT.UI_E2E_SCOPING,
|
|
720
|
+
draftGateAlreadySatisfied,
|
|
721
|
+
draftGate,
|
|
722
|
+
preApprovalGate,
|
|
723
|
+
allowedNextActions,
|
|
724
|
+
forbiddenActions,
|
|
725
|
+
nextAction: PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE,
|
|
726
|
+
reason: uiE2eScoping.reason,
|
|
727
|
+
mergeStateStatus,
|
|
728
|
+
conflictFiles,
|
|
729
|
+
refinementArtifact,
|
|
730
|
+
copilotReviewRoundCount,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
839
734
|
if (prDraft || effectiveLifecycleState === STATE.PR_DRAFT) {
|
|
840
735
|
if (refinementArtifactStatus === REFINEMENT_ARTIFACT_STATUS.MISSING) {
|
|
841
736
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
@@ -1015,24 +910,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1015
910
|
refinementArtifact,
|
|
1016
911
|
});
|
|
1017
912
|
}
|
|
1018
|
-
if (requireRetrospectiveGate) {
|
|
1019
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1020
|
-
if (!retrospectiveGate.approved) {
|
|
1021
|
-
return buildRetrospectiveGatePendingResult({
|
|
1022
|
-
input,
|
|
1023
|
-
currentHeadSha,
|
|
1024
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1025
|
-
draftGate,
|
|
1026
|
-
preApprovalGate,
|
|
1027
|
-
mergeStateStatus,
|
|
1028
|
-
conflictFiles,
|
|
1029
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1030
|
-
refinementArtifact,
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
913
|
if (!draftGate.cleanEvidenceExists) {
|
|
1037
914
|
return buildDraftGateNeededForMergeResult({
|
|
1038
915
|
input,
|
|
@@ -1245,11 +1122,11 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1245
1122
|
});
|
|
1246
1123
|
}
|
|
1247
1124
|
|
|
1248
|
-
const roundExhaustionGateEvidenceNote = roundCapReached
|
|
1125
|
+
const roundExhaustionGateEvidenceNote = (roundCapReached && !roundCapNewCycleRequired)
|
|
1249
1126
|
? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds })
|
|
1250
1127
|
: null;
|
|
1251
1128
|
|
|
1252
|
-
if (!sameHeadCleanConverged && !roundCapReached) {
|
|
1129
|
+
if (!sameHeadCleanConverged && (!roundCapReached || roundCapNewCycleRequired)) {
|
|
1253
1130
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1254
1131
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1255
1132
|
return buildResult({
|
|
@@ -1265,7 +1142,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1265
1142
|
allowedNextActions,
|
|
1266
1143
|
forbiddenActions,
|
|
1267
1144
|
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1268
|
-
reason:
|
|
1145
|
+
reason: roundCapNewCycleRequired
|
|
1146
|
+
? "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`."
|
|
1147
|
+
: "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.",
|
|
1269
1148
|
mergeStateStatus,
|
|
1270
1149
|
conflictFiles,
|
|
1271
1150
|
refinementArtifact,
|
|
@@ -1287,23 +1166,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1287
1166
|
refinementArtifact,
|
|
1288
1167
|
});
|
|
1289
1168
|
}
|
|
1290
|
-
if (requireRetrospectiveGate) {
|
|
1291
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1292
|
-
if (!retrospectiveGate.approved) {
|
|
1293
|
-
return buildRetrospectiveGatePendingResult({
|
|
1294
|
-
input,
|
|
1295
|
-
currentHeadSha,
|
|
1296
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1297
|
-
draftGate,
|
|
1298
|
-
preApprovalGate,
|
|
1299
|
-
mergeStateStatus,
|
|
1300
|
-
conflictFiles,
|
|
1301
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1302
|
-
refinementArtifact,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
1169
|
|
|
1308
1170
|
if (!draftGate.cleanEvidenceExists && !roundCapReached) {
|
|
1309
1171
|
return buildDraftGateNeededForMergeResult({
|
|
@@ -1392,6 +1254,35 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1392
1254
|
// blocked states are handled earlier, so genuinely-blocked states still forbid
|
|
1393
1255
|
// pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
|
|
1394
1256
|
if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
1257
|
+
if (roundCapNewCycleRequired) {
|
|
1258
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1259
|
+
pushUnique(forbiddenActions, [
|
|
1260
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1261
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1262
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1263
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1264
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1265
|
+
]);
|
|
1266
|
+
return buildResult({
|
|
1267
|
+
repo: input.repo ?? null,
|
|
1268
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1269
|
+
currentHeadSha,
|
|
1270
|
+
lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
|
|
1271
|
+
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
1272
|
+
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
1273
|
+
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1274
|
+
draftGate,
|
|
1275
|
+
preApprovalGate,
|
|
1276
|
+
allowedNextActions,
|
|
1277
|
+
forbiddenActions,
|
|
1278
|
+
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1279
|
+
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\`.`,
|
|
1280
|
+
mergeStateStatus,
|
|
1281
|
+
conflictFiles,
|
|
1282
|
+
refinementArtifact,
|
|
1283
|
+
copilotReviewRoundCount,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1395
1286
|
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1396
1287
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1397
1288
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
@@ -1453,23 +1344,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1453
1344
|
refinementArtifact,
|
|
1454
1345
|
});
|
|
1455
1346
|
}
|
|
1456
|
-
if (requireRetrospectiveGate) {
|
|
1457
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1458
|
-
if (!retrospectiveGate.approved) {
|
|
1459
|
-
return buildRetrospectiveGatePendingResult({
|
|
1460
|
-
input,
|
|
1461
|
-
currentHeadSha,
|
|
1462
|
-
draftGateAlreadySatisfied: true,
|
|
1463
|
-
draftGate,
|
|
1464
|
-
preApprovalGate,
|
|
1465
|
-
mergeStateStatus,
|
|
1466
|
-
conflictFiles,
|
|
1467
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1468
|
-
refinementArtifact,
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
1347
|
// Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
|
|
1474
1348
|
// draft_gate evidence must reconcile the draft gate rather than jump to
|
|
1475
1349
|
// final approval. This keeps the core handler consistent with the
|
|
@@ -1613,23 +1487,6 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1613
1487
|
refinementArtifact,
|
|
1614
1488
|
});
|
|
1615
1489
|
}
|
|
1616
|
-
if (requireRetrospectiveGate) {
|
|
1617
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1618
|
-
if (!retrospectiveGate.approved) {
|
|
1619
|
-
return buildRetrospectiveGatePendingResult({
|
|
1620
|
-
input,
|
|
1621
|
-
currentHeadSha,
|
|
1622
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1623
|
-
draftGate,
|
|
1624
|
-
preApprovalGate,
|
|
1625
|
-
mergeStateStatus,
|
|
1626
|
-
conflictFiles,
|
|
1627
|
-
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1628
|
-
refinementArtifact,
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
1490
|
|
|
1634
1491
|
if (!draftGate.cleanEvidenceExists) {
|
|
1635
1492
|
return buildDraftGateNeededForMergeResult({
|
|
@@ -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.
|
|
@@ -1,6 +1,35 @@
|
|
|
1
|
-
import { loadBoardConfig, resolveProjectNumber } from "./queue-board-sync.mjs";
|
|
1
|
+
import { loadBoardConfig, resolveProjectNumber, loadStateColumnMap, LOGICAL_COLUMN } from "./queue-board-sync.mjs";
|
|
2
2
|
import { main as listQueueItemsMain } from "../../../../scripts/projects/list-queue-items.mjs";
|
|
3
3
|
|
|
4
|
+
// Canonical fail-closed Next Up tokens — the SINGLE source of truth so the reason
|
|
5
|
+
// codes and the empty-queue message stay byte-identical across every layer that
|
|
6
|
+
// detects them (queue-driver, run-queue, resolve-active-board-item). These strings
|
|
7
|
+
// drifted twice before centralization (#1091); import them, never re-inline them.
|
|
8
|
+
export const REASON_NEXT_UP_EMPTY = "next-up-empty";
|
|
9
|
+
export const REASON_BOARD_QUERY_ERROR = "board-query-error";
|
|
10
|
+
export const REASON_NEXT_UP_TARGET_MISSING_LOCALLY = "next-up-target-missing-locally";
|
|
11
|
+
export const EMPTY_NEXT_UP_MESSAGE = "queue empty — prioritize Backlog items into Next Up";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the board's "Next Up" pickup order (issue #1091).
|
|
15
|
+
*
|
|
16
|
+
* Next Up is the NORMATIVE, fail-closed pickup source. This resolver reports
|
|
17
|
+
* enough to let the driver distinguish three cases cleanly (it never silently
|
|
18
|
+
* collapses them):
|
|
19
|
+
*
|
|
20
|
+
* - Board NOT configured → `{ ok:true, configured:false, order:[] }`. The
|
|
21
|
+
* Next Up concept does not exist; the driver keeps its legacy local order.
|
|
22
|
+
* - Board configured, Next Up query SUCCEEDS → `{ ok:true, configured:true,
|
|
23
|
+
* order:[…], reason:null }`. `order` may be empty (a genuinely empty Next
|
|
24
|
+
* Up → the driver fails closed / idles, it MUST NOT fall back to Backlog).
|
|
25
|
+
* - Board configured, query ERRORS (unreachable / project unresolvable / API
|
|
26
|
+
* failure) → `{ ok:false, configured:true, order:[], reason:<msg> }`. The
|
|
27
|
+
* driver surfaces the error and stops; it MUST NOT fall back to Backlog.
|
|
28
|
+
*
|
|
29
|
+
* `order` and `reason` are always present so the fail-open membership layer
|
|
30
|
+
* (queue-membership.mjs), which predates the `ok`/`configured` fields, keeps
|
|
31
|
+
* working unchanged (it reads `order`/`reason` only).
|
|
32
|
+
*/
|
|
4
33
|
export async function resolveNextUpOrder(
|
|
5
34
|
repo,
|
|
6
35
|
repoRoot,
|
|
@@ -9,19 +38,32 @@ export async function resolveNextUpOrder(
|
|
|
9
38
|
) {
|
|
10
39
|
const config = loadBoardConfig(repoRoot);
|
|
11
40
|
if (!config.enabled) {
|
|
12
|
-
return { ok: true, order: [], reason: config.reason ?? "board not configured" };
|
|
41
|
+
return { ok: true, configured: false, order: [], reason: config.reason ?? "board not configured" };
|
|
13
42
|
}
|
|
14
43
|
|
|
15
44
|
let projectNumber;
|
|
16
45
|
try {
|
|
17
46
|
projectNumber = await resolveProjectNumber(repo, config, env, dependencies.runChild);
|
|
18
47
|
} catch (err) {
|
|
19
|
-
|
|
48
|
+
// Board IS configured but we cannot resolve/reach it: this is a query ERROR,
|
|
49
|
+
// not an empty Next Up. Fail closed at the driver, never Backlog fallback.
|
|
50
|
+
return { ok: false, configured: true, order: [], reason: err.message ?? "board lookup failed" };
|
|
20
51
|
}
|
|
21
52
|
if (!projectNumber) {
|
|
22
|
-
return { ok: true, order: [], reason: "could not resolve board project" };
|
|
53
|
+
return { ok: false, configured: true, order: [], reason: "could not resolve board project" };
|
|
23
54
|
}
|
|
24
55
|
|
|
56
|
+
// Resolve the logical next_up column through the SAME statusColumns mapping
|
|
57
|
+
// board-sync uses (#1098), so a renamed Next Up column (e.g. "Todo") is
|
|
58
|
+
// queried by its configured display name instead of the literal default.
|
|
59
|
+
// No config-error guard here: loadBoardConfig above already short-circuits any
|
|
60
|
+
// `.devloops` read/parse error to `enabled:false` (early return at the top of
|
|
61
|
+
// this function), so a malformed config never reaches this point. The
|
|
62
|
+
// fail-closed-on-config-error guard lives on the direct-read pickup path
|
|
63
|
+
// (resolve-active-board-item), which does NOT go through loadBoardConfig.
|
|
64
|
+
const { columnNames } = loadStateColumnMap(repoRoot);
|
|
65
|
+
const nextUpColumn = columnNames[LOGICAL_COLUMN.NEXT_UP];
|
|
66
|
+
|
|
25
67
|
const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
|
|
26
68
|
try {
|
|
27
69
|
const result = await listItems(
|
|
@@ -29,14 +71,16 @@ export async function resolveNextUpOrder(
|
|
|
29
71
|
// resolveProjectNumber yields a number, so stringify it. Passing the raw
|
|
30
72
|
// number trips parseProjectRef's `typeof raw !== "string"` guard, which
|
|
31
73
|
// surfaces as a misleading "--project is required" (#901).
|
|
32
|
-
{ repo, project: String(projectNumber), column:
|
|
74
|
+
{ repo, project: String(projectNumber), column: nextUpColumn },
|
|
33
75
|
{ env, runChild: dependencies.runChild },
|
|
34
76
|
);
|
|
35
77
|
const order = (result?.items ?? [])
|
|
36
78
|
.map((it) => it.issueNumber ?? it.prNumber)
|
|
37
79
|
.filter((n) => typeof n === "number");
|
|
38
|
-
|
|
80
|
+
// Successful query — order may be empty (genuinely empty Next Up).
|
|
81
|
+
return { ok: true, configured: true, order, reason: null };
|
|
39
82
|
} catch (err) {
|
|
40
|
-
|
|
83
|
+
// Query ERROR — surface it; the driver stops and never falls back.
|
|
84
|
+
return { ok: false, configured: true, order: [], reason: err.message ?? "Next Up query failed" };
|
|
41
85
|
}
|
|
42
86
|
}
|
|
@@ -107,6 +107,61 @@ export function boardColumnForLoopState(loopState, mapping = {}) {
|
|
|
107
107
|
return columnNames[logical] ?? columnNames[DEFAULT_LOGICAL_COLUMN];
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Derive the board's target LOGICAL column for a queue item from live GitHub
|
|
112
|
+
* facts (#1069). Returns LOGICAL_COLUMN.DONE, LOGICAL_COLUMN.IN_PROGRESS, or
|
|
113
|
+
* null when the item should be left where it is (Backlog/Next Up untouched).
|
|
114
|
+
*
|
|
115
|
+
* facts: {
|
|
116
|
+
* itemKind: "issue" | "pr",
|
|
117
|
+
* issueState: "OPEN" | "CLOSED" | null, // for issue items
|
|
118
|
+
* prState: "OPEN" | "CLOSED" | "MERGED" | null, // item PR, or the issue's linked PR
|
|
119
|
+
* prIsDraft: boolean | null,
|
|
120
|
+
* }
|
|
121
|
+
*/
|
|
122
|
+
export function deriveReconcileColumn(facts = {}) {
|
|
123
|
+
const { itemKind, issueState, prState, prIsDraft } = facts;
|
|
124
|
+
// Merged PR (item is a PR, or issue's linked PR merged) => Done.
|
|
125
|
+
if (prState === "MERGED") return LOGICAL_COLUMN.DONE;
|
|
126
|
+
if (itemKind === "issue" && issueState === "CLOSED") return LOGICAL_COLUMN.DONE;
|
|
127
|
+
// Open, ready (non-draft) PR => In Progress.
|
|
128
|
+
if (prState === "OPEN" && prIsDraft === false) return LOGICAL_COLUMN.IN_PROGRESS;
|
|
129
|
+
// Otherwise leave the item untouched (Backlog / Next Up ordering preserved).
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pure reconcile planner (#1069). Given listed board items, a map of live facts
|
|
135
|
+
* keyed by the item's stable GraphQL node id (`item.itemId`), and the resolved
|
|
136
|
+
* column display names, return the set of moves needed to converge the board and
|
|
137
|
+
* a count of items left unchanged. Idempotent: when every item already sits in
|
|
138
|
+
* its derived column, the moves array is empty.
|
|
139
|
+
*
|
|
140
|
+
* Keying by the stable `itemId` (not the bare issue/PR number) keeps reconcile
|
|
141
|
+
* deterministic on a multi-repo GitHub Projects board, where two items can share
|
|
142
|
+
* a number (repo-A PR #5 vs repo-B issue #5) — number-keying would collide and
|
|
143
|
+
* make moves order-dependent.
|
|
144
|
+
*
|
|
145
|
+
* items: [{ itemId, issueNumber, prNumber, status, ... }] (from list-queue-items)
|
|
146
|
+
* factsByItemId: Map<itemId, factsObject> (facts as consumed by deriveReconcileColumn)
|
|
147
|
+
* columnNames: { in_progress, done, ... } (LOGICAL_COLUMN -> display name)
|
|
148
|
+
*/
|
|
149
|
+
export function planReconcile(items = [], factsByItemId = new Map(), columnNames = {}) {
|
|
150
|
+
const moves = [];
|
|
151
|
+
let unchanged = 0;
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const facts = factsByItemId.get(item.itemId);
|
|
154
|
+
const logical = facts ? deriveReconcileColumn(facts) : null;
|
|
155
|
+
if (logical == null) { unchanged += 1; continue; }
|
|
156
|
+
const target = columnNames[logical];
|
|
157
|
+
if (!target || item.status === target) { unchanged += 1; continue; }
|
|
158
|
+
// `number` is kept only for reporting; the move is applied by node id.
|
|
159
|
+
const number = item.prNumber != null ? item.prNumber : item.issueNumber;
|
|
160
|
+
moves.push({ itemId: item.itemId, number, from: item.status ?? null, to: target });
|
|
161
|
+
}
|
|
162
|
+
return { moves, unchanged };
|
|
163
|
+
}
|
|
164
|
+
|
|
110
165
|
// ── Local config loader ─────────────────────────────────────────────────
|
|
111
166
|
|
|
112
167
|
function readDevloopsSettings(repoRoot) {
|
|
@@ -169,7 +224,7 @@ export function loadBoardConfig(repoRoot) {
|
|
|
169
224
|
* cannot pollute Object.prototype.
|
|
170
225
|
*/
|
|
171
226
|
export function loadStateColumnMap(repoRoot) {
|
|
172
|
-
const { settings: queue } = readDevloopsSettings(repoRoot);
|
|
227
|
+
const { settings: queue, error } = readDevloopsSettings(repoRoot);
|
|
173
228
|
// Null-prototype objects: untrusted keys can never reach Object.prototype.
|
|
174
229
|
const columnNames = Object.assign(Object.create(null), DEFAULT_STATE_COLUMN_NAMES);
|
|
175
230
|
const stateColumnMap = Object.create(null);
|
|
@@ -200,7 +255,11 @@ export function loadStateColumnMap(repoRoot) {
|
|
|
200
255
|
}
|
|
201
256
|
}
|
|
202
257
|
|
|
203
|
-
|
|
258
|
+
// Surface a non-ENOENT read/parse error (mirrors loadBoardConfig). Callers on
|
|
259
|
+
// the fail-closed next_up pickup path MUST honor it rather than silently
|
|
260
|
+
// querying the default literal column against a stale/renamed board (#1098).
|
|
261
|
+
// Existing `.columnNames`-only callers ignore this field and behave unchanged.
|
|
262
|
+
return { columnNames, stateColumnMap, error: error ?? null };
|
|
204
263
|
}
|
|
205
264
|
|
|
206
265
|
// ── Minimal project lookup (read-only, no create/repair) ────────────────
|