@dev-loops/core 1.0.2-slim.0 → 1.0.3
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 +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
|
@@ -54,6 +54,16 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
|
|
|
54
54
|
],
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
const RECONCILIATION_ACCEPTANCE_TEMPLATE = deepFreeze({
|
|
58
|
+
criteria: [
|
|
59
|
+
{ id: "reconcile", must: "Resolve the reported authoritative-state conflict before selecting or executing a strategy.", severity: "required" },
|
|
60
|
+
],
|
|
61
|
+
evidence: ["commands-run", "validation-output"],
|
|
62
|
+
maxFinalizationTurns: 1,
|
|
63
|
+
needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
|
|
64
|
+
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
65
|
+
});
|
|
66
|
+
|
|
57
67
|
// ---------------------------------------------------------------------------
|
|
58
68
|
// Acceptance template table
|
|
59
69
|
// ---------------------------------------------------------------------------
|
|
@@ -584,8 +594,45 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
584
594
|
throw new Error("handoff-envelope: resolverOutput is required and must be an object");
|
|
585
595
|
}
|
|
586
596
|
|
|
587
|
-
const
|
|
588
|
-
|
|
597
|
+
const isWrapped = Object.hasOwn(resolverOutput, "bundle");
|
|
598
|
+
if (isWrapped && (!resolverOutput.bundle || typeof resolverOutput.bundle !== "object")) {
|
|
599
|
+
throw new Error("handoff-envelope: resolverOutput.bundle must be an object when present");
|
|
600
|
+
}
|
|
601
|
+
const bundle = isWrapped ? resolverOutput.bundle : resolverOutput;
|
|
602
|
+
const strategy = bundle.selectedStrategy === null
|
|
603
|
+
? null
|
|
604
|
+
: requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
|
|
605
|
+
const routeKind = strategy === null
|
|
606
|
+
? requireString(bundle.routeKind, "resolverOutput.routeKind")
|
|
607
|
+
: (trimmedOrNull(bundle.routeKind) ?? "route");
|
|
608
|
+
const selectedGate = trimmedOrNull(bundle.selectedGate);
|
|
609
|
+
const outerBundleKind = isWrapped ? trimmedOrNull(resolverOutput.bundleKind) : null;
|
|
610
|
+
const nestedBundleKind = trimmedOrNull(bundle.bundleKind);
|
|
611
|
+
if (outerBundleKind && nestedBundleKind && outerBundleKind !== nestedBundleKind) {
|
|
612
|
+
throw new Error(`handoff-envelope: outer bundleKind (${outerBundleKind}) and inner bundleKind (${nestedBundleKind}) must agree`);
|
|
613
|
+
}
|
|
614
|
+
const bundleKind = outerBundleKind ?? nestedBundleKind;
|
|
615
|
+
const isReconciliation = routeKind === "needs_reconcile"
|
|
616
|
+
&& strategy === null
|
|
617
|
+
&& selectedGate === "fail_closed_reconcile"
|
|
618
|
+
&& bundleKind === "needs_reconcile";
|
|
619
|
+
if (isWrapped && isReconciliation && (
|
|
620
|
+
resolverOutput.bundleKind !== "needs_reconcile"
|
|
621
|
+
|| bundle.bundleKind !== "needs_reconcile"
|
|
622
|
+
|| resolverOutput.selectedStrategy !== "none"
|
|
623
|
+
|| bundle.selectedStrategy !== null
|
|
624
|
+
)) {
|
|
625
|
+
throw new Error("handoff-envelope: wrapped needs_reconcile output requires exact outer and inner bundleKind needs_reconcile markers, outer selectedStrategy none, and inner selectedStrategy null");
|
|
626
|
+
}
|
|
627
|
+
if (bundleKind === "needs_reconcile" && !isReconciliation) {
|
|
628
|
+
throw new Error("handoff-envelope: outer/inner bundleKind needs_reconcile requires inner routeKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
|
|
629
|
+
}
|
|
630
|
+
if (routeKind === "needs_reconcile" && !isReconciliation) {
|
|
631
|
+
throw new Error("handoff-envelope: routeKind needs_reconcile requires outer bundleKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
|
|
632
|
+
}
|
|
633
|
+
if (strategy === null && !isReconciliation) {
|
|
634
|
+
throw new Error("handoff-envelope: a null resolverOutput.selectedStrategy is allowed only for the canonical needs_reconcile/fail_closed_reconcile tuple");
|
|
635
|
+
}
|
|
589
636
|
const executionMode = requireString(bundle.executionMode, "resolverOutput.executionMode");
|
|
590
637
|
const nextAction = requireString(bundle.nextAction, "resolverOutput.nextAction");
|
|
591
638
|
|
|
@@ -598,7 +645,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
598
645
|
// profile instead of the default local-implementation gate. The spike
|
|
599
646
|
// marker lives at the TOP level of the resolver output (the bundle does not
|
|
600
647
|
// carry it), so it is read off `resolverOutput` directly.
|
|
601
|
-
const subGate =
|
|
648
|
+
const subGate = isReconciliation
|
|
649
|
+
? selectedGate
|
|
650
|
+
: (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
|
|
602
651
|
? "spike"
|
|
603
652
|
: resolveSubGate(strategy, gs);
|
|
604
653
|
// Normalize each source independently, then fall back on the normalized result
|
|
@@ -609,10 +658,14 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
609
658
|
|
|
610
659
|
const target = deriveTarget(bundle, repo);
|
|
611
660
|
const requiredReads = deriveRequiredReads(bundle, resolverOutput);
|
|
612
|
-
const stopRules =
|
|
661
|
+
const stopRules = isReconciliation
|
|
662
|
+
? ["reconcile", ...(resolveHumanMergeOnly(settings) ? ["merge"] : [])]
|
|
663
|
+
: deriveStopRules(settings, strategy);
|
|
613
664
|
const gateConfig = deriveGateConfig(settings, subGate);
|
|
614
665
|
const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
|
|
615
|
-
const template =
|
|
666
|
+
const template = isReconciliation
|
|
667
|
+
? RECONCILIATION_ACCEPTANCE_TEMPLATE
|
|
668
|
+
: lookupAcceptanceTemplate(strategy, subGate);
|
|
616
669
|
// Lightweight PR-body-as-spec: retarget the phase-doc criterion
|
|
617
670
|
// text to the PR description. Null/phase_doc leaves the criteria untouched, so
|
|
618
671
|
// the non-lightweight path stays byte-identical.
|
|
@@ -650,6 +703,8 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
650
703
|
maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
|
|
651
704
|
executionMode,
|
|
652
705
|
|
|
706
|
+
...(isReconciliation ? { routeKind, selectedStrategy: null } : {}),
|
|
707
|
+
|
|
653
708
|
nextAction,
|
|
654
709
|
requiredReads,
|
|
655
710
|
|
|
@@ -660,7 +715,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
660
715
|
requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
|
|
661
716
|
|
|
662
717
|
cwd: derivedCwd,
|
|
663
|
-
worktreeRequired:
|
|
718
|
+
worktreeRequired: !isReconciliation,
|
|
664
719
|
|
|
665
720
|
acceptance: {
|
|
666
721
|
criteria: acceptanceCriteria,
|
|
@@ -832,6 +887,58 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
832
887
|
});
|
|
833
888
|
}
|
|
834
889
|
|
|
890
|
+
// ----- terminal reconciliation tuple -----
|
|
891
|
+
// routeKind/selectedStrategy are omitted for ordinary routed envelopes to
|
|
892
|
+
// preserve the v1 shape. If either is present, both must identify the one
|
|
893
|
+
// supported no-strategy terminal envelope exactly; serialized/tampered
|
|
894
|
+
// envelopes receive the same fail-closed enforcement as the builder.
|
|
895
|
+
const hasRouteKind = Object.hasOwn(envelope, "routeKind");
|
|
896
|
+
const hasSelectedStrategy = Object.hasOwn(envelope, "selectedStrategy");
|
|
897
|
+
if (hasRouteKind || hasSelectedStrategy || envelope.currentGate === "fail_closed_reconcile") {
|
|
898
|
+
const reconciliationStopRulesAreCanonical = Array.isArray(envelope.stopRules)
|
|
899
|
+
&& envelope.stopRules[0] === "reconcile"
|
|
900
|
+
&& (envelope.stopRules.length === 1
|
|
901
|
+
|| (envelope.stopRules.length === 2 && envelope.stopRules[1] === "merge"));
|
|
902
|
+
// Routing invariants only: acceptance prose remains the resolver's
|
|
903
|
+
// business, while nextAction must begin with one of the known fail-closed
|
|
904
|
+
// directives rather than arbitrary non-empty text.
|
|
905
|
+
const reconciliationCriterion = envelope.acceptance?.criteria;
|
|
906
|
+
const reconciliationAcceptanceIsCanonical = Array.isArray(reconciliationCriterion)
|
|
907
|
+
&& reconciliationCriterion.some((criterion) => criterion?.id === "reconcile" && criterion?.severity === "required");
|
|
908
|
+
const normalizedNextAction = typeof envelope.nextAction === "string" ? envelope.nextAction.trim() : "";
|
|
909
|
+
const reconciliationNextActionIsActionable = [
|
|
910
|
+
"Reconcile ",
|
|
911
|
+
"Stop and reconcile ",
|
|
912
|
+
"Complete or explicitly skip ",
|
|
913
|
+
"Local implementation requires worktree isolation",
|
|
914
|
+
].some((directive) => normalizedNextAction.startsWith(directive));
|
|
915
|
+
if (
|
|
916
|
+
envelope.routeKind !== "needs_reconcile"
|
|
917
|
+
|| envelope.selectedStrategy !== null
|
|
918
|
+
|| envelope.currentGate !== "fail_closed_reconcile"
|
|
919
|
+
|| envelope.worktreeRequired !== false
|
|
920
|
+
|| !reconciliationStopRulesAreCanonical
|
|
921
|
+
|| !reconciliationAcceptanceIsCanonical
|
|
922
|
+
|| !reconciliationNextActionIsActionable
|
|
923
|
+
) {
|
|
924
|
+
errors.push({
|
|
925
|
+
field: "routeKind/selectedStrategy/currentGate/worktreeRequired/stopRules/acceptance.criteria",
|
|
926
|
+
reason: "terminal reconciliation must use the exact needs_reconcile / null / fail_closed_reconcile tuple, require no worktree, instruct reconciliation, stop at reconcile (plus optional merge), and carry a required reconcile acceptance criterion",
|
|
927
|
+
got: {
|
|
928
|
+
routeKind: envelope.routeKind,
|
|
929
|
+
selectedStrategy: envelope.selectedStrategy,
|
|
930
|
+
currentGate: envelope.currentGate,
|
|
931
|
+
worktreeRequired: envelope.worktreeRequired,
|
|
932
|
+
stopRules: envelope.stopRules,
|
|
933
|
+
nextAction: envelope.nextAction,
|
|
934
|
+
acceptanceCriteria: envelope.acceptance?.criteria,
|
|
935
|
+
acceptanceEvidence: envelope.acceptance?.evidence,
|
|
936
|
+
maxFinalizationTurns: envelope.acceptance?.maxFinalizationTurns,
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
835
942
|
// ----- requiredReads -----
|
|
836
943
|
if (!Array.isArray(envelope.requiredReads)) {
|
|
837
944
|
errors.push({ field: "requiredReads", reason: "must be an array", got: envelope.requiredReads });
|
|
@@ -442,6 +442,24 @@ function rowIsSemantic(criterion, evidence) {
|
|
|
442
442
|
return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Resolve which columns hold the criterion and evidence by HEADER NAME, so a
|
|
447
|
+
* leading index column (`#`, `No`, `Idx`, empty, …) shifts the mapped columns
|
|
448
|
+
* off positions 0/1 without breaking detection. Returns `{ criterionCol,
|
|
449
|
+
* evidenceCol }` when both header families are named in distinct columns.
|
|
450
|
+
* Falls back to positions 0/1 for a matrix-heading table whose columns are not
|
|
451
|
+
* explicitly named (the pre-index-aware behavior); returns null otherwise.
|
|
452
|
+
*/
|
|
453
|
+
function resolveMatrixColumns(headerCells, underHeading) {
|
|
454
|
+
const criterionCol = headerCells.findIndex((h) => MATRIX_CRITERION_HEADER.test(h ?? ""));
|
|
455
|
+
const evidenceCol = headerCells.findIndex((h) => MATRIX_EVIDENCE_HEADER.test(h ?? ""));
|
|
456
|
+
if (criterionCol >= 0 && evidenceCol >= 0 && criterionCol !== evidenceCol) {
|
|
457
|
+
return { criterionCol, evidenceCol };
|
|
458
|
+
}
|
|
459
|
+
if (underHeading) return { criterionCol: 0, evidenceCol: 1 };
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
445
463
|
/**
|
|
446
464
|
* Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
|
|
447
465
|
* the shared `stepFence`). Returns an array of
|
|
@@ -508,26 +526,27 @@ function parseMarkdownTables(body) {
|
|
|
508
526
|
*/
|
|
509
527
|
export function detectAcDodMatrix(body = "") {
|
|
510
528
|
const tables = parseMarkdownTables(body);
|
|
511
|
-
const candidates =
|
|
512
|
-
|
|
529
|
+
const candidates = [];
|
|
530
|
+
for (const t of tables) {
|
|
531
|
+
if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) continue;
|
|
513
532
|
const underHeading = typeof t.heading === "string" &&
|
|
514
533
|
MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
return underHeading || headerNamesMap;
|
|
519
|
-
});
|
|
534
|
+
const cols = resolveMatrixColumns(t.headerCells, underHeading);
|
|
535
|
+
if (cols) candidates.push({ ...t, ...cols });
|
|
536
|
+
}
|
|
520
537
|
if (candidates.length === 0) {
|
|
521
538
|
return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
|
|
522
539
|
}
|
|
523
540
|
// Prefer the first candidate that has >=1 semantic row; otherwise report the
|
|
524
541
|
// first candidate as malformed.
|
|
525
542
|
for (const table of candidates) {
|
|
543
|
+
const { criterionCol, evidenceCol } = table;
|
|
544
|
+
const minCells = Math.max(criterionCol, evidenceCol) + 1;
|
|
526
545
|
const semanticRows = [];
|
|
527
546
|
for (const cells of table.rows) {
|
|
528
|
-
if (cells.length <
|
|
529
|
-
const criterion = cells[
|
|
530
|
-
const evidence = cells[
|
|
547
|
+
if (cells.length < minCells) continue;
|
|
548
|
+
const criterion = cells[criterionCol] ?? "";
|
|
549
|
+
const evidence = cells[evidenceCol] ?? "";
|
|
531
550
|
if (rowIsSemantic(criterion, evidence)) {
|
|
532
551
|
semanticRows.push({ criterion, evidence });
|
|
533
552
|
}
|
|
@@ -884,7 +903,7 @@ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
|
|
|
884
903
|
const CLOSING_ISSUE_REFERENCE_PATTERN =
|
|
885
904
|
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
|
|
886
905
|
|
|
887
|
-
function extractClosingIssueNumbers(body) {
|
|
906
|
+
export function extractClosingIssueNumbers(body) {
|
|
888
907
|
// Same fence-skip as sectionHasBody: a `Closes #N` line quoted inside a
|
|
889
908
|
// ```fenced``` example (e.g. a PR-template sample) must not spoof the gate.
|
|
890
909
|
let fence = null;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanctioned merge-wrapper decision logic. Pure, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The CLI wrapper `scripts/github/merge-pr.mjs` gathers live GitHub facts
|
|
5
|
+
* (mergeable state, CI rollup, gate evidence, size-budget outcome, reviews,
|
|
6
|
+
* comments) and feeds them here. This module owns the FAIL-CLOSED decisions:
|
|
7
|
+
* - is `--human-approved-by` a real GitHub login;
|
|
8
|
+
* - which merge class the PR is in (drain vs escalated);
|
|
9
|
+
* - whether a fresh, agent-unforgeable, head-pinned human approval exists;
|
|
10
|
+
* - and the aggregate precondition verdict that names each failing precondition.
|
|
11
|
+
*
|
|
12
|
+
* It reuses, never re-derives, the existing precondition set:
|
|
13
|
+
* `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
|
|
14
|
+
* `findBlockingTitleMarkers` (pr-title-markers), and the detect-checkpoint-evidence
|
|
15
|
+
* `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
|
|
16
|
+
* fan-out provenance). This module adds ONLY the human-approver identity, the
|
|
17
|
+
* merge-class split, and the aggregate naming.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isCopilotLogin } from "../github/copilot-helpers.mjs";
|
|
21
|
+
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
22
|
+
import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
|
|
23
|
+
import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
|
|
24
|
+
|
|
25
|
+
// A GitHub login: 1-39 chars, alphanumeric or single internal hyphens, never
|
|
26
|
+
// leading/trailing hyphen. This rejects a bare boolean, empty/whitespace, and
|
|
27
|
+
// free text, so `--human-approved-by` is a real login, not a boolean or free text.
|
|
28
|
+
const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/;
|
|
29
|
+
|
|
30
|
+
/** True when `login` is shaped like a real GitHub login (fails closed on non-string). */
|
|
31
|
+
export function isValidGithubLogin(login) {
|
|
32
|
+
return typeof login === "string" && login.length >= 1 && login.length <= 39 && GITHUB_LOGIN_RE.test(login);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MERGE_CLASS = Object.freeze({ DRAIN: "drain", ESCALATED: "escalated" });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify the merge. An `escalate`/`block` size outcome, a T1-touching diff,
|
|
39
|
+
* or an explicit stable-release merge is ESCALATED — a standing authorization
|
|
40
|
+
* never satisfies it; it needs a fresh per-merge operator approval. Everything
|
|
41
|
+
* else is a normal DRAIN merge.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveMergeClass({ sizeOutcome = null, touchesT1 = false, stableRelease = false } = {}) {
|
|
44
|
+
if (stableRelease === true) return MERGE_CLASS.ESCALATED;
|
|
45
|
+
if (sizeOutcome === "escalate" || sizeOutcome === "block") return MERGE_CLASS.ESCALATED;
|
|
46
|
+
if (touchesT1 === true) return MERGE_CLASS.ESCALATED;
|
|
47
|
+
return MERGE_CLASS.DRAIN;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeRegex(value) {
|
|
51
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function reviewLogin(entry) {
|
|
55
|
+
if (typeof entry?.user?.login === "string" && entry.user.login.length > 0) return entry.user.login;
|
|
56
|
+
if (typeof entry?.login === "string" && entry.login.length > 0) return entry.login;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A non-human login: the Copilot reviewer/bot (bracket-free `Copilot`
|
|
61
|
+
// variants, caught by isCopilotLogin) or any GitHub App bot login, which
|
|
62
|
+
// GitHub renders with a `[bot]` suffix (e.g. `github-actions[bot]`). Such a
|
|
63
|
+
// login can never satisfy the human-approval requirement. (A `[bot]` login can
|
|
64
|
+
// never be the named `--human-approved-by <login>` either — isValidGithubLogin
|
|
65
|
+
// rejects the brackets — so this only ever fires on a review/comment AUTHOR.)
|
|
66
|
+
function isNonHumanLogin(login) {
|
|
67
|
+
return isCopilotLogin(login) || /\[bot\]$/i.test(login);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A non-human review/comment author. Beyond the login-shape check, a GitHub Bot
|
|
71
|
+
// account carries `user.type === "Bot"` even when its login has no `[bot]`
|
|
72
|
+
// suffix — reject it by that authoritative type so a bracket-free bot login can
|
|
73
|
+
// never satisfy the named fresh approver.
|
|
74
|
+
function isNonHumanAuthor(entry, login) {
|
|
75
|
+
return isNonHumanLogin(login) || (typeof entry?.type === "string" && entry.type.toLowerCase() === "bot");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reviewCommit(entry) {
|
|
79
|
+
if (typeof entry?.commit_id === "string" && entry.commit_id.length > 0) return entry.commit_id;
|
|
80
|
+
if (typeof entry?.commitId === "string" && entry.commitId.length > 0) return entry.commitId;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
|
|
86
|
+
*
|
|
87
|
+
* Two accepted records, in preference order:
|
|
88
|
+
* 1. a genuine `APPROVED` review by `approvedBy` whose `commit_id` equals the
|
|
89
|
+
* current head SHA — GitHub forbids approving your own PR, so an `APPROVED`
|
|
90
|
+
* review can only have come from a real human distinct from the author;
|
|
91
|
+
* 2. else a head-pinned operator comment marker `approve merge <headSha>`
|
|
92
|
+
* authored by `approvedBy` — the solo-operator path, since an author cannot
|
|
93
|
+
* leave an `APPROVED` review on their own agent-authored PR.
|
|
94
|
+
*
|
|
95
|
+
* FAILS CLOSED (returns `{ satisfied: false }`) when the approval is stale (on
|
|
96
|
+
* an earlier commit), agent/bot-authored (a Copilot-login review/comment never
|
|
97
|
+
* satisfies), from a login other than `approvedBy`, or absent. Because both
|
|
98
|
+
* records are pinned to the current head SHA, this re-gates on every head bump.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{ satisfied: boolean, via: "approved_review"|"comment_marker"|null, reason: string|null }}
|
|
101
|
+
*/
|
|
102
|
+
export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews = [], comments = [] } = {}) {
|
|
103
|
+
if (!isValidGithubLogin(approvedBy)) {
|
|
104
|
+
return { satisfied: false, via: null, reason: "approvedBy is not a valid GitHub login" };
|
|
105
|
+
}
|
|
106
|
+
if (typeof currentHeadSha !== "string" || currentHeadSha.trim().length === 0) {
|
|
107
|
+
return { satisfied: false, via: null, reason: "current head SHA is unknown" };
|
|
108
|
+
}
|
|
109
|
+
const head = currentHeadSha.trim();
|
|
110
|
+
|
|
111
|
+
// Reduce to each login's LATEST submitted review (reviews arrive oldest-first,
|
|
112
|
+
// so the last occurrence wins — matching resolveHumanReviewDecision). A login
|
|
113
|
+
// whose APPROVED review was later superseded by a COMMENTED / CHANGES_REQUESTED
|
|
114
|
+
// / DISMISSED review no longer satisfies: only their latest state counts.
|
|
115
|
+
const latestReviewByLogin = new Map();
|
|
116
|
+
for (const entry of Array.isArray(reviews) ? reviews : []) {
|
|
117
|
+
const login = reviewLogin(entry);
|
|
118
|
+
if (login === null) continue;
|
|
119
|
+
latestReviewByLogin.set(login, entry);
|
|
120
|
+
}
|
|
121
|
+
const approverReview = latestReviewByLogin.get(approvedBy);
|
|
122
|
+
if (
|
|
123
|
+
approverReview
|
|
124
|
+
&& !isNonHumanAuthor(approverReview, approvedBy) // agent/bot review never satisfies
|
|
125
|
+
&& (typeof approverReview.state === "string" ? approverReview.state : null) === "APPROVED"
|
|
126
|
+
&& reviewCommit(approverReview) === head // head-pinned — a stale/earlier-commit approval never satisfies
|
|
127
|
+
) {
|
|
128
|
+
return { satisfied: true, via: "approved_review", reason: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The marker must OPEN its line (after only leading whitespace / list / quote
|
|
132
|
+
// markers) so a negating operator comment never reads as approval: an
|
|
133
|
+
// unanchored `approve merge <head>` would also match `disapprove merge <head>`
|
|
134
|
+
// and `not approve merge <head>` — a fail-open on the merge-authorization
|
|
135
|
+
// path. The trailing `(?:\b|$)` keeps `<head>abc` from matching `<head>`.
|
|
136
|
+
const markerRe = new RegExp(`^[ \\t>*-]*approve\\s+merge\\s+${escapeRegex(head)}(?:\\b|$)`, "im");
|
|
137
|
+
for (const entry of Array.isArray(comments) ? comments : []) {
|
|
138
|
+
const login = reviewLogin(entry);
|
|
139
|
+
const body = typeof entry?.body === "string" ? entry.body : "";
|
|
140
|
+
if (login === null || isNonHumanAuthor(entry, login)) continue; // agent/bot comment never satisfies
|
|
141
|
+
if (login !== approvedBy) continue; // wrong login
|
|
142
|
+
if (!markerRe.test(body)) continue; // not a head-pinned marker for this head
|
|
143
|
+
return { satisfied: true, via: "comment_marker", reason: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
satisfied: false,
|
|
148
|
+
via: null,
|
|
149
|
+
reason: `no fresh ${approvedBy} approval on head ${head} (need an APPROVED review or an "approve merge ${head}" operator comment)`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Decide whether merge is authorized given the class, the standing
|
|
155
|
+
* authorization signal, and any fresh per-merge approval.
|
|
156
|
+
*
|
|
157
|
+
* DRAIN: satisfied by a recorded standing authorization OR a fresh approval.
|
|
158
|
+
* ESCALATED: a standing authorization does NOT satisfy it — a fresh per-merge
|
|
159
|
+
* operator approval is required.
|
|
160
|
+
*
|
|
161
|
+
* @returns {{ authorized: boolean, via: string|null, reason: string|null }}
|
|
162
|
+
*/
|
|
163
|
+
export function resolveMergeApprovalDecision({ mergeClass, standingAuthorized = false, freshApproval = null } = {}) {
|
|
164
|
+
const fresh = freshApproval != null && freshApproval.satisfied === true;
|
|
165
|
+
if (mergeClass === MERGE_CLASS.ESCALATED) {
|
|
166
|
+
if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
|
|
167
|
+
return {
|
|
168
|
+
authorized: false,
|
|
169
|
+
via: null,
|
|
170
|
+
reason: `escalated/stable-release merge requires a fresh per-merge operator approval; a standing authorization does not satisfy it${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (standingAuthorized === true) return { authorized: true, via: "standing_authorization", reason: null };
|
|
174
|
+
if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
|
|
175
|
+
return {
|
|
176
|
+
authorized: false,
|
|
177
|
+
via: null,
|
|
178
|
+
reason: `drain merge requires a recorded standing authorization or a fresh operator approval${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve CI-green from a `gh pr view --json statusCheckRollup` payload.
|
|
184
|
+
*
|
|
185
|
+
* Delegates to the canonical loop-safe normalizer `deriveLoopCiStatusFromRollup`,
|
|
186
|
+
* which EXCLUDES the loop-derived `gate-evidence` / `gate-evidence-runner` checks
|
|
187
|
+
* (detect-checkpoint-evidence validates those separately, so a cancelled/failing
|
|
188
|
+
* derived check must not block a merge whose real CI is green) and treats a
|
|
189
|
+
* completed-but-no-conclusion or otherwise-unreadable entry as non-success.
|
|
190
|
+
* Fails closed: only a real `success` is green; pending/failure/unavailable are
|
|
191
|
+
* not. An empty or no-CI rollup normalizes to `none`, which is NOT green (a PR
|
|
192
|
+
* with no visible CI does not auto-satisfy this precondition).
|
|
193
|
+
*/
|
|
194
|
+
export function resolveCiGreenFromRollup(rollup) {
|
|
195
|
+
if (!Array.isArray(rollup)) return { green: false, reason: "CI status rollup unavailable" };
|
|
196
|
+
const { status, excludedFailureDetails } = deriveLoopCiStatusFromRollup(rollup);
|
|
197
|
+
if (status === "success") return { green: true, reason: null };
|
|
198
|
+
return {
|
|
199
|
+
green: false,
|
|
200
|
+
reason: `CI is not green on the current head (status=${status})`,
|
|
201
|
+
...(Array.isArray(excludedFailureDetails) && excludedFailureDetails.length > 0 ? { excludedFailureDetails } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Aggregate every merge precondition into one fail-closed verdict, naming the
|
|
207
|
+
* specific failing precondition(s). The CLI resolves the live facts and passes
|
|
208
|
+
* them in; this stays pure so each branch is unit-testable.
|
|
209
|
+
*
|
|
210
|
+
* @returns {{ ok: boolean, failures: Array<{ precondition: string, reason: string }>, mergeClass: string, approvalVia: string|null }}
|
|
211
|
+
*/
|
|
212
|
+
export function evaluateMergePreconditions({
|
|
213
|
+
humanApprovedBy,
|
|
214
|
+
mergeable = null,
|
|
215
|
+
mergeStateStatus = null,
|
|
216
|
+
ciGreen = null,
|
|
217
|
+
title = null,
|
|
218
|
+
gateEvidence = null,
|
|
219
|
+
sizeOutcome = null,
|
|
220
|
+
// Default null (not false): a missing/absent T1 signal must reach
|
|
221
|
+
// resolveSizeBudgetHumanApprovalRequired as a non-boolean so it fails closed,
|
|
222
|
+
// rather than being coerced to "T1 untouched".
|
|
223
|
+
touchesT1 = null,
|
|
224
|
+
unresolvedChangesRequestedCount = null,
|
|
225
|
+
currentHeadSha = null,
|
|
226
|
+
reviews = [],
|
|
227
|
+
comments = [],
|
|
228
|
+
standingAuthorized = false,
|
|
229
|
+
stableRelease = false,
|
|
230
|
+
} = {}) {
|
|
231
|
+
const failures = [];
|
|
232
|
+
|
|
233
|
+
if (!isValidGithubLogin(humanApprovedBy)) {
|
|
234
|
+
failures.push({ precondition: "human_approver", reason: "--human-approved-by must be a real GitHub login (not empty, a boolean, or free text)" });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (mergeable !== "MERGEABLE" || (typeof mergeStateStatus === "string" && ["DIRTY", "BEHIND", "UNKNOWN"].includes(mergeStateStatus.toUpperCase()))) {
|
|
238
|
+
failures.push({ precondition: "mergeable", reason: `PR is not conflict-free with base (mergeable=${mergeable ?? "unknown"}, mergeStateStatus=${mergeStateStatus ?? "unknown"}); expected mergeable=MERGEABLE` });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Fail closed on anything but an explicit { green: true } — a `false`, null, or
|
|
242
|
+
// malformed ciGreen must NOT slip past this fail-closed aggregate.
|
|
243
|
+
if (!ciGreen || ciGreen.green !== true) {
|
|
244
|
+
failures.push({ precondition: "ci_green", reason: (ciGreen && ciGreen.reason) ? ciGreen.reason : "CI status could not be resolved for the current head" });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// findBlockingTitleMarkers returns [] for a non-string title, so an absent or
|
|
248
|
+
// malformed title payload would silently pass this fail-closed gate — refuse it.
|
|
249
|
+
if (typeof title !== "string" || title.trim().length === 0) {
|
|
250
|
+
failures.push({ precondition: "title_markers", reason: "PR title is missing or unreadable; cannot verify it is free of merge-blocking markers" });
|
|
251
|
+
} else {
|
|
252
|
+
const titleMarkers = findBlockingTitleMarkers(title);
|
|
253
|
+
if (titleMarkers.length > 0) {
|
|
254
|
+
failures.push({ precondition: "title_markers", reason: `PR title carries merge-blocking marker(s): ${titleMarkers.join(", ")}` });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!gateEvidence || gateEvidence.ok !== true) {
|
|
259
|
+
const reason = gateEvidence && Array.isArray(gateEvidence.failures) && gateEvidence.failures.length > 0
|
|
260
|
+
? gateEvidence.failures.join("; ")
|
|
261
|
+
: "draft_gate / current-head pre_approval_gate evidence is missing or unverified";
|
|
262
|
+
failures.push({ precondition: "gate_evidence", reason });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Computed once, ahead of the size gate, so both preconditions draw "valid
|
|
266
|
+
// human approval" from the one shared resolver instead of two divergent
|
|
267
|
+
// checks (verifyFreshHumanApproval already owns the comment token, head
|
|
268
|
+
// pinning, and bot exclusion; the size gate no longer re-derives it from
|
|
269
|
+
// reviewDecision alone).
|
|
270
|
+
const freshApproval = verifyFreshHumanApproval({ approvedBy: humanApprovedBy, currentHeadSha, reviews, comments });
|
|
271
|
+
|
|
272
|
+
if (resolveSizeBudgetHumanApprovalRequired({ sizeOutcome, touchesT1, humanApprovalSatisfied: freshApproval.satisfied, unresolvedChangesRequestedCount }) === true) {
|
|
273
|
+
failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
|
|
277
|
+
const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
|
|
278
|
+
if (!decision.authorized) {
|
|
279
|
+
failures.push({ precondition: "merge_approval", reason: decision.reason });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { ok: failures.length === 0, failures, mergeClass, approvalVia: decision.authorized ? decision.via : null };
|
|
283
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
MISSING_AC_DOD_MATRIX_FINDING,
|
|
9
9
|
MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
10
10
|
} from "./issue-refinement-artifact.mjs";
|
|
11
|
+
import { COMPLETE_FIXER_DISPOSITION_ACTION, FIXER_DISPOSITION_FORBIDDEN_ACTIONS } from "./fixer-disposition.mjs";
|
|
11
12
|
|
|
12
13
|
export const PR_CHECKPOINT = Object.freeze({
|
|
13
14
|
DRAFT_REVIEW: "draft_review",
|
|
@@ -70,6 +71,11 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
|
|
|
70
71
|
REPORT_DONE: "report_done",
|
|
71
72
|
RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
|
|
72
73
|
RECORD_DESIGNER_REVIEW: "record_designer_review",
|
|
74
|
+
// GATE-EXEC-FIXER-DISPOSITION-BOUNDARY: the only legal next action while a
|
|
75
|
+
// fixer's claimed-tackled threads have incomplete disposition. Value is the
|
|
76
|
+
// shared literal fixer-disposition.mjs's pure evaluator also returns as
|
|
77
|
+
// `nextAction` (asserted equal by test — see fixer-disposition.test.mjs).
|
|
78
|
+
COMPLETE_FIXER_DISPOSITION: COMPLETE_FIXER_DISPOSITION_ACTION,
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
function normalizeGateComment(summary = null) {
|
|
@@ -908,6 +914,49 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
908
914
|
});
|
|
909
915
|
}
|
|
910
916
|
|
|
917
|
+
// GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
|
|
918
|
+
// a caller-supplied fixerDisposition input records whether every thread a
|
|
919
|
+
// fixer claims to have tackled since the last push is fully disposed
|
|
920
|
+
// (commit contained, replied with that commit's evidence, resolved, and
|
|
921
|
+
// re-verified live — see fixer-disposition.mjs's pure evaluator). Present
|
|
922
|
+
// and NOT complete fails this boundary CLOSED regardless of
|
|
923
|
+
// unresolvedThreadCount or lifecycleState — the failure this closes is a
|
|
924
|
+
// review round opening over a dirty surface even when the thread count
|
|
925
|
+
// itself reads clean (bogus/uncontained evidence), so it must run ahead of
|
|
926
|
+
// every lifecycle-state branch below, not be derived from one.
|
|
927
|
+
const fixerDisposition = input.fixerDisposition && typeof input.fixerDisposition === "object"
|
|
928
|
+
? input.fixerDisposition
|
|
929
|
+
: null;
|
|
930
|
+
if (fixerDisposition && fixerDisposition.complete !== true) {
|
|
931
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION]);
|
|
932
|
+
pushUnique(forbiddenActions, FIXER_DISPOSITION_FORBIDDEN_ACTIONS);
|
|
933
|
+
const incompleteThreads = Array.isArray(fixerDisposition.incomplete) ? fixerDisposition.incomplete : [];
|
|
934
|
+
const reasonParts = incompleteThreads.map((entry) => (
|
|
935
|
+
`thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
|
|
936
|
+
));
|
|
937
|
+
return buildResult({
|
|
938
|
+
repo: input.repo ?? null,
|
|
939
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
940
|
+
currentHeadSha,
|
|
941
|
+
lifecycleState: effectiveLifecycleState,
|
|
942
|
+
loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
|
|
943
|
+
gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
|
|
944
|
+
draftGateAlreadySatisfied,
|
|
945
|
+
draftGate,
|
|
946
|
+
preApprovalGate,
|
|
947
|
+
allowedNextActions,
|
|
948
|
+
forbiddenActions,
|
|
949
|
+
nextAction: PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION,
|
|
950
|
+
reason: reasonParts.length > 0
|
|
951
|
+
? `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action for ${incompleteThreads.length} tackled thread(s) with incomplete disposition: ${reasonParts.join("; ")}. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`
|
|
952
|
+
: `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action until fixer disposition is complete and re-verified. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`,
|
|
953
|
+
mergeStateStatus,
|
|
954
|
+
conflictFiles,
|
|
955
|
+
refinementArtifact,
|
|
956
|
+
copilotReviewRoundCount,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
911
960
|
// UI e2e auto-scoping precondition. Path-triggered + fail-closed:
|
|
912
961
|
// if the PR's changed files touch a rendered artifact (a deck under
|
|
913
962
|
// docs/articles|presentations, or the inspect-run viewer source), it MUST be
|
|
@@ -50,9 +50,11 @@ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
|
|
|
50
50
|
issue_intake: LOGICAL_COLUMN.NEXT_UP,
|
|
51
51
|
refinement: LOGICAL_COLUMN.NEXT_UP,
|
|
52
52
|
no_pr: LOGICAL_COLUMN.NEXT_UP,
|
|
53
|
-
pr_draft: LOGICAL_COLUMN.NEXT_UP,
|
|
54
53
|
|
|
55
54
|
// In Progress — active implementation / review / feedback resolution
|
|
55
|
+
// A draft PR exists, so a runner owns the item; it is no longer pickable.
|
|
56
|
+
// This reconciles with the outer `implementation` lifecycle mapping.
|
|
57
|
+
pr_draft: LOGICAL_COLUMN.IN_PROGRESS,
|
|
56
58
|
implementation: LOGICAL_COLUMN.IN_PROGRESS,
|
|
57
59
|
// Tolerated alias for `implementation` (conceptual name);
|
|
58
60
|
// the queue driver passes the real `implementation` lifecycle state.
|
|
@@ -125,8 +127,9 @@ export function deriveReconcileColumn(facts = {}) {
|
|
|
125
127
|
// Merged PR (item is a PR, or issue's linked PR merged) => Done.
|
|
126
128
|
if (prState === "MERGED") return LOGICAL_COLUMN.DONE;
|
|
127
129
|
if (itemKind === "issue" && issueState === "CLOSED") return LOGICAL_COLUMN.DONE;
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
+
// Any OPEN linked PR (draft or ready) => In Progress: a runner owns the item,
|
|
131
|
+
// so it must never be advertised in the pickup queue.
|
|
132
|
+
if (prState === "OPEN") return LOGICAL_COLUMN.IN_PROGRESS;
|
|
130
133
|
// Otherwise leave the item untouched (Backlog / Next Up ordering preserved).
|
|
131
134
|
return null;
|
|
132
135
|
}
|
|
@@ -149,14 +149,13 @@ export function normalizeCheckpointCycleIdentity(identity) {
|
|
|
149
149
|
* cycle forever).
|
|
150
150
|
*
|
|
151
151
|
* A `complete` or `skipped` artifact is scoped by `hasNewerMergeSinceCheckpoint`:
|
|
152
|
-
* when true,
|
|
153
|
-
* point (or
|
|
154
|
-
* cannot cover the newer cycle — it fails
|
|
155
|
-
* derives
|
|
156
|
-
* pure/I/O-free)
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* `required` record for the new cycle.
|
|
152
|
+
* when true, a newer PR merged into the configured base branch since the
|
|
153
|
+
* checkpoint's recorded discharge point (or ancestry/association could not
|
|
154
|
+
* be verified), so the checkpoint cannot cover the newer cycle — it fails
|
|
155
|
+
* closed to MISSING. The caller derives the boolean itself (this module stays
|
|
156
|
+
* pure/I/O-free) from local git ancestry plus authoritative commit-to-PR
|
|
157
|
+
* association, so this runs fresh on every evaluation rather than depending
|
|
158
|
+
* on anything having written a fresh `required` record for the new cycle.
|
|
160
159
|
*
|
|
161
160
|
* `required`/`none` are not scoped by this comparison: `required` already
|
|
162
161
|
* maps to MISSING regardless of recency (an outstanding requirement blocks
|