@dev-loops/core 0.7.1 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -38,8 +38,10 @@
38
38
  "./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
39
39
  "./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
40
40
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
41
+ "./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
41
42
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
42
43
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
44
+ "./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
43
45
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
44
46
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
45
47
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
@@ -55,6 +57,9 @@
55
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
56
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
57
59
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
60
+ "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
61
+ "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
62
+ "./projects/resolve-project": "./src/projects/resolve-project.mjs",
58
63
  "./harness": "./src/harness/index.mjs",
59
64
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
60
65
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
@@ -61,7 +61,7 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
61
61
  * clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
62
62
  * gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
63
63
  * entirely. Everything else passes through.
64
- * - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
64
+ * - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY when the call
65
65
  * originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
66
66
  * Sanctioned external writes flow through node wrappers (gate-verdict comments via
67
67
  * `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
@@ -85,7 +85,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
85
85
  return ALLOW;
86
86
  }
87
87
  // Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
88
- // `gh pr comment` on the target repo from a subagent, so external writes flow through the
88
+ // `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
89
89
  // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
90
90
  if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
91
91
  const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
@@ -101,9 +101,10 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
101
101
  return {
102
102
  decision: "deny",
103
103
  reason:
104
- "Ad-hoc GitHub issue/PR creation and comments from a subagent are blocked. Use the sanctioned " +
104
+ "Ad-hoc GitHub issue/PR creation, comments, and edits from a subagent are blocked. Use the sanctioned " +
105
105
  "node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
106
- "review-thread replies via scripts/github/reply-resolve*.mjs, board sync, or scripts/github/comment-issue.mjs. " +
106
+ "review-thread replies via scripts/github/reply-resolve*.mjs, board sync, issue comments via " +
107
+ "scripts/github/comment-issue.mjs, or issue-body edits via scripts/github/edit-issue.mjs. " +
107
108
  "Direct `gh issue create` is reserved for the main agent / operator.",
108
109
  };
109
110
  }
@@ -98,6 +98,12 @@ const GatesConfig = z.strictObject({
98
98
  // and every angle configured across this config's own draft/preApproval/
99
99
  // spike gates (angles + mandatoryAngles).
100
100
  anglePool: z.array(z.string().trim().min(1)).optional(),
101
+ // Fail-closed enforcement that a fanout_fanin gate's recorded per-angle
102
+ // provenance names only angles in the gate's configured pool (angles +
103
+ // mandatoryAngles) — ad-hoc/foreign angle labels are rejected rather than
104
+ // silently accepted. Default true (reject); set false to warn instead of
105
+ // fail. See resolveRejectForeignAngles / docs/gate-review-sub-loop-contract.md.
106
+ rejectForeignAngles: z.boolean().default(true),
101
107
  });
102
108
 
103
109
  const AutonomyConfig = z.strictObject({
@@ -143,6 +149,10 @@ const LocalImplementationConfig = z.strictObject({
143
149
  enabled: z.boolean(),
144
150
  maxFiles: z.number().int().min(1),
145
151
  maxLines: z.number().int().min(1),
152
+ // Copilot review round cap for light-dispatched PRs (#1210). Composes with
153
+ // (does not replace) refinement.maxCopilotRounds — see
154
+ // resolveEffectiveCopilotRoundCap.
155
+ maxCopilotRounds: z.number().int().nonnegative().default(1),
146
156
  }).optional(),
147
157
  });
148
158
 
@@ -193,6 +203,7 @@ const FileGatesConfig = z.strictObject({
193
203
  maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
194
204
  postFindingsComments: z.boolean().optional(),
195
205
  anglePool: z.array(z.string().trim().min(1)).optional(),
206
+ rejectForeignAngles: z.boolean().optional(),
196
207
  });
197
208
 
198
209
  // Partial persona entries for file-level config (allows omitting fields)
@@ -252,7 +263,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
252
263
  devModeDefault: false,
253
264
  }),
254
265
  localImplementation: Object.freeze({
255
- lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200 }),
266
+ lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
256
267
  }),
