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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.2-slim.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -22,6 +22,7 @@
22
22
  "./debt/signal": "./src/debt/debt-signal.mjs",
23
23
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
24
24
  "./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
25
+ "./github/closing-ref-guard": "./src/github/closing-ref-guard.mjs",
25
26
  "./github/gh": "./src/github/gh.mjs",
26
27
  "./github/issue-ops": "./src/github/issue-ops.mjs",
27
28
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
@@ -447,12 +447,34 @@ function boardRefConfig(ownerKey) {
447
447
  });
448
448
  }
449
449
 
450
+ /**
451
+ * Logical board columns the queue status-column config recognizes. Mirrors
452
+ * LOGICAL_COLUMN in loop/queue-board-sync.mjs; kept inline (a frozen 4-value
453
+ * list) so this low-level config-schema module does not depend on
454
+ * queue-board-sync, which pulls in the projects/GitHub-access modules through
455
+ * its own imports. The two lists are pinned in lockstep by the schema test.
456
+ */
457
+ const QueueLogicalColumn = z.enum(["next_up", "in_progress", "ready_for_review", "done"]);
458
+
450
459
  /** Queue mode config */
451
460
  const QueueConfig = z.strictObject({
452
461
  maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
453
462
  maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
454
463
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
455
464
  archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
465
+ statusColumns: z
466
+ .strictObject({
467
+ next_up: z.string().trim().min(1).optional(),
468
+ in_progress: z.string().trim().min(1).optional(),
469
+ ready_for_review: z.string().trim().min(1).optional(),
470
+ done: z.string().trim().min(1).optional(),
471
+ })
472
+ .describe("Logical-column -> board display-name overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
473
+ .optional(),
474
+ stateColumnMap: z
475
+ .record(z.string().trim().min(1), QueueLogicalColumn)
476
+ .describe("Loop-state -> known logical column overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
477
+ .optional(),
456
478
  });
457
479
 
458
480
  /**
@@ -195,6 +195,17 @@ gates:
195
195
  fanout:
196
196
  maxAnglesPerGroup: 3
197
197
  maxConcurrent: 4
198
+ # The table is global; a group is only emitted for a gate that actually
199
+ # resolves at least one of its angles, so a group naming preApproval-only
200
+ # angles is inert for the draft/spike gates (their angle sets never include
201
+ # these), and vice versa. The first four groups name draft-gate surfaces;
202
+ # the design-* and finalization groups name preApproval-exclusive angles so
203
+ # a grouped preApproval round collapses to one reviewer per group instead of
204
+ # scattering those angles across arbitrary auto-chunked leftover units.
205
+ # finalization names only correctness-final/ui-validation: contradiction-lens
206
+ # is deliberately NOT grouped here because it is also a draft-gate angle, and
207
+ # a global group naming it would peel it into a finalization unit in the draft
208
+ # gate too. It stays an auto-chunk leftover in both gates.
198
209
  groups:
199
210
  - name: docs-surface
200
211
  angles: [docs, link-check, config-drift, contract-surface]
@@ -204,6 +215,12 @@ gates:
204
215
  angles: [correctness, input-validation]
205
216
  - name: determinism-state
206
217
  angles: [determinism, state-concurrency]
218
+ - name: design-simplicity
219
+ angles: [dry, kiss, yagni, deep]
220
+ - name: design-solid
221
+ angles: [srp, soc, ocp, lsp, isp, dip]
222
+ - name: finalization
223
+ angles: [correctness-final, ui-validation]
207
224
  preApproval:
208
225
  angles:
209
226
  - name: dry
@@ -0,0 +1,80 @@
1
+ // Canonical closing-reference primitives shared by the create-pr / edit-pr
2
+ // wrappers so both guard a body's closing reference against the branch's own
3
+ // resolved issue with one implementation. A body swap that re-points the
4
+ // reference at a different issue would otherwise pass silently, and a merge
5
+ // would then close the wrong issue — this is the fail-closed backstop against
6
+ // that data-integrity hole.
7
+
8
+ import { extractClosingIssueNumbers as extractCanonicalClosingRefs } from "../loop/issue-refinement-artifact.mjs";
9
+
10
+ // Every issue number the body's closing references name. Delegates to the ONE
11
+ // canonical body-spec parser so the closing-keyword vocabulary (close/closes/
12
+ // closed, fix/fixes/fixed, resolve/resolves/resolved, any case), the cross-repo
13
+ // `owner/repo#N` form, fenced/inline-code stripping (a `Closes #N` inside a
14
+ // ```fenced``` example or `inline code` span does not auto-close on GitHub and
15
+ // must not spoof the guard), and de-duplication stay owned in ONE place — the
16
+ // guard never re-implements them.
17
+ export function extractClosingIssueNumbers(body) {
18
+ if (!body || typeof body !== "string") return [];
19
+ return extractCanonicalClosingRefs(body);
20
+ }
21
+
22
+ // True when the body carries any closing keyword.
23
+ export function detectClosingKeyword(body) {
24
+ return extractClosingIssueNumbers(body).length > 0;
25
+ }
26
+
27
+ // The issue number from the body's first closing reference, or null when the
28
+ // body carries none. Back-compat surface (create-pr's `--issue` missing-reference
29
+ // check); the mismatch guard uses extractClosingIssueNumbers to see every one.
30
+ export function extractClosingIssueNumber(body) {
31
+ const all = extractClosingIssueNumbers(body);
32
+ return all.length > 0 ? all[0] : null;
33
+ }
34
+
35
+ // Branch slug `[<prefix>/]issue-<N>[-<slug>]` -> N. Matches the dev-loop
36
+ // worktree default branch name and the prefixed form (e.g. a `dl/`-prefixed
37
+ // slug). Returns null when the branch encodes no issue number.
38
+ const BRANCH_ISSUE_PATTERN = /(?:^|\/)issue-(\d+)(?:-|$)/u;
39
+ export function extractIssueFromBranchSlug(branch) {
40
+ if (!branch || typeof branch !== "string") return null;
41
+ const match = BRANCH_ISSUE_PATTERN.exec(branch.trim());
42
+ return match ? Number(match[1]) : null;
43
+ }
44
+
45
+ // Resolve the issue a PR is expected to close from its own facts. The branch
46
+ // slug is authoritative (it encodes the issue the loop cut the branch for);
47
+ // the PR's GitHub-derived closingIssuesReferences is the fallback. Returns null
48
+ // when neither yields an issue — a genuinely issue-less PR, which is exempt.
49
+ export function resolveExpectedIssueFromPrContext(ctx) {
50
+ if (!ctx || typeof ctx !== "object") return null;
51
+ const fromBranch = extractIssueFromBranchSlug(ctx.headRefName);
52
+ if (fromBranch !== null) return fromBranch;
53
+ const refs = Array.isArray(ctx.closingIssuesReferences) ? ctx.closingIssuesReferences : [];
54
+ for (const ref of refs) {
55
+ const n = typeof ref === "number" ? ref : Number(ref?.number);
56
+ if (Number.isInteger(n) && n > 0) return n;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Compare the body's closing reference against the branch's resolved issue.
62
+ // Returns a named refusal string when they disagree, else null. A waiver
63
+ // bypasses; an unresolved expected issue (issue-less) is exempt; a body with no
64
+ // closing reference has nothing to mislink and is exempt (only a present-and-
65
+ // disagreeing reference is refused, never a missing one).
66
+ export function resolveClosingRefMismatch({ body, expectedIssue, allowCrossIssue = false }) {
67
+ if (allowCrossIssue) return null;
68
+ if (!Number.isInteger(expectedIssue)) return null;
69
+ const closing = extractClosingIssueNumbers(body);
70
+ if (closing.length === 0) return null;
71
+ // Refuse when ANY closing reference disagrees — GitHub closes every one, so a
72
+ // correct first reference does not excuse a wrong second (a single-issue
73
+ // dev-loop PR closes only its branch's issue; a deliberate multi/cross-issue
74
+ // reference uses the waiver).
75
+ const disagreeing = closing.find((n) => n !== expectedIssue);
76
+ if (disagreeing !== undefined) {
77
+ return `CLOSING-REF-BRANCH-MISMATCH: the body closes #${disagreeing} but the branch resolves to issue #${expectedIssue} — refusing a mismatched closing reference (pass --allow-cross-issue to record a deliberate cross-issue reference)`;
78
+ }
79
+ return null;
80
+ }
@@ -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 bundle = resolverOutput.bundle ?? resolverOutput;
588
- const strategy = requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
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 = (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
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 = deriveStopRules(settings, strategy);
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 = lookupAcceptanceTemplate(strategy, subGate);
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: true,
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 = tables.filter((t) => {
512
- if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
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 headerNamesMap =
516
- MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
517
- MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
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 < 2) continue;
529
- const criterion = cells[0] ?? "";
530
- const evidence = cells[1] ?? "";
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;
@@ -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, something has merged since the checkpoint's recorded discharge
153
- * point (or that point could not be verified at all), so the checkpoint
154
- * cannot cover the newer cycle — it fails closed to MISSING. The caller
155
- * derives `hasNewerMergeSinceCheckpoint` itself (this module stays
156
- * pure/I/O-free) by checking local git ancestry between the checkpoint's
157
- * recorded merge commit and the base branch, so this runs fresh on every
158
- * evaluation rather than depending on anything having written a fresh
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
@@ -35,12 +35,13 @@ export function isUnderWorktreePath(cwd) {
35
35
  * @returns {string | null} The main worktree path, or null if it cannot be parsed.
36
36
  */
37
37
  export function parseMainWorktreePath(worktreeListOutput) {
38
- const firstLine = worktreeListOutput.split("\n")[0].trim();
39
- if (!firstLine) return null;
40
- // Find the first hex SHA (7+ chars) preceded by whitespace; take everything before it as the path.
41
- const shaIdx = firstLine.search(/\s[0-9a-f]{7,64}\b/iu);
42
- if (shaIdx === -1) return null;
43
- return firstLine.slice(0, shaIdx).trim();
38
+ // The main worktree is the FIRST parseable entry — derived from the same
39
+ // parser `parseAllWorktreePaths` uses, so the two can never disagree about
40
+ // which path is the main checkout. Parsing only the raw first line would
41
+ // return null on a leading blank/SHA-less line while `parseAllWorktreePaths`
42
+ // still parses the real main line, nulling the main-checkout guard while the
43
+ // path stays admittable (the leading-blank-line fail-open).
44
+ return parseAllWorktreePaths(worktreeListOutput)[0] ?? null;
44
45
  }
45
46
 
46
47
  /**
@@ -115,10 +116,15 @@ export function isListedWorktree(cwd, worktreePaths) {
115
116
  /**
116
117
  * Resolve the root of the listed git worktree that contains `cwd`.
117
118
  *
118
- * Mirrors `isListedWorktree`'s matching (realpath-resolved, tmp/worktrees/-scoped,
119
- * exact-or-subdirectory) but returns the worktree ROOT instead of a boolean, so
120
- * callers can address files relative to the worktree's own subtree (`packages/`,
121
- * `node_modules/`) rather than the possibly-nested `cwd`.
119
+ * Returns the worktree ROOT (realpath-resolved, exact-or-subdirectory match)
120
+ * instead of a boolean, so callers can address files relative to the worktree's
121
+ * own subtree (`packages/`, `node_modules/`) rather than the possibly-nested
122
+ * `cwd`. Matches ANY listed worktree — not just `tmp/worktrees/`-scoped ones —
123
+ * so the core-isolation invariant is evaluable for a sibling/linked checkout
124
+ * that lives outside `tmp/worktrees/`; the caller decides admit/reject.
125
+ * When `cwd` sits under nested worktrees (a `tmp/worktrees/` child inside its
126
+ * parent checkout), the LONGEST matching root wins, so the innermost worktree's
127
+ * own subtree is addressed regardless of `git worktree list` order.
122
128
  *
123
129
  * @param {string} cwd - Absolute or relative path inside the worktree.
124
130
  * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
@@ -128,16 +134,16 @@ export function resolveContainingWorktreeRoot(cwd, worktreePaths) {
128
134
  let resolvedCwd;
129
135
  try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
130
136
  const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
137
+ let best = null;
131
138
  for (const p of worktreePaths) {
132
139
  let resolvedP;
133
140
  try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
134
141
  const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
135
- if (!isUnderWorktreePath(normalizedP)) continue;
136
142
  if (normalizedCwd === normalizedP || normalizedCwd.startsWith(normalizedP + "/")) {
137
- return normalizedP;
143
+ if (best === null || normalizedP.length > best.length) best = normalizedP;
138
144
  }
139
145
  }
140
- return null;
146
+ return best;
141
147
  }
142
148
 
143
149
  /**
@@ -191,6 +197,67 @@ export function isWorktreeCoreIsolated(cwd, worktreePaths) {
191
197
  return linkReal === coreReal;
192
198
  }
193
199
 
200
+ /**
201
+ * Shared admit/reject decision for local-implementation worktree isolation.
202
+ *
203
+ * The single source of truth both enforcement sites route through
204
+ * (`pre-flight-gate.mjs` `checkWorktreeIsolation` and
205
+ * `resolve-dev-loop-startup.mjs`'s `local_implementation` block), so they
206
+ * cannot diverge. Each caller maps the returned `error`/`detail` to its own
207
+ * guidance/reason wording; this function owns only the decision.
208
+ *
209
+ * `tmp/worktrees/` stays the default and recommended location, but it is not
210
+ * the invariant. The real invariant is core isolation: a checkout's
211
+ * `node_modules/@dev-loops/core` resolves to its OWN `packages/core`.
212
+ * A checkout OUTSIDE `tmp/worktrees/` that is not the main checkout and
213
+ * satisfies that invariant is admitted rather than rejected on path prefix
214
+ * alone; one that does not satisfy it (its core link escapes its own
215
+ * `packages/core`) fails closed.
216
+ *
217
+ * Decision order:
218
+ * - outside `tmp/worktrees/` + main checkout -> reject `main_checkout_detected`
219
+ * - outside `tmp/worktrees/` + unresolvable worktree root -> reject `not_in_worktree` (fail closed, no vacuous admit)
220
+ * - outside `tmp/worktrees/` + core-isolated -> ADMIT (verified-isolation)
221
+ * - outside `tmp/worktrees/` + not isolated -> reject `not_in_worktree`
222
+ * - under `tmp/worktrees/` + not a real worktree -> reject `not_in_worktree`
223
+ * - under `tmp/worktrees/` + core link escapes -> reject `core_link_escapes`
224
+ * - otherwise -> ADMIT
225
+ *
226
+ * @param {object} params
227
+ * @param {string} params.cwd - Current working directory (absolute or relative).
228
+ * @param {string | null} params.mainWorktreePath - From `parseMainWorktreePath`.
229
+ * @param {string[]} params.allWorktreePaths - From `parseAllWorktreePaths`.
230
+ * @returns {{ ok: true } | { ok: false, error: string, detail: string }}
231
+ */
232
+ export function classifyWorktreeIsolation({ cwd, mainWorktreePath, allWorktreePaths }) {
233
+ if (!isUnderWorktreePath(cwd)) {
234
+ if (mainWorktreePath !== null && isMainCheckout(cwd, mainWorktreePath)) {
235
+ return { ok: false, error: "main_checkout_detected", detail: "main_checkout" };
236
+ }
237
+ // Outside tmp/worktrees and not the main checkout: assert the REAL
238
+ // core-isolation invariant instead of rejecting on path prefix alone.
239
+ // The invariant is only meaningful for a checkout that resolves to a real
240
+ // listed git worktree root; when the root cannot be resolved (an unlisted
241
+ // checkout, or an empty/unparseable `git worktree list` — which also nulls
242
+ // mainWorktreePath and skips the main-checkout guard above) the core-isolation
243
+ // check would vacuously return true, so fail closed rather than admit.
244
+ if (resolveContainingWorktreeRoot(cwd, allWorktreePaths) === null) {
245
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
246
+ }
247
+ if (isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
248
+ return { ok: true };
249
+ }
250
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
251
+ }
252
+ if (!isListedWorktree(cwd, allWorktreePaths)) {
253
+ return { ok: false, error: "not_in_worktree", detail: "fake_worktree" };
254
+ }
255
+ if (!isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
256
+ return { ok: false, error: "core_link_escapes", detail: "core_escapes" };
257
+ }
258
+ return { ok: true };
259
+ }
260
+
194
261
 
195
262
  /**
196
263
  * Realpath-normalize a path that MAY NOT EXIST yet.