dev-loops 0.6.0 → 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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -9,15 +9,19 @@ import {
9
9
  summarizeGateReviewCommentMarkers,
10
10
  summarizeGateReviewComments,
11
11
  } from "../_core-helpers.mjs";
12
- import { access } from "node:fs/promises";
12
+ import { access, readFile } from "node:fs/promises";
13
13
  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 { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
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
+ import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
18
20
  import { buildLogPath } from "./write-gate-findings-log.mjs";
19
21
  import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
20
22
  import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
23
+ import { resolveLedgerCheckouts, resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
24
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
21
25
  const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
22
26
  Fetch the live PR head SHA and visible PR issue comments, then summarize the
23
27
  latest valid draft-gate and pre-approval checkpoint verdict comments. Always fail
@@ -84,9 +88,11 @@ Output (stdout, JSON; always includes preMergeGateCheck):
84
88
  Error output (stderr, JSON):
85
89
  { "ok": false, "error": "...", "usage": "..." }
86
90
  { "ok": false, "error": "..." }
91
+ ${JQ_OUTPUT_USAGE}
87
92
  Exit codes:
88
93
  0 Success (gate evidence is valid)
89
- 1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.`.trim();
94
+ 1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.
95
+ 2 Invalid --jq filter.`.trim();
90
96
  const parseError = buildParseError(USAGE);
91
97
  export function parseDetectCheckpointEvidenceCliArgs(argv) {
92
98
  const { tokens } = parseArgs({
@@ -96,6 +102,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
96
102
  repo: { type: "string" },
97
103
  pr: { type: "string" },
98
104
  "require-before-merge": { type: "boolean" },
105
+ ...JQ_OUTPUT_PARSE_OPTIONS,
99
106
  },
100
107
  allowPositionals: true,
101
108
  strict: false,
@@ -125,6 +132,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
125
132
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
126
133
  continue;
127
134
  }
135
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
128
136
  if (token.name === "require-before-merge") {
129
137
  throw parseError(`--require-before-merge has been removed: gate evidence enforcement is now always-on by default. Omit the flag.`);
130
138
  }
@@ -232,6 +240,7 @@ function normalizeGateMarkerSummary(summary) {
232
240
  }
233
241
  export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
234
242
  const failures = [];
243
+ const warnings = [];
235
244
  if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
236
245
  failures.push("missing visible clean draft_gate comment");
237
246
  }
@@ -249,7 +258,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
249
258
  // { required: false, gates: [] } so the `.required` guard skips this block.
250
259
  if (fanoutEnforcement && fanoutEnforcement.required) {
251
260
  for (const gate of fanoutEnforcement.gates) {
252
- if (gate.executionMode !== "fanout_fanin") {
261
+ // Light-mode acceptance (#1174): a genuinely under-threshold micro-PR
262
+ // collapses the gate fan-out to a single inline check (#1043). Accept that
263
+ // inline verdict ONLY when ALL hold, fail CLOSED otherwise:
264
+ // - lightMode is enabled in config, AND
265
+ // - the reviewed head's merge-base scope was RE-DERIVED under threshold
266
+ // (scopeUnderThreshold; false whenever scope could not be derived), AND
267
+ // - the PR carries no gate:full label (which always forces fan-out), AND
268
+ // - the verdict records a non-empty inline reason.
269
+ // Any non-light inline verdict (over threshold / label / lightMode off /
270
+ // scope underivable) falls through to the byte-identical rejection below.
271
+ const lightAccepted =
272
+ gate.executionMode === "inline_single_agent"
273
+ && fanoutEnforcement.lightMode === true
274
+ && fanoutEnforcement.hasFullLabel !== true
275
+ && gate.scopeUnderThreshold === true
276
+ && typeof gate.inlineReason === "string"
277
+ && gate.inlineReason.trim().length > 0;
278
+ if (gate.executionMode !== "fanout_fanin" && !lightAccepted) {
253
279
  failures.push(
254
280
  `${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
255
281
  );
@@ -259,6 +285,67 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
259
285
  failures.push(
260
286
  `${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
261
287
  );
288
+ continue;
289
+ }
290
+ // Opt-in provenance enforcement (gates.requireFanoutProvenance), layered on
291
+ // top of fan-out evidence. When off (default) requireProvenance is falsy so
292
+ // NO new failure is added — behavior is byte-identical to today. When on, a
293
+ // fanout_fanin ledger must record INTERNALLY-CONSISTENT provenance (checked
294
+ // the same way the write path validates it — a hand-edited or shadow ledger
295
+ // is re-validated here, not trusted) with distinctReviewers >= the floor.
296
+ // Provenance is enforced ONLY for fanout_fanin verdicts (#1174): a
297
+ // light-accepted inline verdict is already scope-bounded and has no
298
+ // multi-reviewer provenance to record, so requiring it would make the
299
+ // light path unmergeable — the inverse of this issue's fix.
300
+ if (fanoutEnforcement.requireProvenance && gate.executionMode === "fanout_fanin") {
301
+ const prov = gate.provenance;
302
+ const consistencyErr = provenanceConsistencyError(prov);
303
+ const reviewers = prov && Number.isInteger(prov.distinctReviewers) ? prov.distinctReviewers : null;
304
+ if (consistencyErr) {
305
+ failures.push(
306
+ `${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (${consistencyErr}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
307
+ );
308
+ } else if (reviewers === null || reviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) {
309
+ failures.push(
310
+ `${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (need provenance.distinctReviewers >= ${FANOUT_PROVENANCE_MIN_REVIEWERS}, got ${reviewers === null ? "none" : reviewers}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
311
+ );
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
+ }
262
349
  }
263
350
  }
264
351
  }
@@ -277,6 +364,8 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
277
364
  return {
278
365
  ok: failures.length === 0,
279
366
  failures,
367
+ // Additive: only present when non-empty, preserving the existing {ok, failures} shape.
368
+ ...(warnings.length > 0 ? { warnings } : {}),
280
369
  };
281
370
  }
282
371
  async function ledgerExists(fullPath) {
@@ -287,43 +376,163 @@ async function ledgerExists(fullPath) {
287
376
  return false;
288
377
  }
289
378
  }
379
+ /**
380
+ * True if the ledger (relative path) exists under ANY enumerated checkout
381
+ * (main + every worktree). Fixes #1050: a ledger written in the PR worktree is
382
+ * found even when the check runs from a different checkout's git-toplevel.
383
+ */
384
+ async function ledgerExistsInAny(checkouts, ledgerPath) {
385
+ for (const root of checkouts) {
386
+ if (await ledgerExists(path.resolve(root, ledgerPath))) {
387
+ return true;
388
+ }
389
+ }
390
+ return false;
391
+ }
392
+ /**
393
+ * Read the recorded fan-out `provenance` object from a ledger across the
394
+ * enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
395
+ * semantics: prefers the FIRST checkout whose ledger provenance actually
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.
406
+ */
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
+ };
417
+ let firstNonNull = null;
418
+ for (const root of checkouts) {
419
+ const full = path.resolve(root, ledgerPath);
420
+ try {
421
+ const parsed = JSON.parse(await readFile(full, "utf8"));
422
+ const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
423
+ if (prov == null) continue; // ledger present but no provenance — keep scanning.
424
+ if (satisfies(prov)) {
425
+ return prov; // satisfying ledger — prefer it over any earlier failing one.
426
+ }
427
+ if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
428
+ } catch {
429
+ // Missing/unreadable/malformed ledger in this checkout — try the next.
430
+ }
431
+ }
432
+ return firstNonNull;
433
+ }
290
434
  /**
291
435
  * Build the fan-out evidence enforcement descriptor.
292
436
  *
293
437
  * Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
294
438
  * Returns { required: false } when enforcement is disabled OR when config is
295
439
  * unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
296
- * fail open and never enable enforcement. When enabled, records per-required-gate
297
- * executionMode and whether the deterministic findings-log ledger exists for the
298
- * reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
299
- * missing ledgers.
440
+ * fail open and never enable enforcement. When enabled, returns
441
+ * { required: true, requireProvenance, lightMode, hasFullLabel, gates } where
442
+ * each per-required-gate entry records executionMode, inlineReason,
443
+ * scopeUnderThreshold, and whether the deterministic findings-log ledger exists
444
+ * for the reviewed head SHA, so the pre-merge check can fail closed on inline
445
+ * verdicts or missing ledgers.
446
+ *
447
+ * Light mode (#1174): when gates.lightMode is configured, `hasFullLabel`
448
+ * (gate:full PR label) and `baseRef` feed a fail-closed merge-base scope
449
+ * re-derivation for inline verdicts. A gate's scopeUnderThreshold is true only
450
+ * when light mode is on, the PR has no gate:full label, a base ref is known,
451
+ * and the reviewed head's merge-base diff is genuinely under threshold — which
452
+ * lets the pre-merge check accept an inline single-agent verdict for a
453
+ * small-scope PR instead of always rejecting inline evidence.
300
454
  */
301
- async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
455
+ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd, hasFullLabel = false, baseRef = null }) {
302
456
  // Fail open when config could not be loaded/validated. `== null` covers both
303
457
  // null and undefined; the loader only ever yields null on failure, but the
304
458
  // loose check defensively treats an absent config as unavailable.
305
459
  if (config == null || !resolveRequireFanoutEvidence(config)) {
460
+ // Disabled/unavailable return is intentionally byte-identical to before
461
+ // (no requireProvenance key): buildPreMergeGateCheck only reads it inside
462
+ // the `required` block, so this preserves the exact existing shape.
306
463
  return { required: false, gates: [] };
307
464
  }
308
- const draftRequired = resolveGateConfig(config, "draft").required;
309
- const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
465
+ // Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
466
+ // only takes effect while evidence enforcement (above) is active.
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);
472
+ // Light-mode facts (#1174): the threshold that a re-derived merge-base scope
473
+ // must fall under for an inline verdict to be accepted. null when lightMode is
474
+ // disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
475
+ // false), preserving today's rejection.
476
+ const lightThreshold = resolveLightMode(config);
477
+ const lightMode = lightThreshold != null;
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
+ };
310
492
  const gateSpecs = [
311
- { name: "draft_gate", marker: draftGateMarker, required: draftRequired },
312
- { 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 },
313
495
  ].filter((spec) => spec.required && spec.marker.visible);