257
268
  queue: Object.freeze({
258
269
  maxParallel: 3,
@@ -1021,6 +1032,17 @@ export function resolveRequireFanoutProvenance(config) {
1021
1032
  return config?.gates?.requireFanoutProvenance === true;
1022
1033
  }
1023
1034
 
1035
+ /**
1036
+ * Resolve whether a fan-out provenance entry naming an angle outside the
1037
+ * gate's configured pool should FAIL (default) or only WARN.
1038
+ *
1039
+ * @param {DevLoopConfig} config
1040
+ * @returns {boolean}
1041
+ */
1042
+ export function resolveRejectForeignAngles(config) {
1043
+ return config?.gates?.rejectForeignAngles !== false;
1044
+ }
1045
+
1024
1046
  /**
1025
1047
  * Resolve whether the consolidated gate fan-out findings should be posted as a
1026
1048
  * visible, marker-tagged PR comment.
@@ -1061,6 +1083,33 @@ export function resolveLightMode(config) {
1061
1083
  };
1062
1084
  }
1063
1085
 
1086
+ /**
1087
+ * Resolve the effective Copilot review round cap for a PR (#1210).
1088
+ *
1089
+ * Full PRs (lightweight=false) use `refinement.maxCopilotRounds` unchanged
1090
+ * (default 5). Light-dispatched PRs compose with it rather than replacing it:
1091
+ * `effective = min(localImplementation.lightMode.maxCopilotRounds ?? 1,
1092
+ * refinement.maxCopilotRounds)` — so setting `refinement.maxCopilotRounds: 0`
1093
+ * disables Copilot rounds everywhere, including lightweight, with that one
1094
+ * setting.
1095
+ *
1096
+ * @param {DevLoopConfig} config
1097
+ * @param {{ lightweight?: boolean }} [options]
1098
+ * @returns {number}
1099
+ */
1100
+ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false } = {}) {
1101
+ // Clamp here, not only in the zod schema: programmatically-built config
1102
+ // objects bypass schema defaulting/validation, and a negative cap must never
1103
+ // reach round-cap comparisons.
1104
+ const maxCopilotRounds = Math.max(0, /** @type {number} */ (resolveRefinementConfig(config, "maxCopilotRounds")));
1105
+ if (!lightweight) return maxCopilotRounds;
1106
+ const lightMaxRounds = config?.localImplementation?.lightMode?.maxCopilotRounds;
1107
+ const effectiveLightCap = typeof lightMaxRounds === "number" && Number.isFinite(lightMaxRounds)
1108
+ ? Math.max(0, lightMaxRounds)
1109
+ : 1;
1110
+ return Math.min(effectiveLightCap, maxCopilotRounds);
1111
+ }
1112
+
1064
1113
  /** Label that forces full fan-out regardless of change size. */
1065
1114
  export const GATE_FULL_LABEL = "gate:full";
1066
1115
 
@@ -1161,6 +1210,36 @@ export function resolveAnglePool(config) {
1161
1210
  return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
1162
1211
  }
1163
1212
 
1213
+ /**
1214
+ * Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
1215
+ * fanout_fanin verdict must cover and the pool its recorded angles must stay
1216
+ * within. Single source of truth for all angle-coverage enforcement consumers
1217
+ * (ledger write, verdict-comment write, merge-evidence read) so they agree.
1218
+ *
1219
+ * - `mandatoryAngles` is filtered through `excludeAngles`: a config that
1220
+ * excludes a mandatory angle must not deadlock every fanout write (the
1221
+ * angle would be missing-mandatory if omitted yet foreign if recorded).
1222
+ * - `pool` is `resolveGateAngles` (configured angles ∪ mandatoryAngles, minus
1223
+ * excludeAngles); when `additiveAngles` is enabled it widens to the global
1224
+ * lens catalog (`resolveAnglePool`) too — dynamic resolution may
1225
+ * legitimately dispatch catalog angles then — with `excludeAngles` still a
1226
+ * hard ceiling. A null pool skips the foreign-angle check entirely.
1227
+ *
1228
+ * @param {DevLoopConfig} config
1229
+ * @param {"draft"|"preApproval"|"spike"} gate
1230
+ * @returns {{ mandatoryAngles: string[], pool: string[]|null }}
1231
+ */
1232
+ export function resolveGateAngleContract(config, gate) {
1233
+ const gateConfig = resolveGateConfig(config, gate);
1234
+ const excluded = new Set(gateConfig.excludeAngles);
1235
+ const mandatoryAngles = gateConfig.mandatoryAngles.filter((a) => !excluded.has(a));
1236
+ let pool = resolveGateAngles(config, gate);
1237
+ if (gateConfig.additiveAngles && pool !== null) {
1238
+ pool = [...new Set([...pool, ...resolveAnglePool(config)])].filter((a) => !excluded.has(a));
1239
+ }
1240
+ return { mandatoryAngles, pool };
1241
+ }
1242
+
1164
1243
  /**
1165
1244
  * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
1166
1245
  *
@@ -44,6 +44,9 @@ gates:
44
44
  - renderer-security
45
45
  - determinism
46
46
  - pr-comments
47
+ - contradiction-lens
48
+ - code-conformance
49
+ - semantic-drift
47
50
  excludeAngles: []
48
51
  required: true
49
52
  requireCi: true
@@ -66,6 +69,9 @@ gates:
66
69
  - dip
67
70
  - docs
68
71
  - pr-checklist-matrix
72
+ - contradiction-lens
73
+ - correctness-final
74
+ - ui-validation
69
75
  excludeAngles: []
70
76
  required: true
71
77
  mandatoryAngles:
@@ -15,6 +15,149 @@ export function isCopilotLogin(login) {
15
15
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
16
16
  }
17
17
 
18
+ // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
19
+ // the write-side sanitizer and the read-side guard scan key off this shape so a
20
+ // gate-evidence comment can quote the rule (inside a code span/fenced block)
21
+ // without arming the request-copilot-review.mjs anti-summon guard. The token
22
+ // regex carries the same left word-boundary as the guard regex so the sanitizer
23
+ // never mangles text the guard would not arm on (e.g. user@copilot.example).
24
+ const COPILOT_SUMMON_TOKEN_RE = /(?<=^|\W)(@copilot|\/copilot[a-z0-9_-]*)/gi;
25
+ const COPILOT_SUMMON_WORD_BOUNDARY_RE = /(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i;
26
+ // GFM inline code span: an N-backtick run, lazy content, closed by a same-length
27
+ // run. Covers single-backtick spans as well as double-backtick spans wrapping a
28
+ // literal backtick.
29
+ const INLINE_CODE_SPAN_RE = /(`+)[\s\S]*?\1(?!`)/g;
30
+ const ZERO_WIDTH_JOINER = "\u200D";
31
+
32
+ // Apply `transformLine` to every markdown line OUTSIDE a fenced code block
33
+ // (```/~~~), leaving fence-delimiter lines and fenced content untouched.
34
+ // Mirrors the fenced-block tracking scripts/docs/validate-rule-ownership.mjs
35
+ // uses for its own lexical scan.
36
+ function transformNonFencedLines(text, transformLine) {
37
+ const lines = String(text).split(/\r?\n/);
38
+ let inFencedBlock = false;
39
+ let fencedDelimiter = "";
40
+ const transformed = lines.map((line) => {
41
+ const rawTrimmed = line.trim();
42
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
43
+ if (fenceMatch) {
44
+ if (!inFencedBlock) {
45
+ inFencedBlock = true;
46
+ fencedDelimiter = fenceMatch[1];
47
+ return line;
48
+ }
49
+ if (rawTrimmed.startsWith(fencedDelimiter)) {
50
+ inFencedBlock = false;
51
+ fencedDelimiter = "";
52
+ return line;
53
+ }
54
+ }
55
+ if (inFencedBlock) {
56
+ return line;
57
+ }
58
+ return transformLine(line);
59
+ });
60
+ return transformed.join("\n");
61
+ }
62
+
63
+ // Apply `replaceSegment` to every part of a line that lies OUTSIDE an inline
64
+ // code span (any N-backtick GFM span), leaving span content untouched.
65
+ function transformOutsideSpans(line, replaceSegment) {
66
+ let result = "";
67
+ let last = 0;
68
+ for (const span of line.matchAll(INLINE_CODE_SPAN_RE)) {
69
+ result += replaceSegment(line.slice(last, span.index));
70
+ result += span[0];
71
+ last = span.index + span[0].length;
72
+ }
73
+ return result + replaceSegment(line.slice(last));
74
+ }
75
+
76
+ // Wrap bare `@copilot`/`/copilot*` tokens in backticks so a comment can quote the
77
+ // anti-summon rule without arming it. Tokens already inside an inline code span
78
+ // are left untouched.
79
+ function wrapBareSummonTokensInLine(line) {
80
+ return transformOutsideSpans(line, (segment) => segment.replace(COPILOT_SUMMON_TOKEN_RE, "`$1`"));
81
+ }
82
+
83
+ // Does this single (non-fenced) line still arm the guard scan after inline code
84
+ // spans are dropped? Mirrors stripMarkdownCodeForScan's per-line step. Spans are
85
+ // replaced with a SPACE, not the empty string: the fragments flanking a span
86
+ // must never be rejoined into a token that was not present ("@copi`x`lot" is not
87
+ // a summon), while a token directly abutting a span ("text`x`@copilot", which
88
+ // GitHub renders as a real mention) still arms.
89
+ function lineArmsSummonGuard(line) {
90
+ return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(line.replace(INLINE_CODE_SPAN_RE, " "));
91
+ }
92
+
93
+ const ZWJ_FALLBACK_RE = /(?<=^|\W)([@/])(copilot)/gi;
94
+
95
+ // Sanitize one line, verifying against the guard scan. Backtick-wrapping is the
96
+ // primary neutralization (visible, greppable), but pre-existing backticks on the
97
+ // line can destabilize it two ways: an UNBALANCED stray backtick pairs with an
98
+ // inserted one and re-exposes the token to the guard's span-stripping, and
99
+ // adjacent spans (e.g. a span ending right before the token's new wrap) can make
100
+ // the wrapped line re-tokenize differently on the next pass, re-wrapping the
101
+ // token and growing the comment by one backtick per rewrite. The wrapped result
102
+ // is therefore accepted only when it is BOTH guard-inert AND a fixed point of
103
+ // the wrapper (re-wrapping it changes nothing); otherwise fall back to inserting
104
+ // a zero-width joiner into the residual tokens still outside the wrapped line's
105
+ // spans — invisible, guard-inert, and idempotent (the joined token no longer
106
+ // matches the summon shape). Working on the wrapped line (not the original)
107
+ // preserves every stable backtick wrap and keeps the joiner out of legitimate
108
+ // pre-existing code spans.
109
+ function sanitizeSummonLine(line) {
110
+ const wrapped = wrapBareSummonTokensInLine(line);
111
+ if (!lineArmsSummonGuard(wrapped) && wrapBareSummonTokensInLine(wrapped) === wrapped) {
112
+ return wrapped;
113
+ }
114
+ return transformOutsideSpans(wrapped, (segment) => segment.replace(ZWJ_FALLBACK_RE, `$1${ZERO_WIDTH_JOINER}$2`));
115
+ }
116
+
117
+ export function sanitizeCopilotSummonTokens(text) {
118
+ return transformNonFencedLines(String(text), sanitizeSummonLine);
119
+ }
120
+
121
+ // Drop all markdown code content (fenced blocks entirely, inline code spans
122
+ // per line) from `text`, leaving only the bare-text markdown to scan. Unlike
123
+ // transformNonFencedLines (which leaves fenced lines verbatim — correct for
124
+ // sanitizing, where code content must not be rewritten), fenced content here
125
+ // must be REMOVED rather than kept: leaving it in place would let bare text
126
+ // inside a fence still match the anti-summon scan.
127
+ function stripMarkdownCodeForScan(text) {
128
+ const lines = String(text).split(/\r?\n/);
129
+ let inFencedBlock = false;
130
+ let fencedDelimiter = "";
131
+ const kept = [];
132
+ for (const line of lines) {
133
+ const rawTrimmed = line.trim();
134
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
135
+ if (fenceMatch) {
136
+ if (!inFencedBlock) {
137
+ inFencedBlock = true;
138
+ fencedDelimiter = fenceMatch[1];
139
+ } else if (rawTrimmed.startsWith(fencedDelimiter)) {
140
+ inFencedBlock = false;
141
+ fencedDelimiter = "";
142
+ }
143
+ continue;
144
+ }
145
+ if (inFencedBlock) {
146
+ continue;
147
+ }
148
+ // Space (not empty-string) replacement: see lineArmsSummonGuard.
149
+ kept.push(line.replace(INLINE_CODE_SPAN_RE, " "));
150
+ }
151
+ return kept.join("\n");
152
+ }
153
+
154
+ // The request-copilot-review.mjs anti-summon guard scan: true when `text`
155
+ // contains a bare-text (not code-spanned/fenced) `@copilot` or `/copilot`
156
+ // occurrence. Quoting the rule inside backticks or a fenced block is exempt.
157
+ export function containsBareCopilotSummon(text) {
158
+ return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(stripMarkdownCodeForScan(text));
159
+ }
160
+
18
161
  export function normalizeTimestamp(value) {
19
162
  if (typeof value !== "string" || value.trim().length === 0) {
20
163
  return null;
@@ -234,18 +234,19 @@ function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
234
234
 
235
235
  /**
236
236
  * The raw external-write verb forms that must be blocked when originating from a subagent:
237
- * ad-hoc GitHub issue/PR creation and comments run directly via `gh` (not the sanctioned node
238
- * wrappers). Each entry is `[subcmd, verb]`.
237
+ * ad-hoc GitHub issue/PR creation, comments, and edits run directly via `gh` (not the sanctioned
238
+ * node wrappers). Each entry is `[subcmd, verb]`.
239
239
  */
