dev-loops 0.7.1 → 0.7.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.
Files changed (86) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { isCopilotLogin, parseReviewThreads } from "../_core-helpers.mjs";
2
+ import { isCopilotLogin, parseReviewThreads, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
3
3
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
4
4
  export const MIN_DISMISSAL_REASON_LENGTH = 30;
5
5
  export function hasCommitShaReference(text) {
@@ -175,12 +175,15 @@ export async function replyAndMaybeResolve(
175
175
  } else {
176
176
  await validateReplyTarget({ repo, pr, commentId, threadId }, { env, ghCommand });
177
177
  }
178
+ // Neutralize any bare @copilot/`/copilot`* tokens the reply text quotes (e.g.
179
+ // a dismissal reason citing the anti-summon rule) so posting this reply can
180
+ // never arm request-copilot-review.mjs's anti-summon guard.
178
181
  const reply = parseReplyPayload(await postReply(
179
182
  {
180
183
  repo,
181
184
  pr,
182
185
  commentId,
183
- body,
186
+ body: sanitizeCopilotSummonTokens(body),
184
187
  },
185
188
  { env, ghCommand },
186
189
  ));
@@ -2,6 +2,10 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { runChild as _runChild } from "../_cli-primitives.mjs";
6
+ import { resolveSettings, applyDevloopsBoard } from "../projects/_resolve-project.mjs";
7
+ import { main as addQueueItemMain } from "../projects/add-queue-item.mjs";
8
+ import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
5
9
  const USAGE = `Usage: create-pr.mjs [gh pr create args...]
6
10
  Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
7
11
  tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
@@ -11,6 +15,16 @@ Behavior:
11
15
  - honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
12
16
  - rejects \`--ready\` before invoking \`gh\`
13
17
  - detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
18
+ - \`--lightweight\` (consumed here in every form — bare or \`=true/1/false/0\`, last
19
+ occurrence wins — never forwarded to \`gh\`): when an explicit \`--body\`/
20
+ \`--body-file\` also carries no \`Closes #N\`/\`Fixes #N\`, the new PR is issue-less
21
+ lightweight and is auto-enqueued as a board PR item in the configured In Progress column
22
+ (reuses \`queue.projectNumber\` / \`queue.boardTitle\` from \`.devloops\`, same as the queue
23
+ scripts). Requires an explicit \`--repo owner/name\` (space or = form). A trailing stdout
24
+ line reports the outcome: \`{"board":{"enqueued":bool,...}}\`. No board configured, no
25
+ \`--repo\`, no explicit body source, or an enqueue error is a non-fatal no-op (noted in
26
+ that line; exit code unaffected). Omitting \`--lightweight\`, or a body that already
27
+ carries a closing keyword (tracker-backed), never calls the board.
14
28
  - forwards every other argument to \`gh pr create\` unchanged
15
29
  - preserves the underlying \`gh pr create\` stdout, stderr, and exit code
16
30
  Examples:
@@ -26,8 +40,17 @@ Exit codes:
26
40
  N same non-zero exit code returned by \`gh pr create\``.trim();
27
41
  const parseError = buildParseError(USAGE);
28
42
  const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
43
+ // Bare and inline-boolean forms, mirroring DRAFT_FLAG_PATTERN below: every
44
+ // matching token is consumed (never forwarded to gh, which rejects unknown
45
+ // flags), and the LAST occurrence decides — bare or =true/=1 enables,
46
+ // anything else (=false, =0, ...) disables.
47
+ const LIGHTWEIGHT_FLAG_PATTERN = /^--lightweight(?:=(.*))?$/iu;
48
+ // Both `--repo owner/name` and `--repo=owner/name` — gh accepts either form.
49
+ const REPO_FLAG_PATTERN = /^--repo(?:$|=)/u;
50
+ const PR_URL_NUMBER_PATTERN = /\/pull\/(\d+)(?:\D|$)/u;
29
51
  const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
30
- const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
52
+ // Shared inline-boolean truthiness for --draft= and --lightweight= values.
53
+ const TRUE_FLAG_VALUE_PATTERN = /^(?:true|1)$/iu;
31
54
  // Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
32
55
  // short flag that `gh pr create` documents, so an explicit assignee in either
33
56
  // form suppresses the `--assignee @me` default (otherwise a caller passing
@@ -56,8 +79,7 @@ async function resolveBody(args) {
56
79
  }
57
80
  return null; // unreadable → warn
58
81
  }
59
- async function warnMissingClosingKeyword(args) {
60
- const body = await resolveBody(args);
82
+ function warnMissingClosingKeyword(body) {
61
83
  if (body === null) return; // no --body or --body-file, skip
62
84
  if (!detectClosingKeyword(body)) {
63
85
  process.stderr.write(
@@ -66,6 +88,48 @@ async function warnMissingClosingKeyword(args) {
66
88
  );
67
89
  }
68
90
  }
91
+ // A plain string value for a single-value flag, in both the space form
92
+ // (`--repo owner/name`) and the inline form (`--repo=owner/name`); unlike
93
+ // resolveBody, never reads a file. Returns null when the flag is absent.
94
+ function getFlagValue(args, flagPattern) {
95
+ const idx = args.findIndex((token) => flagPattern.test(token));
96
+ if (idx === -1) return null;
97
+ const eq = args[idx].indexOf("=");
98
+ if (eq !== -1) return args[idx].slice(eq + 1);
99
+ return idx + 1 < args.length ? args[idx + 1] : null;
100
+ }
101
+ function parsePrNumberFromOutput(stdout) {
102
+ const match = PR_URL_NUMBER_PATTERN.exec(stdout ?? "");
103
+ return match ? Number(match[1]) : null;
104
+ }
105
+ // Auto-enqueue an issue-less lightweight PR as a board PR item in the configured
106
+ // In Progress column. Reuses the same .devloops queue.projectNumber /
107
+ // queue.boardTitle resolution and add-queue-item's idempotent add — never
108
+ // reimplements the board API calls. Never throws: an unconfigured board, a
109
+ // missing --repo, an unparsed PR number, or an enqueue failure are all
110
+ // non-fatal no-ops reported in the returned shape.
111
+ export async function enqueueIssuelessLightweightPr({ repo, prNumber, cwd, env, runChild }) {
112
+ if (!repo) return { enqueued: false, reason: "repo-not-specified" };
113
+ if (!Number.isInteger(prNumber) || prNumber < 1) return { enqueued: false, reason: "pr-number-not-parsed" };
114
+ const settings = resolveSettings(cwd);
115
+ if (!settings?.project && !settings?.title) {
116
+ return { enqueued: false, reason: "no-board-configured" };
117
+ }
118
+ const { columnNames, error: columnError } = loadStateColumnMap(cwd);
119
+ if (columnError) {
120
+ return { enqueued: false, reason: `config-error: ${columnError}` };
121
+ }
122
+ const args = { repo, item: prNumber };
123
+ applyDevloopsBoard(args, cwd);
124
+ args.column = columnNames[LOGICAL_COLUMN.IN_PROGRESS];
125
+ try {
126
+ const result = await addQueueItemMain(args, { env, runChild, cwd });
127
+ const { itemId, prNumber: itemPrNumber, status, alreadyPresent } = result.item;
128
+ return { enqueued: true, itemId, prNumber: itemPrNumber, status, alreadyPresent };
129
+ } catch (err) {
130
+ return { enqueued: false, reason: `enqueue-error: ${err instanceof Error ? err.message : String(err)}` };
131
+ }
132
+ }
69
133
  export function buildCreatePrArgs(argv) {
70
134
  const args = [...argv];
71
135
  if (args.includes("--help") || args.includes("-h")) {
@@ -79,7 +143,7 @@ export function buildCreatePrArgs(argv) {
79
143
  }
80
144
  const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
81
145
  const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
82
- const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
146
+ const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
83
147
  const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
84
148
  return {
85
149
  help: false,
@@ -92,26 +156,71 @@ export function buildCreatePrArgs(argv) {
92
156
  ],
93
157
  };
94
158
  }
95
- export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
159
+ // captureStdout tees gh's stdout to a buffer (still writing it straight through to
160
+ // process.stdout, unbuffered) so the PR URL can be parsed once gh exits, without
161
+ // changing what a caller/terminal sees. Only enabled for the issue-less lightweight
162
+ // path; every other caller keeps the plain "inherit" byte-identical behavior.
163
+ export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}, { captureStdout = false } = {}) {
96
164
  return new Promise((resolve, reject) => {
165
+ // `gh pr create` never reads stdin (body comes from --body/--body-file), so
166
+ // the captured path ignores it rather than inheriting: inheriting a
167
+ // never-closing stdin (e.g. an in-process test runner) would hang any child
168
+ // that waits for stdin to end.
97
169
  const child = spawn(ghCommand, ghArgs, {
98
170
  env,
99
- stdio: "inherit",
171
+ stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit",
100
172
  });
173
+ let stdout = "";
174
+ if (captureStdout) {
175
+ child.stdout.on("data", (chunk) => {
176
+ stdout += chunk;
177
+ process.stdout.write(chunk);
178
+ });
179
+ }
101
180
  child.on("error", reject);
102
181
  child.on("close", (code) => {
103
- resolve(typeof code === "number" ? code : 1);
182
+ resolve({ code: typeof code === "number" ? code : 1, stdout });
104
183
  });
105
184
  });
106
185
  }
107
186
  export async function main(argv = process.argv.slice(2), runtime = {}) {
108
- const { help, ghArgs } = buildCreatePrArgs(argv);
187
+ // Last occurrence wins, same as the --draft handling in buildCreatePrArgs.
188
+ const lastLightweightToken = argv.filter((token) => LIGHTWEIGHT_FLAG_PATTERN.test(token)).at(-1) ?? null;
189
+ const lightweight = lastLightweightToken === "--lightweight" ||
190
+ (typeof lastLightweightToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastLightweightToken.slice("--lightweight=".length)));
191
+ const forwardedArgv = argv.filter((token) => !LIGHTWEIGHT_FLAG_PATTERN.test(token));
192
+ const { help, ghArgs } = buildCreatePrArgs(forwardedArgv);
109
193
  if (help) {
110
194
  process.stdout.write(`${USAGE}\n`);
111
195
  return 0;
112
196
  }
113
- await warnMissingClosingKeyword(argv);
114
- return spawnCreatePr(ghArgs, runtime);
197
+ const body = await resolveBody(forwardedArgv);
198
+ warnMissingClosingKeyword(body);
199
+ // Issue-less lightweight: caller signals lightweight AND an explicit body
200
+ // source (--body/--body-file) carries no closing keyword. A tracker-backed
201
+ // lightweight PR (closing keyword present) never reaches the board — its
202
+ // issue already owns the board entry. A null body (no explicit source) is
203
+ // NOT classified issue-less: the body may come from elsewhere (editor,
204
+ // template) and could carry a closing keyword this wrapper never saw, so it
205
+ // fails toward not enqueuing and reports body-not-provided instead.
206
+ const issueLess = lightweight && body !== null && !detectClosingKeyword(body);
207
+ const { code, stdout } = await spawnCreatePr(ghArgs, runtime, { captureStdout: issueLess });
208
+ if (lightweight && code === 0 && (issueLess || body === null)) {
209
+ const board = issueLess
210
+ ? await enqueueIssuelessLightweightPr({
211
+ repo: getFlagValue(forwardedArgv, REPO_FLAG_PATTERN),
212
+ prNumber: parsePrNumberFromOutput(stdout),
213
+ cwd: runtime.cwd ?? process.cwd(),
214
+ env: runtime.env ?? process.env,
215
+ runChild: runtime.runChild ?? _runChild,
216
+ })
217
+ : { enqueued: false, reason: "body-not-provided" };
218
+ if (!board.enqueued) {
219
+ process.stderr.write(`[create-pr] Board note: PR not enqueued (${board.reason}).\n`);
220
+ }
221
+ process.stdout.write(`${JSON.stringify({ board })}\n`);
222
+ }
223
+ return code;
115
224
  }
116
225
  if (isDirectCliRun(import.meta.url)) {
117
226
  try {
@@ -14,8 +14,8 @@ import path from "node:path";
14
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
- import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateConfig, resolveLightMode, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
18
- import { FANOUT_UNAVAILABLE_MESSAGE, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
17
+ import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateAngleContract, resolveGateConfig, resolveLightMode, resolveRejectForeignAngles, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
18
+ import { FANOUT_UNAVAILABLE_MESSAGE, checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
19
19
  import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
20
20
  import { buildLogPath } from "./write-gate-findings-log.mjs";
21
21
  import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
@@ -240,6 +240,7 @@ function normalizeGateMarkerSummary(summary) {
240
240
  }
241
241
  export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
242
242
  const failures = [];
243
+ const warnings = [];
243
244
  if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
244
245
  failures.push("missing visible clean draft_gate comment");
245
246
  }
@@ -310,6 +311,42 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
310
311
  );
311
312
  }
312
313
  }
314
+ // Angle-coverage enforcement: independent of requireFanoutProvenance.
315
+ // When the gate configures mandatory angles, a fanout_fanin ledger MUST
316
+ // record internally-consistent provenance — otherwise a shadow ledger
317
+ // that simply omits provenance would bypass mandatory-angle coverage.
318
+ // Recorded provenance is then re-validated: perAngle must cover every
319
+ // mandatory angle and (default) stay within the configured pool. Gates
320
+ // without mandatory angles keep today's behavior (absent provenance adds
321
+ // no failure; that stricter gap is requireFanoutProvenance's opt-in).
322
+ if (gate.executionMode === "fanout_fanin") {
323
+ const mandatoryAngles = gate.mandatoryAngles ?? [];
324
+ const provValid = gate.provenance != null && provenanceConsistencyError(gate.provenance) === null;
325
+ if (mandatoryAngles.length > 0 && !provValid) {
326
+ failures.push(
327
+ `${gate.name}: mandatory angle coverage is configured (${mandatoryAngles.join(", ")}) but the findings-log ledger records no valid fan-out provenance to verify it against; write the ledger with --provenance covering the mandatory angles; ${FANOUT_UNAVAILABLE_MESSAGE}`,
328
+ );
329
+ } else if (provValid) {
330
+ const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(gate.provenance.perAngle, {
331
+ mandatoryAngles,
332
+ pool: gate.anglePool ?? null,
333
+ });
334
+ if (missingMandatory.length > 0) {
335
+ failures.push(
336
+ `${gate.name}: fan-out provenance is missing mandatory angle(s): ${missingMandatory.join(", ")}; ${FANOUT_UNAVAILABLE_MESSAGE}`,
337
+ );
338
+ }
339
+ if (foreignAngles.length > 0) {
340
+ const message = `${gate.name}: fan-out provenance names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
341
+ if (fanoutEnforcement.rejectForeignAngles ?? true) {
342
+ failures.push(`${message}; ${FANOUT_UNAVAILABLE_MESSAGE}`);
343
+ } else {
344
+ // rejectForeignAngles: false is WARNING mode, not silence.
345
+ warnings.push(`${message} (gates.rejectForeignAngles is false; recorded as a warning)`);
346
+ }
347
+ }
348
+ }
349
+ }
313
350
  }
314
351
  }
315
352
  if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
@@ -327,6 +364,8 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
327
364
  return {
328
365
  ok: failures.length === 0,
329
366
  failures,
367
+ // Additive: only present when non-empty, preserving the existing {ok, failures} shape.
368
+ ...(warnings.length > 0 ? { warnings } : {}),
330
369
  };
331
370
  }
332
371
  async function ledgerExists(fullPath) {
@@ -354,14 +393,27 @@ async function ledgerExistsInAny(checkouts, ledgerPath) {
354
393
  * Read the recorded fan-out `provenance` object from a ledger across the
355
394
  * enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
356
395
  * semantics: prefers the FIRST checkout whose ledger provenance actually
357
- * SATISFIES enforcement (internally consistent AND distinctReviewers >= floor),
358
- * so a below-floor or provenance-less ledger in an earlier-enumerated checkout
359
- * cannot SHADOW a valid one in the PR worktree (which would falsely fail closed).
360
- * Falls back to the first non-null provenance (for a useful diagnostic message)
361
- * only when NO checkout satisfies, and null only when none is present. Only
362
- * called when requireFanoutProvenance is enabled so the default path pays no I/O.
396
+ * SATISFIES the FULL active enforcement internally consistent, meeting the
397
+ * distinctReviewers floor when requireFanoutProvenance is on, AND passing the
398
+ * gate's angle contract (mandatory-angle coverage; pool membership when
399
+ * foreign angles are rejected) so a stale checkout's below-floor,
400
+ * provenance-less, or angle-contract-failing ledger cannot SHADOW a valid one
401
+ * in the PR worktree (which would falsely fail closed). Falls back to the
402
+ * first non-null provenance (for a useful diagnostic message) only when NO
403
+ * checkout satisfies, and null only when none is present. Called whenever
404
+ * requireFanoutProvenance is enabled OR the gate's verdict is fanout_fanin —
405
+ * inline verdicts never trigger this read.
363
406
  */
364
- async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
407
+ async function readLedgerProvenanceInAny(checkouts, ledgerPath, criteria = {}) {
408
+ const { requireProvenance = false, mandatoryAngles = [], anglePool = null, rejectForeignAngles = true } = criteria;
409
+ const satisfies = (prov) => {
410
+ if (provenanceConsistencyError(prov) !== null) return false;
411
+ if (requireProvenance && prov.distinctReviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) return false;
412
+ const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(prov.perAngle, { mandatoryAngles, pool: anglePool });
413
+ if (missingMandatory.length > 0) return false;
414
+ if (foreignAngles.length > 0 && rejectForeignAngles) return false;
415
+ return true;
416
+ };
365
417
  let firstNonNull = null;
366
418
  for (const root of checkouts) {
367
419
  const full = path.resolve(root, ledgerPath);
@@ -369,8 +421,8 @@ async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
369
421
  const parsed = JSON.parse(await readFile(full, "utf8"));
370
422
  const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
371
423
  if (prov == null) continue; // ledger present but no provenance — keep scanning.
372
- if (provenanceConsistencyError(prov) === null && prov.distinctReviewers >= FANOUT_PROVENANCE_MIN_REVIEWERS) {
373
- return prov; // satisfying ledger — prefer it over any earlier below-floor one.
424
+ if (satisfies(prov)) {
425
+ return prov; // satisfying ledger — prefer it over any earlier failing one.
374
426
  }
375
427
  if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
376
428
  } catch {
@@ -413,17 +465,33 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
413
465
  // Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
414
466
  // only takes effect while evidence enforcement (above) is active.
415
467
  const requireProvenance = resolveRequireFanoutProvenance(config);
468
+ // Angle-coverage enforcement (mandatory angles + pool membership) is layered
469
+ // independently of requireProvenance: it re-validates whatever provenance a
470
+ // fanout_fanin ledger actually recorded, regardless of that opt-in flag.
471
+ const rejectForeignAngles = resolveRejectForeignAngles(config);
416
472
  // Light-mode facts (#1174): the threshold that a re-derived merge-base scope
417
473
  // must fall under for an inline verdict to be accepted. null when lightMode is
418
474
  // disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
419
475
  // false), preserving today's rejection.
420
476
  const lightThreshold = resolveLightMode(config);
421
477
  const lightMode = lightThreshold != null;
422
- const draftRequired = resolveGateConfig(config, "draft").required;
423
- const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
478
+ const draftGateConfig = resolveGateConfig(config, "draft");
479
+ const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
480
+ // Shared angle-contract resolver (exclude-filtered mandatory angles +
481
+ // additive-aware pool) — the same contract the write paths enforce. The
482
+ // field names here (`mandatoryAngles`/`anglePool`) are exactly what
483
+ // buildPreMergeGateCheck reads off each gate entry.
484
+ const buildAngleFields = (gateKey) => {
485
+ const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
486
+ return { mandatoryAngles, anglePool: pool };
487
+ };
488
+ const GATE_ANGLE_CONFIG = {
489
+ draft_gate: buildAngleFields("draft"),
490
+ pre_approval_gate: buildAngleFields("preApproval"),
491
+ };
424
492
  const gateSpecs = [
425
- { name: "draft_gate", marker: draftGateMarker, required: draftRequired },
426
- { name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
493
+ { name: "draft_gate", marker: draftGateMarker, required: draftGateConfig.required },
494
+ { name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalGateConfig.required },
427
495
  ].filter((spec) => spec.required && spec.marker.visible);
428
496
  const gates = [];
429
497
  const checkouts = resolveLedgerCheckouts(cwd);
@@ -444,6 +512,13 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
444
512
  const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
445
513
  scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
446
514
  }
515
+ // Read ledger provenance for ANY fanout_fanin gate (not just when
516
+ // requireProvenance is on): angle-coverage enforcement re-validates
517
+ // whatever provenance is recorded independently of that opt-in flag.
518
+ // The selection criteria mirror the full active enforcement so a stale
519
+ // checkout's contract-failing ledger cannot shadow a passing one.
520
+ const readProvenance = requireProvenance || spec.marker.executionMode === "fanout_fanin";
521
+ const angleFields = GATE_ANGLE_CONFIG[spec.name];
447
522
  gates.push({
448
523
  name: spec.name,
449
524
  executionMode: spec.marker.executionMode ?? null,
@@ -451,10 +526,13 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
451
526
  scopeUnderThreshold,
452
527
  ledgerPath,
453
528
  ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
454
- provenance: requireProvenance ? await readLedgerProvenanceInAny(checkouts, ledgerPath) : null,
529
+ provenance: readProvenance
530
+ ? await readLedgerProvenanceInAny(checkouts, ledgerPath, { requireProvenance, rejectForeignAngles, ...angleFields })
531
+ : null,
532
+ ...angleFields,
455
533
  });
456
534
  }
457
- return { required: true, requireProvenance, lightMode, hasFullLabel, gates };
535
+ return { required: true, requireProvenance, rejectForeignAngles, lightMode, hasFullLabel, gates };
458
536
  }
459
537
  export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
460
538
  const runnerOwnership = await ensureAsyncRunnerOwnership({
@@ -614,6 +692,13 @@ async function main() {
614
692
  process.exitCode = 1;
615
693
  return;
616
694
  }
695
+ // Warnings (e.g. foreign angles under gates.rejectForeignAngles: false) do
696
+ // not fail the check but must not pass silently. Suppressed under --silent.
697
+ if (Array.isArray(preMergeGateCheck.warnings) && !options.silent) {
698
+ for (const warning of preMergeGateCheck.warnings) {
699
+ process.stderr.write(`WARNING: ${warning}\n`);
700
+ }
701
+ }
617
702
  process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
618
703
  } catch (error) {
619
704
  if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {