dev-loops 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.
Files changed (107) 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 +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
3
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
4
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleContract, resolveGateConfig, resolveRefinementConfig, resolveRejectForeignAngles } from "@dev-loops/core/config";
5
+ import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
5
6
  import { parseArgs } from "node:util";
6
7
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
8
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
8
9
  import { truncateText } from "@dev-loops/core/bash-exit-one";
9
10
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
10
11
  import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
@@ -72,6 +73,11 @@ Optional:
72
73
  --gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
73
74
  when omitted. Explicit gate is validated
74
75
  against allowed coordination actions.
76
+ --lightweight This PR is light-dispatched (#1210):
77
+ resolve the Copilot round cap as
78
+ min(lightMode.maxCopilotRounds ?? 1,
79
+ refinement.maxCopilotRounds) instead of
80
+ refinement.maxCopilotRounds alone.
75
81
  --findings-severity-counts <json> JSON object mapping severity to count
76
82
  (e.g. '{"must-fix":0,"worth-fixing-now":0}').
77
83
  Required for --verdict clean when
@@ -261,6 +267,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
261
267
  "findings-severity-counts": { type: "string" },
262
268
  "execution-mode": { type: "string" },
263
269
  "inline-reason": { type: "string" },
270
+ lightweight: { type: "boolean" },
264
271
  ...JQ_OUTPUT_PARSE_OPTIONS,
265
272
  },
266
273
  allowPositionals: true,
@@ -281,6 +288,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
281
288
  findingsSeverityCounts: undefined,
282
289
  executionMode: undefined,
283
290
  inlineReason: undefined,
291
+ lightweight: false,
284
292
  jq: undefined,
285
293
  silent: false,
286
294
  };
@@ -306,6 +314,10 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
306
314
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
307
315
  continue;
308
316
  }
317
+ if (token.name === "lightweight") {
318
+ options.lightweight = true;
319
+ continue;
320
+ }
309
321
  if (token.name === "gate") {
310
322
  const gate = normalizeGateName(requireTokenValue(token, parseError));
311
323
  if (!gate) {
@@ -742,7 +754,11 @@ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSu
742
754
  "",
743
755
  `**Next action:** ${nextAction}`,
744
756
  );
745
- return lines.join("\n");
757
+ // Neutralize any bare @copilot/`/copilot`* tokens in the rendered body (gate
758
+ // evidence legitimately quotes the anti-summon rule, e.g. from a findings
759
+ // excerpt) so posting this comment can never arm request-copilot-review.mjs's
760
+ // anti-summon guard on a later request.
761
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
746
762
  }
747
763
  function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
748
764
  if (requestedHeadSha === currentHeadSha) {
@@ -823,7 +839,7 @@ function detectStaleGateCommentWarning({ strict, headSha, gate }) {
823
839
  }
824
840
  return `A gate comment for \`${gate}\` already exists on a different head SHA \`${strict.headSha}\` (comment ${strict.commentId}). The old comment is stale for the current head.`;
825
841
  }
826
- async function runGhJson(args, { env, ghCommand }) {
842
+ async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
827
843
  const result = await runChild(ghCommand, args, env);
828
844
  if (result.code !== 0) {
829
845
  const detail = result.stderr.trim() || `exit code ${result.code}`;
@@ -841,18 +857,18 @@ function parseCommentMutationResponse(payload) {
841
857
  }
842
858
  return { commentId, commentUrl };
843
859
  }
844
- async function createComment({ repo, pr, body }, { env, ghCommand }) {
845
- const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand });
860
+ async function createComment({ repo, pr, body }, { env, ghCommand, runChild = defaultRunChild }) {
861
+ const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand, runChild });
846
862
  return parseCommentMutationResponse(payload);
847
863
  }
848
- async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
849
- const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand });
864
+ async function updateComment({ repo, commentId, body }, { env, ghCommand, runChild = defaultRunChild }) {
865
+ const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand, runChild });
850
866
  return parseCommentMutationResponse(payload);
851
867
  }
852
868
 
853
- async function verifyComment({ repo, commentId }, { env, ghCommand }) {
869
+ async function verifyComment({ repo, commentId }, { env, ghCommand, runChild = defaultRunChild }) {
854
870
  try {
855
- const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand });
871
+ const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand, runChild });
856
872
  return payload?.id != null;