240
240
  const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
241
241
  ["issue", "create"],
242
242
  ["issue", "comment"],
243
+ ["issue", "edit"],
243
244
  ["pr", "comment"],
244
245
  ]);
245
246
 
246
247
  /**
247
- * Whether `command` contains a raw `gh issue create`, `gh issue comment`, or `gh pr comment`
248
- * invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
248
+ * Whether `command` contains a raw `gh issue create`, `gh issue comment`, `gh issue edit`, or
249
+ * `gh pr comment` invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
249
250
  * blocks these when they originate from a subagent context. Node-wrapper commands
250
251
  * (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
251
252
  * @param {string} command @returns {boolean}
@@ -255,8 +256,8 @@ export function commandContainsRawExternalWrite(command) {
255
256
  }
256
257
 
257
258
  /**
258
- * Return `{ segment, explicitRepo }` for every raw external-write segment across all three verb
259
- * forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
259
+ * Return `{ segment, explicitRepo }` for every raw external-write segment across all four verb
260
+ * forms (`gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment`). PreToolUse gate use only —
260
261
  * lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
261
262
  * later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
262
263
  * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
@@ -116,6 +116,51 @@ export function provenanceConsistencyError(prov) {
116
116
  return null;
117
117
  }
118
118
 
119
+ /**
120
+ * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
121
+ * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
122
+ * the current head's delta still counts toward its base angle for both
123
+ * mandatory-angle coverage and pool-membership checks.
124
+ *
125
+ * @param {string} angle
126
+ * @returns {string}
127
+ */
128
+ function baseAngleName(angle) {
129
+ return angle.replace(/-delta-at-.+$/, "");
130
+ }
131
+
132
+ /**
133
+ * Validate a recorded fan-out angle list against a gate's configured angle
134
+ * contract: every mandatory angle must be represented, and — when a pool is
135
+ * supplied — every recorded angle must be a member of it (delta-suffixed
136
+ * angles count toward their {@link baseAngleName}). Pure; shared by the write
137
+ * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
138
+ * `--findings-json` per-angle results) and the merge-evidence read path
139
+ * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
140
+ * so all three enforce identically.
141
+ *
142
+ * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
143
+ * @param {object} [gateAngleContract]
144
+ * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
145
+ * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
146
+ * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
147
+ */
148
+ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
149
+ const recorded = Array.isArray(recordedAngles)
150
+ ? recordedAngles
151
+ .map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
152
+ .filter((a) => a.length > 0)
153
+ : [];
154
+ const recordedBases = new Set(recorded.map(baseAngleName));
155
+ const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
156
+ let foreignAngles = [];
157
+ if (Array.isArray(pool) && pool.length > 0) {
158
+ const poolSet = new Set(pool);
159
+ foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
160
+ }
161
+ return { missingMandatory, foreignAngles };
162
+ }
163
+
119
164
  /**
120
165
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
121
166
  * Mirrors the config default (gates.maxFanoutReviewers).
@@ -280,6 +280,9 @@ function deriveRequiredReads(bundle, resolverOutput) {
280
280
  * (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
281
281
  * model (deriveSpecSource coerces it to null).
282
282
  */