314
496
  const gates = [];
497
+ const checkouts = resolveLedgerCheckouts(cwd);
498
+ // checkouts[0] is always resolveRepoRoot(cwd) (resolveLedgerCheckouts adds it
499
+ // first, unconditionally, and never throws — it falls back to cwd on git
500
+ // failure) — reuse it instead of a second `git rev-parse --show-toplevel`.
501
+ const repoRoot = checkouts[0];
315
502
  for (const spec of gateSpecs) {
316
503
  const headSha = spec.marker.headSha ?? currentHeadSha;
317
504
  const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
318
- const fullPath = path.resolve(cwd, ledgerPath);
505
+ // Re-derive scope FAIL-CLOSED for inline verdicts only (the fan-out default
506
+ // path pays no git I/O). scopeUnderThreshold is true ONLY when lightMode is
507
+ // on, the PR has no gate:full label, a base ref is known, and the merge-base
508
+ // diff for the reviewed head is genuinely under threshold. Any git/scope
509
+ // failure leaves it false, so the inline verdict is rejected exactly as today.
510
+ let scopeUnderThreshold = false;
511
+ if (lightMode && !hasFullLabel && baseRef && spec.marker.executionMode === "inline_single_agent") {
512
+ const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
513
+ scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
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];
319
522
  gates.push({
320
523
  name: spec.name,
321
524
  executionMode: spec.marker.executionMode ?? null,
525
+ inlineReason: spec.marker.inlineReason ?? null,
526
+ scopeUnderThreshold,
322
527
  ledgerPath,
323
- ledgerExists: await ledgerExists(fullPath),
528
+ ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
529
+ provenance: readProvenance
530
+ ? await readLedgerProvenanceInAny(checkouts, ledgerPath, { requireProvenance, rejectForeignAngles, ...angleFields })
531
+ : null,
532
+ ...angleFields,
324
533
  });
325
534
  }
