@dev-loops/core 1.0.1 → 1.0.2-slim.0

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.
@@ -1,30 +1,19 @@
1
1
  /**
2
2
  * Deterministic outer dev-loop lifecycle state model.
3
3
  *
4
- * This module defines the sequential lifecycle phases — issue_intake →
5
- * refinement → implementation → draft_gate → feedback_resolution →
6
- * pre_approval_gate → merge — as a consultable graph so skills use
7
- * machine-resolved state instead of restating the flow in prose.
8
- *
9
- * This module provides:
10
- * - LIFECYCLE_STATE: stable phase name constants
11
- * - LIFECYCLE_TRANSITIONS: legal transition graph between phases
12
- * - LIFECYCLE_GRAPH: metadata (start, end, entry, terminal, nonterminal)
13
- * - LIFECYCLE_NEXT_ACTIONS: recommended next action for each phase
14
- * - resolveLifecycleState: resolver that maps inputs to one lifecycle phase
15
- * - getAllowedTransitions: helper to list allowed next phases
16
- * - COPILOT_INNER_STATE_MAP: maps lifecycle phases to copilot-loop-state.mjs inner states
4
+ * Defines the sequential lifecycle phases — issue_intake → refinement →
5
+ * implementation → draft_gate → feedback_resolution → pre_approval_gate →
6
+ * merge — as a consultable graph so skills use machine-resolved state instead
7
+ * of restating the flow in prose.
17
8
  *
18
9
  * Contract guarantees:
19
- * - One deterministic lifecycle phase per normalized input set
20
- * - Ambiguous or incomplete inputs fall back to issue_intake
21
- * - Transition graph enforces legal phase progression
22
- * - Purely functional; no I/O or side effects
10
+ * - One deterministic lifecycle phase per normalized input set.
11
+ * - Ambiguous or incomplete inputs fall back to issue_intake.
12
+ * - Purely functional; no I/O or side effects.
23
13
  *
24
- * Integration boundary:
25
- * - Skills call resolveLifecycleState to determine current phase
26
- * - Copilot-loop-state.mjs remains the inner machine for the Copilot review portion
27
- * - Lifecycle phases are the outer sequence; inner states are sub-phase detail
14
+ * Integration boundary: lifecycle phases are the outer sequence;
15
+ * copilot-loop-state.mjs remains the inner machine for the Copilot review
16
+ * portion (inner states are sub-phase detail).
28
17
  */
29
18
 
30
19
  // ---------------------------------------------------------------------------
@@ -4,8 +4,8 @@ import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
4
4
  import { evaluateUiDesignerReviewScoping } from "./ui-designer-review-scoping.mjs";
5
5
  import { trimmedOrNull } from "./normalize.mjs";
6
6
  import {
7
- MISSING_AC_CHECKLIST_FINDING,
8
- MISSING_DOD_CHECKLIST_FINDING,
7
+ MALFORMED_AC_DOD_MATRIX_FINDING,
8
+ MISSING_AC_DOD_MATRIX_FINDING,
9
9
  MISSING_EXPLICIT_NON_GOALS_FINDING,
10
10
  } from "./issue-refinement-artifact.mjs";
11
11
 