283
+ // Distinct from refinementArtifact.specSource (linked_issue|pr_body|plan_file,
284
+ // REFINEMENT_ARTIFACT_SPEC_SOURCE in packages/core/src/loop/pr-gate-coordination.mjs):
285
+ // same field name, different object, different value space — intentionally separate enums.
283
286
  export const CANONICAL_SPEC_SOURCE = Object.freeze({
284
287
  PHASE_DOC: "phase_doc",
285
288
  PR_BODY: "pr_body",
@@ -378,6 +378,8 @@ function extractClosingIssueNumbers(body) {
378
378
  // backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
379
379
  // ponytail: not full CommonMark span matching; an unbalanced stray backtick
380
380
  // over-strips toward fail-closed, which is the safe direction for this gate.
381
+ // Revisit with a real CommonMark span parser only if valid closing refs in
382
+ // backtick-heavy bodies start being over-stripped into false negatives.
381
383
  const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
382
384
  const seen = new Set();
383
385
  const numbers = [];
@@ -411,18 +413,34 @@ function sectionHasBody(section) {
411
413
  * Validate that a PR body carries every invariant required to serve as the
412
414
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
413
415
  * testable Acceptance criteria (>=1 checklist item), Definition of done
414
- * (>=1 checklist item), Open questions/risks, and a GitHub closing-keyword
415
- * issue reference (`Closes #N` and GitHub's other accepted forms — the
416
- * lightweight path's `Closes #N` linkage, issue #1181). Reuses the generic
417
- * markdown logic (parseMarkdownSections / AC + DoD patterns /
418
- * extractChecklistItems) so there is no parallel validator. Fails closed:
419
- * every missing invariant is reported under its distinct `missing_*` code.
420
- * Pure; no side effects.
416
+ * (>=1 checklist item), Open questions/risks, and — unless explicit
417
+ * issue-less mode is requested — a GitHub closing-keyword issue reference
418
+ * (`Closes #N` and GitHub's other accepted forms — the lightweight path's
419
+ * `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
420
+ * (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
421
+ * there is no parallel validator. Fails closed: every missing invariant is
422
+ * reported under its distinct `missing_*` code. Pure; no side effects.
421
423
  *
422
- * @param {{ body?: string, expectedIssue?: number }} input
424
+ * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
425
+ * stay unconditional, but the closing-issue linkage flips from REQUIRED to
426
+ * FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
427
+ * reference to an issue that doesn't back it. A present reference in this
428
+ * mode fails closed under `unexpected_closing_issue_reference`, distinct
429
+ * from `missing_closing_issue_reference` (tracker-backed mode, the default)
430
+ * so callers can tell "no issue expected" apart from "issue expected but
431
+ * absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
432
+ * pick exactly one mode (tracker-backed, with or without a specific
433
+ * expected issue) or issue-less — never both.
434
+ *
435
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
423
436
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
424
437
  */
425
- export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
438
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
439
+ if (issueLess && Number.isInteger(expectedIssue)) {
440
+ // Fail closed at the library boundary too (not just the CLI): the two modes
441
+ // are contradictory and silently preferring one would hide caller bugs.
442
+ throw new Error("validatePrBodySpec: issueLess and expectedIssue are mutually exclusive; pass exactly one issue-linkage mode");
443
+ }
426
444
  const bodyText = typeof body === "string" ? body : "";
427
445
  const sections = parseMarkdownSections(bodyText);
428
446
  const errors = [];
@@ -453,7 +471,14 @@ export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
453
471
  }
454
472
 
455
473
  const closesIssues = extractClosingIssueNumbers(bodyText);
456
- if (closesIssues.length === 0) {
474
+ if (issueLess) {
475
+ if (closesIssues.length > 0) {
476
+ errors.push({
477
+ code: "unexpected_closing_issue_reference",
478
+ message: `Issue-less PR body MUST NOT carry a closing reference to an issue that doesn't back it (found ${closesIssues.map((n) => `#${n}`).join(", ")}).`,
479
+ });
480
+ }
481
+ } else if (closesIssues.length === 0) {
457
482
  errors.push({
458
483
  code: "missing_closing_issue_reference",
459
484
  message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
@@ -476,6 +501,41 @@ export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
476
501
  };
477
502
  }
478
503
 
504
+ /**
505
+ * Decide what an enqueue caller should do with a refinement-artifact result,
506
+ * so an un-refined item never lands in the Next Up pickup column in the first
507
+ * place. The draft gate remains the backstop for whatever slips through.
508
+ *
509
+ * Pure decision table, no I/O:
510
+ * - target isn't the pickup column, or the artifact is present → enqueue
511
+ * as requested.
512
+ * - pickup target, artifact missing, interactive caller → block (caller
513
+ * throws; no mutation).
514
+ * - pickup target, artifact missing, headless/auto caller → divert (caller
515
+ * parks the item in the non-pickup column instead of failing the run).
516
+ *
517
+ * @param {{ artifact: ReturnType<typeof detectIssueRefinementArtifact>, targetIsPickup: boolean, auto?: boolean }} input
518
+ * @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
519
+ */
520
+ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
521
+ // `artifact.finding === null` is the explicit "has ANY refinement artifact"
522
+ // signal (AC checklist OR DoD checklist OR linked doc) — clearer than reading
523
+ // `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
524
+ if (!targetIsPickup || artifact.finding === null) {
525
+ return { action: "enqueue" };
526
+ }
527
+ const missing = [
528
+ "Acceptance criteria section",
529
+ "Definition of done section",
530
+ "linked refinement doc",
531
+ ];
532
+ const reason =
533
+ `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
534
+ "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
535
+ "(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
536
+ return { action: auto ? "divert" : "block", reason, missing };
537
+ }
538
+
479
539
  /**
480
540
  * Map a draft-gate refinement check to the result surface consumed by
481
541
  * `evaluatePrGateCoordination`. The mapping keeps the contract
@@ -43,6 +43,28 @@ export const PLAN_FILE_PROMOTE_ACTION = Object.freeze({
43
43
  */
44
44
  export const PLAN_FILE_PR_FRONT_MATTER_KEY = "prNumber";
45
45
 
46
+ /**
47
+ * Build the plan-file promotion marker sentence: the single source of truth
48
+ * for the PR-body text that names the committed plan doc as the spec-of-record.
49
+ * `buildPromotionPrBody` emits it and `PLAN_FILE_PROMOTION_DOC_PATH_PATTERN`
50
+ * (below) parses it back out — keep the two in lockstep.
51
+ *
52
+ * @param {string} docPath repo-relative path of the committed plan doc
53
+ * @returns {string}
54
+ */
55
+ export function buildPlanFilePromotionMarker(docPath) {
56
+ return `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`;
57
+ }
58
+
59
+ /**
60
+ * Matches the marker sentence `buildPlanFilePromotionMarker` produces and
61
+ * captures the plan doc path. The captured path is bounded to a single line
62
+ * and a `.md` suffix so a multi-line/unbounded body cannot smuggle an
63
+ * oversized or cross-line "path".
64
+ */
65
+ export const PLAN_FILE_PROMOTION_DOC_PATH_PATTERN =
66
+ /Spec-of-record: the committed plan doc `([^`\n]{1,200}?\.md)`/u;
67
+
46
68
  /**
47
69
  * Minimal additive front-matter support for plan files (an escalated extension
48
70
  * to P1's format): a leading `---\n...\n---\n` block of simple `key: value`
@@ -219,7 +241,7 @@ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definiti
219
241
  const safeAc = neutralizeIssueCloseKeywords(ac);
220
242
  const safeDod = neutralizeIssueCloseKeywords(dod);
221
243
  return [
222
- `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`,
244
+ buildPlanFilePromotionMarker(docPath),
223
245
  "This PR was opened by PR-FIRST promotion; no tracker issue exists.",
224
246
  "",
225
247
  "## Acceptance criteria",