@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 +2 -1
- package/src/config/config.mjs +22 -0
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/worktree-guard.mjs +80 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.2
|
|
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",
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
|
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;
|
|
@@ -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
|
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* `
|
|
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
|
-
|
|
143
|
+
if (best === null || normalizedP.length > best.length) best = normalizedP;
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
|
-
return
|
|
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.
|