326
- return { required: true, gates };
535
+ return { required: true, requireProvenance, rejectForeignAngles, lightMode, hasFullLabel, gates };
327
536
  }
328
537
  export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
329
538
  const runnerOwnership = await ensureAsyncRunnerOwnership({
@@ -378,8 +587,31 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
378
587
  // treat it as config-unavailable and leave fan-out enforcement disabled
379
588
  // (preserves default behavior). Other gate checks remain unaffected.
380
589
  let config = null;
381
- const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
590
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(cwd) });
382
591
  config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
592
+ // Light-mode pre-merge facts (#1174): the base commit for the merge-base scope
593
+ // re-derivation and whether the PR forces full fan-out via the gate:full label.
594
+ // Fetched LAZILY — only when fan-out enforcement is active AND lightMode is on
595
+ // AND a gate actually recorded an inline verdict — so the common fan-out path
596
+ // (and every existing caller/test) makes NO extra gh call and stays unchanged.
597
+ let baseRef = null;
598
+ let hasFullLabel = false;
599
+ const anyInlineVerdict = [draftGateMarker, preApprovalGateMarker].some(
600
+ (marker) => marker.visible && marker.executionMode === "inline_single_agent",
601
+ );
602
+ if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
603
+ try {
604
+ const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
605
+ baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
606
+ ? lightFacts.baseRefOid.trim()
607
+ : null;
608
+ hasFullLabel = Array.isArray(lightFacts?.labels)
609
+ && lightFacts.labels.some((label) => (typeof label === "string" ? label : label?.name) === GATE_FULL_LABEL);
610
+ } catch {
611
+ // Fail CLOSED: without the label/base facts we cannot safely accept an
612
+ // inline verdict, so leave baseRef null (scope underivable → rejected).
613
+ }
614
+ }
383
615
  const fanoutEnforcement = await buildFanoutEnforcement({
384
616
  repo: options.repo,
385
617
  pr: options.pr,
@@ -388,6 +620,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
388
620
  preApprovalGateMarker,
389
621
  config,
390
622
  cwd,
623
+ hasFullLabel,
624
+ baseRef,
391
625
  });