857
873
  } catch {
858
874
  return false;
@@ -879,13 +895,13 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
879
895
  // markPrReady mutations are individually idempotent, so concurrent cooperating
880
896
  // runners cause at most a transient draft flicker (not a stuck draft) — only a hard
881
897
  // crash mid-transition can leave the PR drafted until a subsequent run.
882
- async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot }) {
898
+ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild = defaultRunChild }) {
883
899
  const { convertPrToDraft, markPrReady } = await import("./reconcile-draft-gate.mjs");
884
900
  process.stderr.write(
885
901
  `[draft_gate] ${options.repo}#${options.pr} is ready but needs clean draft_gate evidence; ` +
886
902
  `temporarily converting to draft to post the verdict, then restoring ready.\n`,
887
903
  );
888
- const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
904
+ const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
889
905
  let result;
890
906
  try {
891
907
  // The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
@@ -896,12 +912,12 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
896
912
  // with a clear error instead of recursing indefinitely (exit 13). (#1020)
897
913
  result = await upsertCheckpointVerdict(
898
914
  { ...options, _draftTransitionInProgress: true },
899
- { env, ghCommand, repoRoot },
915
+ { env, ghCommand, repoRoot, runChild },
900
916
  );
901
917
  } catch (error) {
902
918
  if (conversion.alreadyDraft !== true) {
903
919
  try {
904
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
920
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
905
921
  process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after a failed verdict post.\n`);
906
922
  } catch (restoreError) {
907
923
  // Best-effort restore; surface the original error but log the restore failure
@@ -916,7 +932,7 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
916
932
  }
917
933
  if (conversion.alreadyDraft !== true) {
918
934
  try {
919
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
935
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
920
936
  } catch (restoreError) {
921
937
  // The verdict WAS posted successfully; only the ready-restore failed. Make that
922
938
  // explicit so the caller does not re-post the gate (the comment already exists)
@@ -933,7 +949,8 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
933
949
  return { ...result, draftTransition: true };
934
950
  }
935
951
 
936
- export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
952
+ export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
953
+ const gh = { env, ghCommand, repoRoot, runChild };
937
954
  // Root cause 1: allow resurrected sessions to claim ownership when the previous
938
955
  // run's coordination record is stale. Without this, a new run ID is rejected even
939
956
  // though the old run is dead, forcing manual file deletion.
@@ -949,19 +966,24 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
949
966
  // loadPrGateCoordinationContext call will surface the real error.
950
967
  }
951
968
  }
952
- const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr }, { env, ghCommand });
969
+ // Thread the light-dispatch signal (#1210) so the context interpreter and the
970
+ // maxCopilotRounds resolution below both use the composed lightweight cap —
971
+ // the two must never disagree at the cap boundary (#1126).
972
+ const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, gh);
953
973
  const evidence = coordinationContext.gateEvidence;
954
974
  const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
955
975
  const { config } = await loadDevLoopConfig({ repoRoot });
956
976
  const draftGateConfig = resolveGateConfig(config, "draft");
957
977
  const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
958
- const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
978
+ const maxCopilotRounds = options.lightweight === true
979
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
980
+ : resolveRefinementConfig(config, "maxCopilotRounds");
959
981
  // Root cause 2: detect internal-only PRs so the Copilot convergence requirement
960
982
  // is suppressed. Docs-only / tooling-only PRs should go straight to pre_approval_gate
961
983
  // without requiring an external Copilot review cycle.
962
984
  let reviewMode = null;
963
985
  try {
964
- const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, { env, ghCommand });
986
+ const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, gh);
965
987
  if (internalResult?.ok && internalResult.internalOnly) {
966
988
  reviewMode = "internal_only";
967
989
  }
@@ -982,9 +1004,14 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
982
1004
  copilotReviewRoundCount: coordinationContext.snapshot?.copilotReviewRoundCount ?? 0,
983
1005
  maxCopilotRounds,
984
1006
  sameHeadCleanConverged: coordinationContext.interpretation.sameHeadCleanConverged,
1007
+ // Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
1008
+ // sameHeadCleanConverged, so an outstanding request on the current head refuses
1009
+ // RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
1010
+ copilotReviewRequestStatus: coordinationContext.snapshot?.copilotReviewRequestStatus ?? "none",
985
1011
  draftGateRequireCi: draftGateConfig.requireCi,
986
1012
  draftGate: coordinationContext.gateEvidence.draftGate,
987
1013
  draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
1014
+ refinementArtifact: coordinationContext.refinementArtifact,
988
1015
  preApprovalGate: coordinationContext.gateEvidence.preApprovalGate,
989
1016
  preApprovalGateMarker: coordinationContext.gateEvidence.preApprovalGateMarker,
990
1017
  ...(reviewMode ? { reviewMode } : {}),
@@ -1064,7 +1091,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1064
1091
  && !coordination.draftGateAlreadySatisfied
1065
1092
  && coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
1066
1093
  ) {
1067
- return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
1094
+ return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild });
1068
1095
  }
1069
1096
  // Fail closed on a lagged draft-state read: we are re-entering FROM
1070
1097
  // postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
@@ -1122,6 +1149,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1122
1149
  // multi-line per-angle breakdown and the `**Findings summary:**` line carries a
1123
1150
  // single-line digest (so the marker/parse contract still round-trips).
1124
1151
  let structuredFindings = null;
1152
+ let rawFindingsInput = null;
1125
1153
  if (options.findingsJson) {
1126
1154
  let raw;
1127
1155
  try {
@@ -1140,6 +1168,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1140
1168
  const candidate = Array.isArray(parsed)
1141
1169
  ? parsed
1142
1170
  : (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
1171
+ rawFindingsInput = candidate;
1143
1172
  try {
1144
1173
  structuredFindings = normalizeStructuredFindings(candidate);
1145
1174
  } catch (err) {
@@ -1149,6 +1178,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1149
1178
  throw new Error(`--findings-json "${options.findingsJson}" did not contain any renderable findings (expected a non-empty per-angle array of { angle, findings } entries, or a flat per-finding array of { severity, summary, angle? } entries)`);
1150
1179
  }
1151
1180
  }
1181
+ // Fan-out angle-coverage enforcement (fail closed): a fanout_fanin verdict's
1182
+ // structured per-angle results must cover every configured mandatory angle,
1183
+ // and (default) must not name an angle outside the gate's configured pool.
1184
+ // Only applies when structured per-angle results were actually supplied —
1185
+ // a free-text --findings-summary fanout_fanin verdict carries no per-angle
1186
+ // data to validate.
1187
+ if (structuredFindings && (options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin") {
1188
+ // Angle-less entries would be bucketed under the synthetic `general` label
1189
+ // by normalization and then surface as a CONFUSING foreign-angle error.
1190
+ // Fail first with a dedicated message naming the real problem instead.
1191
+ const angleless = (rawFindingsInput ?? []).filter(
1192
+ (e) => !e || typeof e !== "object" || typeof e.angle !== "string" || e.angle.trim().length === 0,
1193
+ ).length;
1194
+ if (angleless > 0) {
1195
+ throw new Error(
1196
+ `--findings-json for ${options.gate}: ${angleless} entr${angleless === 1 ? "y" : "ies"} lack a non-empty .angle — a fanout_fanin verdict must attribute every per-angle entry/finding to its review angle (use the nested [{ angle, verdict, findings }] shape, or add .angle to each flat finding)`,
1197
+ );
1198
+ }
1199
+ const gateKey = options.gate === "draft_gate" ? "draft" : "preApproval";
1200
+ const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
1201
+ const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(structuredFindings, {
1202
+ mandatoryAngles,
1203
+ pool,
1204
+ });
1205
+ if (missingMandatory.length > 0) {
1206
+ throw new Error(
1207
+ `--findings-json for ${options.gate} is missing mandatory angle(s): ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles; add a per-angle entry for each before posting a fanout_fanin verdict)`,
1208
+ );
1209
+ }
1210
+ if (foreignAngles.length > 0) {
1211
+ const message = `--findings-json for ${options.gate} names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
1212
+ if (resolveRejectForeignAngles(config)) {
1213
+ throw new Error(
1214
+ `${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`,
1215
+ );
1216
+ }
1217
+ // rejectForeignAngles: false is WARNING mode, not silence — one line per call.
1218
+ if (!options.silent) {
1219
+ process.stderr.write(`WARNING: ${message} (gates.rejectForeignAngles is false; recorded as a warning)\n`);
1220
+ }
1221
+ }
1222
+ }
1152
1223
  // --findings-json takes precedence; when structured findings are present, do not
1153
1224
  // read --findings-file at all (avoids a spurious hard failure if a caller passes
1154
1225
  // both and the file is missing/invalid even though it would be ignored anyway).
@@ -1227,15 +1298,15 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1227
1298
  };