@@ -26,7 +26,7 @@ export const PR_CHECKPOINT = Object.freeze({
26
26
 
27
27
 
28
28
  /**
29
- * Refinement-artifact gate check (issue #532).
29
+ * Refinement-artifact gate check.
30
30
  *
31
31
  * The draft gate must verify the linked issue has an explicit refinement
32
32
  * artifact (Acceptance criteria / DoD / linked refinement doc) before it
@@ -246,16 +246,16 @@ function normalizeRefinementArtifactStatus(value) {
246
246
  // their own validation-failure reason from the detector; that reason must
247
247
  // replace the "linked issue" wording, which does not apply when the PR is the
248
248
  // spec-of-record and no linked issue was ever expected.
249
- // #1877 full-matrix vocabulary for the draft-gate blocked reason: which
250
- // matrix arm is missing per finding — the SAME finding taxonomy the enqueue
251
- // gate's guidance (decideEnqueueRefinementGate) and the detector
249
+ // Matrix-floor vocabulary for the draft-gate blocked reason: which piece
250
+ // of the refinement floor is missing per finding — the SAME finding taxonomy
251
+ // the enqueue gate's guidance (decideEnqueueRefinementGate) and the detector
252
252
  // (detectIssueRefinementArtifact) use, so the draft gate (the unconditional
253
- // backstop for that floor) cannot drift from it. An AC-only or DoD-only
254
- // linked issue is a matrix miss, not "no artifact" — the guidance must name
255
- // the actually-missing arm so the fix is not misdirected.
253
+ // backstop for that floor) cannot drift from it. A checklist-only or
254
+ // matrix-malformed linked issue names the actually-missing/invalid artifact so
255
+ // the fix is not misdirected.
256
256
  const REFINEMENT_MISSING_ARM_BY_FINDING = Object.freeze({
257
- [MISSING_DOD_CHECKLIST_FINDING]: "a Definition of done checklist (mapped to the acceptance criteria)",
258
- [MISSING_AC_CHECKLIST_FINDING]: "an Acceptance criteria checklist (for the DoD items to map to)",
257
+ [MISSING_AC_DOD_MATRIX_FINDING]: "an AC→DoD mapping matrix (a two-column table mapping each acceptance-criterion outcome to its required completion evidence)",
258
+ [MALFORMED_AC_DOD_MATRIX_FINDING]: "a valid AC→DoD mapping matrix (the existing table is empty or identifier-only/tautological)",
259
259
  [MISSING_EXPLICIT_NON_GOALS_FINDING]: "an explicit Non-goals section",
260
260
  });
261
261
 
@@ -269,15 +269,15 @@ function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact)
269
269
  : REFINEMENT_ARTIFACT_FINDING;
270
270
  const missingArm = REFINEMENT_MISSING_ARM_BY_FINDING[finding];
271
271
  if (missingArm !== undefined) {
272
- return `Linked issue #${linkedIssue} has an incomplete refinement matrix — it is missing ${missingArm}. Add it to the issue body to complete the full AC/DoD/Non-goals matrix, then re-open the draft PR. finding=${finding}`;
272
+ return `Linked issue #${linkedIssue} has an incomplete refinement matrix — it is missing ${missingArm}. Add it to the issue body to complete the ACDoD mapping matrix + Non-goals refinement floor, then re-open the draft PR. finding=${finding}`;
273
273
  }
274
274
  if (linkedIssue !== null && Number.isInteger(linkedIssue)) {
275
- return `Linked issue #${linkedIssue} has no refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). Refine the issue to the full AC/DoD/Non-goals matrix — or link a refinement doc (tmp/refinement/*.md), a complete artifact on its own — then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
275
+ return `Linked issue #${linkedIssue} has no refinement artifact (no AC→DoD mapping matrix, or a resolvable linked refinement doc). Refine the issue to the authoritative ACDoD mapping matrix plus an explicit Non-goals section — or link a refinement doc (tmp/refinement/*.md), a complete artifact on its own — then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
276
276
  }
277
- return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
277
+ return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (no AC→DoD mapping matrix, or a resolvable linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
278
278
  }
279
279
 
280
- // #1472: describes the CI state a round-cap-reached fallback branch actually
280
+ // Describes the CI state a round-cap-reached fallback branch actually
281
281
  // granted on, for human-read reason text. preApprovalRequireCi:false plus a
282
282
  // non-success/non-crediblyGreen ciStatus is not "green CI" — claiming it is
283
283
  // would be the same false-CI-claim buildRoundExhaustionGateEvidenceNote below
@@ -301,7 +301,7 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
301
301
 
302
302
  /**
303
303
  * Blocked result for a PR that would otherwise reach final_approval_ready but
304
- * still carries a merge-blocking marker in its title (issue #842). The title is
304
+ * still carries a merge-blocking marker in its title. The title is
305
305
  * the most visible contract surface, so a WIP/DRAFT/DO NOT MERGE title must
306
306
  * block the final-approval boundary just like the mark-ready transition does.
307
307
  */
@@ -445,7 +445,7 @@ function buildResult({
445
445
  * caller must run request-copilot-review.mjs first.
446
446
  *
447
447
  * Exception: round-cap clean fallback (rounds exhausted + clean converged)
448
- * does not require a formal re-request (#613).
448
+ * does not require a formal re-request.
449
449
  *
450
450
  * @param {object} params
451
451
  * @param {string} params.copilotReviewRequestStatus - "none"|"requested"|"already-requested"|"unavailable"|"failed"
@@ -454,10 +454,10 @@ function buildResult({
454
454
  * @param {number|null} params.maxCopilotRounds
455
455
  * @param {boolean} params.sameHeadCleanConverged
456
456
  * @param {boolean} [params.roundCapCleanFallback=false] - interpreter resolved the
457
- * round-cap clean fallback (#896): rounds exhausted + clean threads + green CI on
457
+ * round-cap clean fallback: rounds exhausted + clean threads + green CI on
458
458
  * the current head, including a post-cap head Copilot has not (and will not)
459
459
  * re-review. No further Copilot round is permitted, so the formal-request guard
460
- * must not fire — the pre_approval_gate reviews the post-cap head (per #848).
460
+ * must not fire — the pre_approval_gate reviews the post-cap head.
461
461
  * @param {boolean} [params.postConvergenceSignificantChange=false] - significant
462
462
  * post-convergence changes on a newer head start a new review cycle and must
463
463
  * not be treated as round-cap clean-fallback suppression.
@@ -493,17 +493,17 @@ export function shouldGuardCopilotReviewRequest({
493
493
  }
494
494
  // Durable signal: if Copilot was ever formally requested as a reviewer,
495
495
  // the current "none" status is from a fulfilled request (normal cycle),
496
- // not from a missing request. Do not guard the happy path (#613, round 2).
496
+ // not from a missing request. Do not guard the happy path.
497
497
  if (copilotReviewEverFormallyRequested) {
498
498
  return false;
499
499
  }
500
500
  // Round-cap clean fallback: exhausted rounds + clean converged does not require
501
501
  // a formal re-request. This covers two shapes of "clean at the cap":
502
502
  // - sameHeadCleanConverged: the current head itself carries a clean Copilot review;
503
- // - roundCapCleanFallback (#896): the head is clean (zero unresolved threads + green
503
+ // - roundCapCleanFallback: the head is clean (zero unresolved threads + green
504
504
  // CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
505
505
  // Copilot round is permitted, so forcing a formal request would dead-end the loop;
506
- // the pre_approval_gate reviews the post-cap head instead (per #848).
506
+ // the pre_approval_gate reviews the post-cap head instead.
507
507
  const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
508
508
  if (
509
509
  roundCapReached
@@ -517,7 +517,7 @@ export function shouldGuardCopilotReviewRequest({
517
517
 
518
518
  /**
519
519
  * Boundaries at which a non-draft PR must NOT carry a merge-blocking title
520
- * marker (issue #842 / AC2). A WIP/DRAFT/DO NOT MERGE/🚧 title is acceptable
520
+ * marker. A WIP/DRAFT/DO NOT MERGE/🚧 title is acceptable
521
521
  * while the PR is still in draft, but the moment the PR leaves draft and reaches
522
522
  * the pre-approval gate boundary (entry) or the final-approval boundary, the
523
523
  * title is a live merge-contract surface and must be clean. The guard is applied
@@ -532,7 +532,7 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
532
532
  ]);
533
533
 
534
534
  /**
535
- * Independent gate-ENTRY re-check (issue #1190): even when the caller's
535
+ * Independent gate-ENTRY re-check: even when the caller's
536
536
  * lifecycleState/sameHeadCleanConverged claims a settled Copilot convergence,
537
537
  * an outstanding (`requested`/`already-requested`) Copilot review request on
538
538
  * the CURRENT head is a second, independent "unsettled" signal — not derived
@@ -547,8 +547,8 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
547
547
  * already been spent.
548
548
  *
549
549
  * Skipped when Copilot review is not required at all — `reviewMode:
550
- * "internal_only"` or `maxCopilotRounds: 0` — preserving the existing #613 /
551
- * #1210 exemptions (internal-only and light-dispatched-with-disabled-review
550
+ * "internal_only"` or `maxCopilotRounds: 0` — preserving the existing
551
+ * exemptions (internal-only and light-dispatched-with-disabled-review
552
552
  * PRs never need a Copilot round in the first place).
553
553
  */
554
554
  const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
@@ -589,7 +589,7 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
589
589
  if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
590
590
  return null;
591
591
  }
592
- // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest, #896/#848):
592
+ // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest):
593
593
  // past the cap a lingering requested/already-requested status is for a review
594
594
  // that can never come (no further round is permitted), so treating it as
595
595
  // "unsettled" here would re-introduce the infinite-wait dead-end the
@@ -602,7 +602,7 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
602
602
  maxCopilotRounds: input.maxCopilotRounds,
603
603
  });
604
604
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
605
- // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape (#1472):
605
+ // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape:
606
606
  // without this, this guard would rewrite that grant back to
607
607
  // waiting_for_copilot_review the instant it is produced, re-introducing the
608
608
  // never-arriving-review dead-end the round-cap exemption exists to prevent.
@@ -657,7 +657,7 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
657
657
 
658
658
  /**
659
659
  * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
660
- * (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
660
+ * at the pre-approval / final-approval boundary for non-draft PRs.
661
661
  *
662
662
  * The title check is also performed inline at the three FINAL_APPROVAL_READY
663
663
  * sites (defense in depth); this wrapper additionally covers the pre-approval
@@ -708,7 +708,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
708
708
  const prClosed = input.prClosed === true;
709
709
  const prMerged = input.prMerged === true;
710
710
  const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
711
- // Operator-authorized post-convergence suppression (#1441): set only when the
711
+ // Operator-authorized post-convergence suppression: set only when the
712
712
  // caller has verified an explicit prior withdrawal (withdraw-copilot-review-
713
713
  // request.mjs) recorded a suppression marker for this EXACT head, proving the
714
714
  // delta since Copilot's last submitted review is a pure doc/prose bump. Never
@@ -737,7 +737,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
737
737
  const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
738
738
  const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
739
739
  const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
740
- // #1472: explicit current-head unresolved-thread signal for the round-cap
740
+ // Explicit current-head unresolved-thread signal for the round-cap
741
741
  // fallback check below (STATE.ROUND_CAP_REACHED). Unlike copilotReviewRoundCount
742
742
  // (which safely defaults to 0 when absent), an absent/invalid count here must
743
743
  // NOT be coerced to 0 — that would silently treat an unknown thread count as
@@ -749,11 +749,11 @@ function evaluatePrGateCoordinationCore(input = {}) {
749
749
  const postConvergenceSignificantChange = input.postConvergenceSignificantChange === true;
750
750
  const roundCapNewCycleRequired = roundCapReached && copilotReviewRoundCount > 0 && postConvergenceSignificantChange;
751
751
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
752
- // UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
752
+ // UI e2e auto-scoping: the PR changed-file set + whether the shared UI
753
753
  // e2e suite passed for this head. Inclusion is path-triggered, never annotated.
754
754
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
755
755
  const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
756
- // Designer/vision recorded-evidence scoping (#1443, ADR 0041 UI half). See
756
+ // Designer/vision recorded-evidence scoping (ADR 0041 UI half). See
757
757
  // the designer-review scoping block below. Evidence reuses the loop's
758
758
  // existing outcome + artifact-bundle record; exempt when a light/spike
759
759
  // relaxed-gate carve-out applies.
@@ -834,7 +834,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
834
834
  });
835
835
  }
836
836
 
837
- // Mergeability is a required precondition at every gate (issue #980). GitHub
837
+ // Mergeability is a required precondition at every gate. GitHub
838
838
  // computes `mergeable` asynchronously, so an unsettled UNKNOWN must fail closed
839
839
  // to a recheck — never a pass. The detect layer already re-polls a bounded
840
840
  // number of times; if it still reads UNKNOWN here, hold gate progression and
@@ -908,13 +908,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
908
908
  });
909
909
  }
910
910
 
911
- // UI e2e auto-scoping precondition (#976). Path-triggered + fail-closed:
911
+ // UI e2e auto-scoping precondition. Path-triggered + fail-closed:
912
912
  // if the PR's changed files touch a rendered artifact (a deck under
913
913
  // docs/articles|presentations, or the inspect-run viewer source), it MUST be
914
914
  // registered in the shared UI e2e suite AND that suite must have passed for
915
915
  // this head. A rendered-artifact change with no registered/passing coverage
916
916
  // blocks here with a reason naming the artifact. Distinct seam from the
917
- // mergeability (#980) preconditions to minimize
917
+ // mergeability preconditions to minimize
918
918
  // merge-time conflict. Non-UI changes pass through untouched (required=false).
919
919
  const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
920
920
  if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
@@ -947,7 +947,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
947
947
  });
948
948
  }
949
949
 
950
- // Designer/vision recorded-evidence precondition (#1443, ADR 0041 UI half).
950
+ // Designer/vision recorded-evidence precondition (ADR 0041 UI half).
951
951
  // Path-triggered + fail-closed, modeled on the UI e2e scoping block above: if
952
952
  // the PR's changed files touch a rendered artifact (docs/articles|presentations
953
953
  // HTML), it MUST carry recorded designer/vision review evidence (the loop's
@@ -1389,7 +1389,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1389
1389
  // straight to pre_approval_gate, so this branch must not force a re-request
1390
1390
  // for a missing Copilot convergence point that will never exist. The
1391
1391
  // sibling PR_READY_NO_FEEDBACK branch already honors internal_only; this
1392
- // reconciles READY_TO_REREQUEST_REVIEW with it (issue 1771). Non-suppressed
1392
+ // reconciles READY_TO_REREQUEST_REVIEW with it. Non-suppressed
1393
1393
  // external-review PRs keep reviewMode null and hit the guard unchanged.
1394
1394
  if (!sameHeadCleanConverged && !postConvergenceReviewSuppressed && reviewMode !== "internal_only" && (!roundCapReached || roundCapNewCycleRequired)) {
1395
1395
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
@@ -1511,17 +1511,17 @@ function evaluatePrGateCoordinationCore(input = {}) {
1511
1511
  });
1512
1512
  }
1513
1513
 
1514
- // Round-cap clean fallback (#896, #848): the Copilot review round cap is
1514
+ // Round-cap clean fallback: the Copilot review round cap is
1515
1515
  // exhausted and the current head is clean (zero unresolved threads + green CI)
1516
1516
  // — including a POST-CAP head Copilot has not (and will not) re-review, since
1517
1517
  // no further Copilot round is permitted. Re-requesting review is illegal here,
1518
1518
  // so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW — nor at a forced
1519
- // rerequest for a post-convergence significant change (#1387): the cap makes
1519
+ // rerequest for a post-convergence significant change: the cap makes
1520
1520
  // that rerequest impossible (request-copilot-review suppresses it), so a
1521
1521
  // significant change discovered here is reviewed by the pre_approval_gate
1522
1522
  // fan-out itself, on the post-cap head, same as any other clean fallback. It
1523
1523
  // routes to the pre_approval_gate, which reviews the post-cap head itself
1524
- // (per #848). The CI guards below still hold (failing / credibly-green CI
1524
+ // itself. The CI guards below still hold (failing / credibly-green CI
1525
1525
  // blocks), and conflicts / blocked states are handled earlier, so
1526
1526
  // genuinely-blocked states still forbid pre_approval. Mirrors
1527
1527
  // LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
@@ -1587,10 +1587,10 @@ function evaluatePrGateCoordinationCore(input = {}) {
1587
1587
  refinementArtifact,
1588
1588
  });
1589
1589
  }
1590
- // Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
1590
+ // Mirror LOW_SIGNAL_CONVERGED: a clean current head with no clean
1591
1591
  // draft_gate evidence must reconcile the draft gate rather than jump to
1592
1592
  // final approval. This keeps the core handler consistent with the
1593
- // detect-pr-gate-coordination-state #579 post-pass, which unconditionally
1593
+ // detect-pr-gate-coordination-state post-pass, which unconditionally
1594
1594
  // downgrades FINAL_APPROVAL_READY → DRAFT_GATE_NEEDED when
1595
1595
  // draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
1596
1596
  // exemption). Without this guard the final-approval-without-draft-gate
@@ -1668,23 +1668,23 @@ function evaluatePrGateCoordinationCore(input = {}) {
1668
1668
  });
1669
1669
  }
1670
1670
 
1671
- // Defensive gate-entry re-check for ROUND_CAP_REACHED (#1472): the compound
1671
+ // Defensive gate-entry re-check for ROUND_CAP_REACHED: the compound
1672
1672
  // "unresolved threads OR non-clean CI" hard stop that copilot-loop-state.mjs
1673
1673
  // emits has no dedicated boundary of its own, so without this branch the
1674
1674
  // generic fallback below always names `report_blocked`. Both shipped
1675
1675
  // callers read lifecycleState and the CI/thread facts from ONE snapshot, and
1676
1676
  // the interpreter's own CI predicate is strictly wider than this branch's
1677
- // (it also accepts `crediblyGreen`, #1371): whenever this branch's narrower
1677
+ // (it also accepts `crediblyGreen`): whenever this branch's narrower
1678
1678
  // predicate and zero unresolved threads hold, the interpreter has already
1679
1679
  // classified the snapshot ROUND_CAP_CLEAN_FALLBACK, never ROUND_CAP_REACHED
1680
1680
  // (see the equivalence test in pr-gate-coordination.test.mjs). This branch
1681
1681
  // is therefore unreachable through the shipped callers — it is pure
1682
- // defense-in-depth, demanded by issue 1472's corrected AC so the three
1682
+ // defense-in-depth, demanded by the corrected AC so the three
1683
1683
  // fields can never disagree if a future caller hands the evaluator a
1684
1684
  // round_cap_reached label alongside facts that satisfy the grant. The
1685
1685
  // predicate intentionally mirrors every other pre-approval CI boundary in
1686
1686
  // this file (success, or CI not required) — `crediblyGreen` is unconfirmed
1687
- // CI and stays blocked here exactly as it does everywhere else (#1371). Any
1687
+ // CI and stays blocked here exactly as it does everywhere else. Any
1688
1688
  // other combination (threads still unresolved, an unknown thread count, or
1689
1689
  // CI not confirmed green) falls through unchanged to the generic default
1690
1690
  // below, preserving today's blocked behavior exactly.
@@ -1694,7 +1694,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1694
1694
  // OR CI is not required by config — never for an unconfirmed/failing/absent
1695
1695
  // status gated in only because requireCi is false. Naming it "green" in
1696
1696
  // human-read reason/evidence text for the latter case would be a false CI
1697
- // claim (#1472 defer), so describe the actual grant basis instead.
1697
+ // claim, so describe the actual grant basis instead.
1698
1698
  const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
1699
1699
  if (unresolvedThreadCount === 0 && ciConfirmedGreen) {
1700
1700
  if (preApprovalGate.currentHeadClean) {
@@ -1717,7 +1717,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1717
1717
  refinementArtifact,
1718
1718
  });
1719
1719
  }
1720
- // Mirror ROUND_CAP_CLEAN_FALLBACK/#579: a clean current head with no clean
1720
+ // Mirror ROUND_CAP_CLEAN_FALLBACK: a clean current head with no clean
1721
1721
  // draft_gate evidence must reconcile the draft gate rather than jump to
1722
1722
  // final approval.
1723
1723
  if (!draftGate.cleanEvidenceExists) {
@@ -2,7 +2,8 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
5
- import { ghGraphql } from "../github/gh.mjs";
5
+ import { resolveOwner } from "../github/gh.mjs";
6
+ import { discoverProjects } from "../projects/projects-access.mjs";
6
7
 
7
8
  const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
8
9
 
@@ -40,7 +41,7 @@ export const DEFAULT_STATE_COLUMN_NAMES = Object.freeze({
40
41
  /**
41
42
  * Default loop-state → logical-column map. Covers both the lifecycle states
42
43
  * (lifecycle-state.mjs) and the inner Copilot loop states (copilot-loop-state.mjs),
43
- * plus the conceptual names used by issue #793. Unknown states fall back to
44
+ * plus the conceptual state names. Unknown states fall back to
44
45
  * IN_PROGRESS (a safe, visible "work is happening" column) rather than throwing.
45
46
  */
46
47
  export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
@@ -53,7 +54,7 @@ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
53
54
 
54
55
  // In Progress — active implementation / review / feedback resolution
55
56
  implementation: LOGICAL_COLUMN.IN_PROGRESS,
56
- // Tolerated alias for `implementation` (conceptual name from issue #793);
57
+ // Tolerated alias for `implementation` (conceptual name);
57
58
  // the queue driver passes the real `implementation` lifecycle state.
58
59
  local_implementation_active: LOGICAL_COLUMN.IN_PROGRESS,
59
60
  draft_gate: LOGICAL_COLUMN.IN_PROGRESS,
@@ -80,7 +81,7 @@ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
80
81
  // Done — terminal (lifecycle MERGE = "merge", queue terminal = "done")
81
82
  merge: LOGICAL_COLUMN.DONE,
82
83
  done: LOGICAL_COLUMN.DONE,
83
- // Tolerated aliases (conceptual names from issue #793).
84
+ // Tolerated aliases (conceptual names).
84
85
  merged: LOGICAL_COLUMN.DONE,
85
86
  issue_closed: LOGICAL_COLUMN.DONE,
86
87
  });
@@ -109,7 +110,7 @@ export function boardColumnForLoopState(loopState, mapping = {}) {
109
110
 
110
111
  /**
111
112
  * 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
+ * facts. Returns LOGICAL_COLUMN.DONE, LOGICAL_COLUMN.IN_PROGRESS, or
113
114
  * null when the item should be left where it is (Backlog/Next Up untouched).
114
115
  *
115
116
  * facts: {
@@ -131,7 +132,7 @@ export function deriveReconcileColumn(facts = {}) {
131
132
  }
132
133
 
133
134
  /**
134
- * Pure reconcile planner (#1069). Given listed board items, a map of live facts
135
+ * Pure reconcile planner. Given listed board items, a map of live facts
135
136
  * keyed by the item's stable GraphQL node id (`item.itemId`), and the resolved
136
137
  * column display names, return the set of moves needed to converge the board and
137
138
  * a count of items left unchanged. Idempotent: when every item already sits in
@@ -139,7 +140,7 @@ export function deriveReconcileColumn(facts = {}) {
139
140
  *
140
141
  * Keying by the stable `itemId` (not the bare issue/PR number) keeps reconcile
141
142
  * 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
+ * a number (repo-A PR 5 vs repo-B issue 5) — number-keying would collide and
143
144
  * make moves order-dependent.
144
145
  *
145
146
  * items: [{ itemId, issueNumber, prNumber, status, ... }] (from list-queue-items)
@@ -172,7 +173,7 @@ function readDevloopsSettings(repoRoot) {
172
173
  try {
173
174
  const raw = readFileSync(base + ext, "utf8");
174
175
  const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
175
- // `tracker` (issue #1408, the tracker-agnostic seam) is surfaced so
176
+ // `tracker` (the tracker-agnostic seam) is surfaced so
176
177
  // loadBoardConfig can read tracker.board directly.
177
178
  return { settings: settings?.queue ?? null, tracker: settings?.tracker ?? null };
178
179
  } catch (err) {
@@ -207,7 +208,7 @@ export function loadBoardConfig(repoRoot) {
207
208
  if (error) {
208
209
  return { enabled: false, reason: `config read/parse error: ${error}` };
209
210
  }
210
- // tracker.board (canonical board key, issue #1408) — see resolveTrackerBoard
211
+ // tracker.board (canonical board key) — see resolveTrackerBoard
211
212
  // in ../config/config.mjs for the equivalent resolution against the
212
213
  // validated, loaded config.
213
214
  const trackerBoard = boardSelector(tracker?.board);
@@ -270,86 +271,19 @@ export function loadStateColumnMap(repoRoot) {
270
271
 
271
272
  // Surface a non-ENOENT read/parse error (mirrors loadBoardConfig). Callers on
272
273
  // the fail-closed next_up pickup path MUST honor it rather than silently
273
- // querying the default literal column against a stale/renamed board (#1098).
274
+ // querying the default literal column against a stale/renamed board.
274
275
  // Existing `.columnNames`-only callers ignore this field and behave unchanged.
275
276
  return { columnNames, stateColumnMap, error: error ?? null };
276
277
  }
277
278
 
278
279
  // ── Minimal project lookup (read-only, no create/repair) ────────────────
279
280
 
280
- const GET_USER_ID = [
281
- "query($login:String!) {",
282
- " user(login:$login) { id }",
283
- "}"
284
- ].join("\n");
285
-
286
- const GET_ORG_ID = [
287
- "query($login:String!) {",
288
- " organization(login:$login) { id }",
289
- "}"
290
- ].join("\n");
291
-
292
- const LIST_USER_PROJECTS = [
293
- "query($login:String!, $after:String) {",
294
- " user(login:$login) {",
295
- " projectsV2(first:50, after:$after) {",
296
- " pageInfo { hasNextPage endCursor }",
297
- " nodes { id number title url }",
298
- " }",
299
- " }",
300
- "}"
301
- ].join("\n");
302
-
303
- const LIST_ORG_PROJECTS = [
304
- "query($login:String!, $after:String) {",
305
- " organization(login:$login) {",
306
- " projectsV2(first:50, after:$after) {",
307
- " pageInfo { hasNextPage endCursor }",
308
- " nodes { id number title url }",
309
- " }",
310
- " }",
311
- "}"
312
- ].join("\n");
313
-
314
- async function resolveOwner(login, env, runChild) {
315
- const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
316
- if (userPayload?.data?.user?.id) {
317
- return { id: userPayload.data.user.id, kind: "user" };
318
- }
319
- const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
320
- if (orgPayload?.data?.organization?.id) {
321
- return { id: orgPayload.data.organization.id, kind: "org" };
322
- }
323
- throw Object.assign(
324
- new Error(`Could not resolve owner ID for "${login}"`),
325
- { code: "NO_USER_ID" },
326
- );
327
- }
328
-
281
+ // Board-sync tolerates null project nodes (defensive against a partial page);
282
+ // the shared discovery returns raw nodes, so this caller keeps its own null
283
+ // filter at the call site rather than in the shared traversal.
329
284
  async function listAllProjects(login, kind, env, runChild) {
330
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
331
- const projects = [];
332
- let after = null;
333
- while (true) {
334
- const vars = { login };
335
- if (after) vars.after = after;
336
- const payload = await ghGraphql(query, vars, env, runChild);
337
- const connection = kind === "org"
338
- ? payload?.data?.organization?.projectsV2
339
- : payload?.data?.user?.projectsV2;
340
- const nodes = connection?.nodes ?? [];
341
- projects.push(...nodes.filter((n) => n != null));
342
- const pageInfo = connection?.pageInfo ?? {};
343
- if (!pageInfo.hasNextPage) break;
344
- if (!pageInfo.endCursor) {
345
- throw Object.assign(
346
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
347
- { code: "GH_API_ERROR" },
348
- );
349
- }
350
- after = pageInfo.endCursor;
351
- }
352
- return projects;
285
+ const projects = await discoverProjects(login, kind, env, runChild);
286
+ return projects.filter((n) => n != null);
353
287
  }
354
288
 
355
289
  const projectNumberCache = new Map();