392
626
  return {
393
627
  ok: true,
@@ -458,7 +692,14 @@ async function main() {
458
692
  process.exitCode = 1;
459
693
  return;
460
694
  }
461
- process.stdout.write(`${JSON.stringify(output)}\n`);
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
+ }
702
+ process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
462
703
  } catch (error) {
463
704
  if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {
464
705
  const staleRunnerCheck = {
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseIssueNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
  export const LINKED_ISSUE_PR_QUERY = [
7
8
  "query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
8
9
  " repository(owner:$owner, name:$name) {",
@@ -28,6 +29,7 @@ export const LINKED_ISSUE_PR_QUERY = [
28
29
  " }",
29
30
  " ... on CrossReferencedEvent {",
30
31
  " createdAt",
32
+ " willCloseTarget",
31
33
  " source {",
32
34
  " __typename",
33
35
  " ... on PullRequest {",
@@ -73,12 +75,18 @@ Error output (stderr, JSON):
73
75
  Argument/usage errors:
74
76
  { "ok": false, "error": "...", "usage": "..." }
75
77
  gh/runtime failures:
76
- { "ok": false, "error": "..." }`.trim();
78
+ { "ok": false, "error": "..." }
79
+ ${JQ_OUTPUT_USAGE}`.trim();
77
80
  const parseError = buildParseError(USAGE);
78
81
  export function parseDetectLinkedIssuePrCliArgs(argv) {
79
82
  const { tokens } = parseArgs({
80
83
  args: [...argv],
81
- options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, issue: { type: "string" } },
84
+ options: {
85
+ help: { type: "boolean", short: "h" },
86
+ repo: { type: "string" },
87
+ issue: { type: "string" },
88
+ ...JQ_OUTPUT_PARSE_OPTIONS,
89
+ },
82
90
  allowPositionals: true,
83
91
  strict: false,
84
92
  tokens: true,
@@ -107,6 +115,7 @@ export function parseDetectLinkedIssuePrCliArgs(argv) {
107
115
  options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
108
116
  continue;
109
117
  }
118
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
110
119
  throw parseError(`Unknown argument: ${token.rawName}`);
111
120
  }
112
121
  if (options.repo === undefined || options.issue === undefined) {
@@ -162,6 +171,13 @@ function normalizeLinkedPrNode(node) {
162
171
  };
163
172
  }
164
173
  if (node.__typename === "CrossReferencedEvent") {
174
+ // Only a cross-reference that will CLOSE this issue owns its board status.
175
+ // A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
176
+ // create board-ownership linkage — otherwise every sibling a PR mentions
177
+ // gets dragged to In Progress and the resolver fails closed (#1130).
178
+ if (node.willCloseTarget !== true) {
179
+ return null;
180
+ }
165
181
  return {
166
182
  eventType: "CROSS_REFERENCED_EVENT",
167
183
  eventCreatedAt: node.createdAt,
@@ -253,7 +269,7 @@ export function selectLinkedIssuePr(candidates) {
253
269
  });
254
270
  return sorted[0] ?? null;
255
271
  }
256
- export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
272
+ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
257
273
  const { owner, name } = parseRepoSlug(repo);
258
274
  const candidates = [];
259
275
  const closedUnmergedCandidates = [];
@@ -322,7 +338,7 @@ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env,
322
338
  }
323
339
  export async function runCli(
324
340
  argv = process.argv.slice(2),
325
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
341
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
326
342
  ) {
327
343
  const options = parseDetectLinkedIssuePrCliArgs(argv);
328
344
  if (options.help) {
@@ -333,7 +349,7 @@ export async function runCli(
333
349
  { repo: options.repo, issue: options.issue },
334
350
  { env, ghCommand },
335
351
  );
336
- stdout.write(`${JSON.stringify(result)}\n`);
352
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
337
353
  }
338
354
  if (isDirectCliRun(import.meta.url)) {
339
355
  runCli().catch((error) => {