1228
1299
  }
1229
1300
  if (existing) {
1230
- const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
1301
+ const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, gh);
1231
1302
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
1232
1303
  // A run id is set (production context) — DEVLOOPS_RUN_ID.
1233
1304
  let updateVerificationWarning = null;
1234
1305
  if (envRunId) {
1235
- let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
1306
+ let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
1236
1307
  if (!verified) {
1237
1308
  await new Promise((resolve) => setTimeout(resolve, 2000));
1238
- verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
1309
+ verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
1239
1310
  }
1240
1311
  updateVerificationWarning = !verified
1241
1312
  ? `Post-update verification failed: comment ${updated.commentId} not retrievable after retry.`
@@ -1258,7 +1329,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1258
1329
  ...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
1259
1330
  };
1260
1331
  }
1261
- const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
1332
+ const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, gh);
1262
1333
  // Post-creation verification: verify the comment is retrievable before returning.
1263
1334
  // GitHub API can have brief eventual-consistency windows where a just-posted
1264
1335
  // comment is not yet returned by paginated list endpoints. A direct fetch
@@ -1268,11 +1339,11 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1268
1339
  let verified = true;
1269
1340
  let verificationWarning = null;
1270
1341
  if (envRunId) {
1271
- verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
1342
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
1272
1343
  if (!verified) {
1273
1344
  // Brief wait then retry — eventual consistency should resolve within ~2s.
1274
1345
  await new Promise((resolve) => setTimeout(resolve, 2000));
1275
- verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
1346
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
1276
1347
  }
1277
1348
  verificationWarning = !verified
1278
1349
  ? `Post-creation verification failed: comment ${created.commentId} not retrievable after retry. The comment was created (API confirmed) but may not appear in list endpoints immediately.`