dev-loops 0.2.6 → 0.3.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 (92) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/skills/copilot-pr-followup/SKILL.md +57 -3
  7. package/.claude/skills/dev-loop/SKILL.md +5 -5
  8. package/.claude/skills/docs/anti-patterns.md +2 -0
  9. package/.claude/skills/docs/copilot-loop-operations.md +2 -2
  10. package/.claude/skills/local-implementation/SKILL.md +17 -3
  11. package/AGENTS.md +1 -1
  12. package/CHANGELOG.md +60 -0
  13. package/agents/dev-loop.agent.md +5 -1
  14. package/agents/developer.agent.md +1 -0
  15. package/agents/fixer.agent.md +1 -0
  16. package/agents/review.agent.md +30 -0
  17. package/cli/index.mjs +42 -7
  18. package/package.json +2 -2
  19. package/scripts/README.md +6 -5
  20. package/scripts/_cli-primitives.mjs +2 -0
  21. package/scripts/_core-helpers.mjs +1 -0
  22. package/scripts/claude/generate-claude-assets.mjs +12 -2
  23. package/scripts/claude/headless-dev-loop.mjs +53 -13
  24. package/scripts/claude/headless-info-smoke.mjs +45 -11
  25. package/scripts/docs/validate-links.mjs +20 -11
  26. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  27. package/scripts/github/capture-review-threads.mjs +32 -14
  28. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  29. package/scripts/github/detect-checkpoint-evidence.mjs +123 -15
  30. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  31. package/scripts/github/manage-sub-issues.mjs +37 -16
  32. package/scripts/github/post-gate-findings.mjs +392 -0
  33. package/scripts/github/probe-copilot-review.mjs +24 -12
  34. package/scripts/github/ready-for-review.mjs +17 -8
  35. package/scripts/github/reconcile-draft-gate.mjs +24 -12
  36. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  37. package/scripts/github/request-copilot-review.mjs +97 -19
  38. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  39. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +646 -41
  41. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  42. package/scripts/github/write-gate-context.mjs +634 -0
  43. package/scripts/github/write-gate-findings-log.mjs +42 -21
  44. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  45. package/scripts/loop/conductor-monitor.mjs +25 -9
  46. package/scripts/loop/conductor.mjs +31 -12
  47. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  48. package/scripts/loop/debt-remediate.mjs +28 -11
  49. package/scripts/loop/detect-change-scope.mjs +36 -11
  50. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  51. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  52. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  53. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  54. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +56 -28
  56. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  57. package/scripts/loop/detect-stale-runner.mjs +32 -14
  58. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  59. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  60. package/scripts/loop/info.mjs +28 -10
  61. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  62. package/scripts/loop/inspect-run.mjs +35 -16
  63. package/scripts/loop/outer-loop.mjs +35 -16
  64. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  65. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  66. package/scripts/loop/pre-flight-gate.mjs +25 -9
  67. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  68. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  69. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  70. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  71. package/scripts/loop/run-conductor-cycle.mjs +23 -8
  72. package/scripts/loop/run-queue.mjs +87 -15
  73. package/scripts/loop/run-refinement-audit.mjs +44 -22
  74. package/scripts/loop/run-watch-cycle.mjs +28 -12
  75. package/scripts/loop/steer-loop.mjs +122 -62
  76. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  77. package/scripts/projects/add-queue-item.mjs +60 -43
  78. package/scripts/projects/archive-done-items.mjs +506 -0
  79. package/scripts/projects/ensure-queue-board.mjs +65 -64
  80. package/scripts/projects/list-queue-items.mjs +57 -56
  81. package/scripts/projects/move-queue-item.mjs +123 -124
  82. package/scripts/projects/reorder-queue-item.mjs +384 -108
  83. package/scripts/projects/sync-item-status.mjs +198 -0
  84. package/scripts/refine/_refine-helpers.mjs +21 -9
  85. package/scripts/refine/verify.mjs +31 -13
  86. package/skills/copilot-pr-followup/SKILL.md +57 -3
  87. package/skills/dev-loop/SKILL.md +9 -5
  88. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  89. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  90. package/skills/docs/anti-patterns.md +2 -0
  91. package/skills/docs/copilot-loop-operations.md +2 -2
  92. package/skills/local-implementation/SKILL.md +17 -3
@@ -2,7 +2,8 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
4
  import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
5
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
5
+ import { parseArgs } from "node:util";
6
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
7
  import { truncateText } from "@dev-loops/core/bash-exit-one";
7
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
9
  import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
@@ -14,13 +15,16 @@ import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
14
15
  import { detectInternalOnly } from "../loop/detect-internal-only-pr.mjs";
15
16
  const GATE_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
16
17
  const GATE_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
18
+ const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
19
+ const DEFAULT_EXECUTION_MODE = "inline_single_agent";
17
20
  const MAX_GATE_COMMENT_TEXT_LENGTH = 2000;
18
21
  const MAX_GATE_COMMENT_EXCERPT_LENGTH = 120;
19
22
  const REMOVED_FLAGS = new Set([
20
23
  "--force",
21
24
  "--force-reason",
22
25
  ]);
23
- const USAGE = `Usage: upsert-checkpoint-verdict.mjs --repo <owner/name> --pr <number> --head-sha <sha> --verdict <clean|findings_present|blocked> (--findings-summary <text> | --findings-file <path>) --next-action <text> [--gate <draft_gate|pre_approval_gate>]
26
+ const USAGE = `Usage: upsert-checkpoint-verdict.mjs --repo <owner/name> --pr <number> --head-sha <sha> --verdict <clean|findings_present|blocked> (--findings-summary <text> | --findings-file <path> | --findings-json <path>) --next-action <text> [--gate <draft_gate|pre_approval_gate>]
27
+ The --findings-json structured per-angle path is preferred for --execution-mode fanout_fanin.
24
28
  Create or update the visible checkpoint verdict comment for a gate/head pair.
25
29
  Same-head reruns are idempotent: if a visible marker already exists for the same
26
30
  \`gate + headSha\`, this helper updates it in place when correction is needed and
@@ -39,6 +43,29 @@ Required:
39
43
  alternative to --findings-summary
40
44
  (preserves newlines; takes precedence
41
45
  when both are present)
46
+ --findings-json <path> Read STRUCTURED fan-out review
47
+ findings from a JSON file. PRIMARY
48
+ shape is the per-angle review-results
49
+ array (array of { angle, verdict?,
50
+ findings:[{severity, summary, file?,
51
+ line?, disposition?}] } — the same
52
+ per-angle objects that feed
53
+ consolidateFanin). A FLAT per-finding
54
+ array (array of { severity, summary,
55
+ angle?, file?|files?, line?,
56
+ disposition? } — consolidateFanin's
57
+ OUTPUT / toFindingsLogShape) is also
58
+ accepted and is GROUPED by each
59
+ finding's .angle. A non-empty input
60
+ matching NEITHER shape is rejected
61
+ (no silent all-clean). Renders a
62
+ readable per-angle breakdown (newlines
63
+ preserved); the findings summary line
64
+ carries a single-line digest. Takes
65
+ precedence over
66
+ --findings-summary/--findings-file for
67
+ the rendered body. Intended for
68
+ --execution-mode fanout_fanin.
42
69
  --next-action <text>
43
70
  Optional:
44
71
  --gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
@@ -48,6 +75,19 @@ Optional:
48
75
  (e.g. '{"must-fix":0,"worth-fixing-now":0}').
49
76
  Required for --verdict clean when
50
77
  blockCleanOnFindingSeverities is configured.
78
+ --execution-mode <fanout_fanin|inline_single_agent>
79
+ How the gate review was executed.
80
+ Defaults to inline_single_agent. Inline
81
+ runs (default or explicit) emit a stderr
82
+ warning that the fan-out/fan-in sub-loop
83
+ was not run and REQUIRE --inline-reason.
84
+ --inline-reason <text> REQUIRED when executionMode resolves to
85
+ inline_single_agent (the default mode):
86
+ short reason recorded for why the gate
87
+ ran inline. A bare call with neither
88
+ --execution-mode nor --inline-reason
89
+ errors. Optional and ignored (dropped)
90
+ for --execution-mode fanout_fanin.
51
91
  Output (stdout, JSON):
52
92
  {
53
93
  "ok": true,
@@ -86,6 +126,10 @@ function normalizeHeadSha(value) {
86
126
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
87
127
  return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
88
128
  }
129
+ function normalizeExecutionMode(value) {
130
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
131
+ return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
132
+ }
89
133
  function normalizeRequiredText(value, flag) {
90
134
  const normalized = typeof value === "string" ? value.trim() : "";
91
135
  if (normalized.length === 0) {
@@ -198,7 +242,27 @@ export function summarizeCheckpointVerdictText(value, limit = MAX_GATE_COMMENT_T
198
242
  return smartTruncate(verboseSummary ?? flat, limit);
199
243
  }
200
244
  export function parseUpsertCheckpointVerdictCliArgs(argv) {
201
- const args = [...argv];
245
+ const { tokens } = parseArgs({
246
+ args: [...argv],
247
+ options: {
248
+ help: { type: "boolean", short: "h" },
249
+ repo: { type: "string" },
250
+ pr: { type: "string" },
251
+ gate: { type: "string" },
252
+ "head-sha": { type: "string" },
253
+ verdict: { type: "string" },
254
+ "findings-summary": { type: "string" },
255
+ "findings-file": { type: "string" },
256
+ "findings-json": { type: "string" },
257
+ "next-action": { type: "string" },
258
+ "findings-severity-counts": { type: "string" },
259
+ "execution-mode": { type: "string" },
260
+ "inline-reason": { type: "string" },
261
+ },
262
+ allowPositionals: true,
263
+ strict: false,
264
+ tokens: true,
265
+ });
202
266
  const options = {
203
267
  help: false,
204
268
  repo: undefined,
@@ -208,68 +272,84 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
208
272
  verdict: undefined,
209
273
  findingsSummary: undefined,
210
274
  findingsFile: undefined,
275
+ findingsJson: undefined,
211
276
  nextAction: undefined,
212
277
  findingsSeverityCounts: undefined,
278
+ executionMode: undefined,
279
+ inlineReason: undefined,
213
280
  };
214
- while (args.length > 0) {
215
- const token = args.shift();
216
- if (token === "--help" || token === "-h") {
281
+ for (const token of tokens) {
282
+ if (token.kind === "positional") {
283
+ throw parseError(`Unknown argument: ${token.value}`);
284
+ }
285
+ if (token.kind !== "option") {
286
+ continue;
287
+ }
288
+ if (token.name === "help") {
217
289
  options.help = true;
218
290
  return options;
219
291
  }
220
- if (REMOVED_FLAGS.has(token)) {
221
- rejectRemovedFlag(token);
292
+ if (REMOVED_FLAGS.has(token.rawName)) {
293
+ rejectRemovedFlag(token.rawName);
222
294
  }
223
- if (token === "--repo") {
224
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
295
+ if (token.name === "repo") {
296
+ options.repo = requireTokenValue(token, parseError).trim();
225
297
  continue;
226
298
  }
227
- if (token === "--pr") {
228
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
299
+ if (token.name === "pr") {
300
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
229
301
  continue;
230
302
  }
231
- if (token === "--gate") {
232
- const gate = normalizeGateName(requireOptionValue(args, "--gate", parseError));
303
+ if (token.name === "gate") {
304
+ const gate = normalizeGateName(requireTokenValue(token, parseError));
233
305
  if (!gate) {
234
306
  throw parseError("--gate must be one of: draft_gate, pre_approval_gate");
235
307
  }
236
308
  options.gate = gate;
237
309
  continue;
238
310
  }
239
- if (token === "--head-sha") {
240
- const headSha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
311
+ if (token.name === "head-sha") {
312
+ const headSha = normalizeHeadSha(requireTokenValue(token, parseError));
241
313
  if (!headSha) {
242
314
  throw parseError("--head-sha must be a 7-64 character hexadecimal SHA");
243
315
  }
244
316
  options.headSha = headSha;
245
317
  continue;
246
318
  }
247
- if (token === "--verdict") {
248
- const verdict = normalizeVerdict(requireOptionValue(args, "--verdict", parseError));
319
+ if (token.name === "verdict") {
320
+ const verdict = normalizeVerdict(requireTokenValue(token, parseError));
249
321
  if (!verdict) {
250
322
  throw parseError("--verdict must be one of: clean, findings_present, blocked");
251
323
  }
252
324
  options.verdict = verdict;
253
325
  continue;
254
326
  }
255
- if (token === "--findings-summary") {
256
- options.findingsSummary = normalizeRequiredText(requireOptionValue(args, "--findings-summary", parseError), "--findings-summary");
327
+ if (token.name === "findings-summary") {
328
+ options.findingsSummary = normalizeRequiredText(requireTokenValue(token, parseError), "--findings-summary");
257
329
  continue;
258
330
  }
259
- if (token === "--findings-file") {
260
- const rawPath = requireOptionValue(args, "--findings-file", parseError).trim();
331
+ if (token.name === "findings-file") {
332
+ const rawPath = requireTokenValue(token, parseError).trim();
261
333
  if (rawPath.length === 0) {
262
334
  throw parseError("--findings-file must be a non-empty path");
263
335
  }
264
336
  options.findingsFile = rawPath;
265
337
  continue;
266
338
  }
267
- if (token === "--next-action") {
268
- options.nextAction = normalizeRequiredText(requireOptionValue(args, "--next-action", parseError), "--next-action");
339
+ if (token.name === "findings-json") {
340
+ const rawPath = requireTokenValue(token, parseError).trim();
341
+ if (rawPath.length === 0) {
342
+ throw parseError("--findings-json must be a non-empty path");
343
+ }
344
+ options.findingsJson = rawPath;
269
345
  continue;
270
346
  }
271
- if (token === "--findings-severity-counts") {
272
- const raw = requireOptionValue(args, "--findings-severity-counts", parseError);
347
+ if (token.name === "next-action") {
348
+ options.nextAction = normalizeRequiredText(requireTokenValue(token, parseError), "--next-action");
349
+ continue;
350
+ }
351
+ if (token.name === "findings-severity-counts") {
352
+ const raw = requireTokenValue(token, parseError);
273
353
  let parsed;
274
354
  try {
275
355
  parsed = JSON.parse(raw);
@@ -289,16 +369,51 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
289
369
  options.findingsSeverityCounts = counts;
290
370
  continue;
291
371
  }
292
- throw parseError(`Unknown argument: ${token}`);
372
+ if (token.name === "execution-mode") {
373
+ const mode = normalizeExecutionMode(requireTokenValue(token, parseError));
374
+ if (!mode) {
375
+ throw parseError("--execution-mode must be one of: fanout_fanin, inline_single_agent");
376
+ }
377
+ options.executionMode = mode;
378
+ continue;
379
+ }
380
+ if (token.name === "inline-reason") {
381
+ const reason = collapseWhitespace(requireTokenValue(token, parseError));
382
+ if (reason.length === 0) {
383
+ throw parseError("--inline-reason must be a non-empty string");
384
+ }
385
+ options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
386
+ continue;
387
+ }
388
+ throw parseError(`Unknown argument: ${token.rawName}`);
389
+ }
390
+ // Default execution mode to inline_single_agent when omitted. inlineReason is
391
+ // only meaningful for inline mode; drop it for fanout_fanin to avoid recording
392
+ // a misleading reason.
393
+ options.executionMode = options.executionMode ?? DEFAULT_EXECUTION_MODE;
394
+ if (options.executionMode !== "inline_single_agent") {
395
+ options.inlineReason = undefined;
293
396
  }
294
397
  const missing = ["repo", "pr", "headSha", "verdict", "findingsSummary", "nextAction"]
295
398
  .filter((key) => options[key] === undefined);
296
- if (options.findingsFile) {
399
+ // --findings-file and --findings-json each provide the findings body, so either
400
+ // satisfies the findingsSummary requirement.
401
+ if (options.findingsFile || options.findingsJson) {
297
402
  const fsIdx = missing.indexOf("findingsSummary");
298
403
  if (fsIdx !== -1) missing.splice(fsIdx, 1);
299
404
  }
300
405
  if (missing.length > 0) {
301
- throw parseError("upsert-checkpoint-verdict requires --repo, --pr, --head-sha, --verdict, --findings-summary (or --findings-file), and --next-action");
406
+ throw parseError("upsert-checkpoint-verdict requires --repo, --pr, --head-sha, --verdict, --findings-summary (or --findings-file or --findings-json), and --next-action");
407
+ }
408
+ // Contract (skills/copilot-pr-followup/SKILL.md): inline runs MUST pass
409
+ // --inline-reason. Inline is the default mode, so a complete call that resolves
410
+ // to inline without a reason errors here. fanout_fanin does not require a
411
+ // reason. Checked after required-field validation so an incomplete call still
412
+ // reports the missing-field error first.
413
+ if (options.executionMode === "inline_single_agent" && options.inlineReason === undefined) {
414
+ throw parseError(
415
+ "--inline-reason is required for executionMode inline_single_agent (the default). Pass --execution-mode fanout_fanin for fan-out/fan-in runs, or --inline-reason \"<why>\" to record why the gate ran inline.",
416
+ );
302
417
  }
303
418
  try {
304
419
  parseRepoSlug(options.repo);
@@ -321,20 +436,302 @@ function appendGateEvidenceNote(summary, note) {
321
436
  }
322
437
  return smartTruncate(`${normalizedSummary}; ${normalizedNote}`, MAX_GATE_COMMENT_TEXT_LENGTH);
323
438
  }
324
- export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSummary, nextAction, blockCleanOnFindingSeverities }) {
439
+ const STRUCTURED_FINDINGS_SEVERITY_ORDER = ["must-fix", "worth-fixing-now", "defer"];
440
+ // Sanitize free text for a single-line markdown bullet. Collapse whitespace
441
+ // (LLM text often carries embedded newlines, which would split a bullet across
442
+ // lines) and neutralize HTML-comment delimiters so a finding field cannot smuggle
443
+ // a hidden marker into the rendered body. Mirrors post-gate-findings.mjs.
444
+ function sanitizeStructuredInline(value) {
445
+ return String(value)
446
+ .replace(/\s+/gu, " ")
447
+ .replace(/<!--/gu, "&lt;!--")
448
+ .replace(/-->/gu, "--&gt;")
449
+ .trim();
450
+ }
451
+ // Sanitize text rendered inside an inline backtick code span (angle labels,
452
+ // file refs): additionally strip backticks so an embedded backtick cannot close
453
+ // the span and break out into raw markdown.
454
+ function sanitizeStructuredCodeSpan(value) {
455
+ return sanitizeStructuredInline(String(value).replace(/`/gu, ""));
456
+ }
457
+ // Normalize a single finding object into a deterministic render entry, or null
458
+ // when it carries no usable summary.
459
+ function normalizeStructuredFinding(f) {
460
+ if (!f || typeof f !== "object" || Array.isArray(f)) {
461
+ return null;
462
+ }
463
+ const summary = typeof f.summary === "string" ? f.summary.trim() : "";
464
+ if (summary.length === 0) {
465
+ return null;
466
+ }
467
+ const entry = {
468
+ severity: typeof f.severity === "string" ? f.severity.trim() : "",
469
+ summary,
470
+ };
471
+ if (typeof f.file === "string" && f.file.trim().length > 0) {
472
+ entry.file = f.file.trim();
473
+ } else if (Array.isArray(f.files)) {
474
+ // Flat consolidated findings (toFindingsLogShape) carry a `files` array
475
+ // rather than a single `file`; surface the first entry as the location ref.
476
+ const file = f.files.find((x) => typeof x === "string" && x.trim().length > 0);
477
+ if (file) {
478
+ entry.file = file.trim();
479
+ }
480
+ }
481
+ if (typeof f.line === "number" && Number.isFinite(f.line)) {
482
+ entry.line = f.line;
483
+ }
484
+ if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
485
+ entry.disposition = f.disposition.trim();
486
+ }
487
+ return entry;
488
+ }
489
+ // Map a severity to its sort rank. Known severities follow
490
+ // STRUCTURED_FINDINGS_SEVERITY_ORDER (must-fix → worth-fixing-now → defer);
491
+ // unknown/missing severities map to a LARGE rank so they sort LAST, never
492
+ // before must-fix. (indexOf alone would give an unknown severity rank -1,
493
+ // floating it ABOVE must-fix and hiding the highest-priority items below it.)
494
+ function severitySortRank(severity) {
495
+ const idx = STRUCTURED_FINDINGS_SEVERITY_ORDER.indexOf(severity);
496
+ return idx === -1 ? STRUCTURED_FINDINGS_SEVERITY_ORDER.length : idx;
497
+ }
498
+ // Sort findings by severity (must-fix first, unknown/missing last) for
499
+ // deterministic output, preserving input order within a severity.
500
+ function sortStructuredFindings(findings) {
501
+ findings.sort(
502
+ (a, b) => severitySortRank(a.severity) - severitySortRank(b.severity),
503
+ );
504
+ return findings;
505
+ }
506
+ // Does this item look like a NESTED per-angle entry (consolidateFanin's INPUT
507
+ // shape: { angle, verdict?, findings: [...] })? It must carry a `findings`
508
+ // ARRAY — that, not the presence of an `angle` string, is what distinguishes a
509
+ // per-angle section from a single flat finding.
510
+ function looksLikePerAngleEntry(item) {
511
+ return Boolean(item) && typeof item === "object" && !Array.isArray(item) && Array.isArray(item.findings);
512
+ }
513
+ // Does this item look like a FLAT per-finding entry (consolidateFanin's OUTPUT /
514
+ // toFindingsLogShape shape: { severity, summary, angle?, file?/files?, ... })? It
515
+ // carries a summary (and typically a severity) but NO nested `findings` array.
516
+ function looksLikeFlatFinding(item) {
517
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
518
+ return false;
519
+ }
520
+ if (Array.isArray(item.findings)) {
521
+ return false;
522
+ }
523
+ return typeof item.summary === "string" && item.summary.trim().length > 0;
524
+ }
525
+ // Build a render-ready per-angle section from a nested entry. A missing/blank
526
+ // angle is NOT dropped — its findings still matter for the verdict — so it is
527
+ // rendered under a `general` fallback label (consistent with the flat-grouping
528
+ // angleless→`general` bucket). Dropping it would let a non-empty structured
529
+ // payload silently degrade to the free-text path and hide findings.
530
+ function buildAngleSectionFromNested(raw) {
531
+ const trimmedAngle = typeof raw.angle === "string" ? raw.angle.trim() : "";
532
+ const angle = trimmedAngle.length > 0 ? trimmedAngle : "general";
533
+ const findings = [];
534
+ for (const f of raw.findings) {
535
+ const entry = normalizeStructuredFinding(f);
536
+ if (entry) {
537
+ findings.push(entry);
538
+ }
539
+ }
540
+ sortStructuredFindings(findings);
541
+ const verdict = typeof raw.verdict === "string" && raw.verdict.trim().length > 0
542
+ ? raw.verdict.trim()
543
+ : (findings.length > 0 ? "findings_present" : "clean");
544
+ return { angle, verdict, findings };
545
+ }
546
+ // Group a FLAT per-finding array into per-angle sections, keyed by each
547
+ // finding's `.angle` field (findings without an angle are grouped under a
548
+ // shared "general" bucket so they are NOT dropped). The verdict for each
549
+ // section is derived from whether it carries findings.
550
+ function groupFlatFindingsByAngle(input) {
551
+ const order = [];
552
+ const byAngle = new Map();
553
+ for (const f of input) {
554
+ const entry = normalizeStructuredFinding(f);
555
+ if (!entry) {
556
+ continue;
557
+ }
558
+ const angle = typeof f.angle === "string" && f.angle.trim().length > 0
559
+ ? f.angle.trim()
560
+ : "general";
561
+ if (!byAngle.has(angle)) {
562
+ byAngle.set(angle, []);
563
+ order.push(angle);
564
+ }
565
+ byAngle.get(angle).push(entry);
566
+ }
567
+ const angles = [];
568
+ for (const angle of order) {
569
+ const findings = sortStructuredFindings(byAngle.get(angle));
570
+ angles.push({
571
+ angle,
572
+ verdict: findings.length > 0 ? "findings_present" : "clean",
573
+ findings,
574
+ });
575
+ }
576
+ return angles;
577
+ }
578
+ // Normalize the structured findings input into a deterministic, render-ready
579
+ // per-angle shape. Accepts BOTH recognizable shapes without silently zeroing
580
+ // findings:
581
+ // 1. NESTED per-angle (consolidateFanin INPUT):
582
+ // [{ angle, verdict?, findings: [{ severity, summary, file?, line?, disposition? }] }]
583
+ // → rendered one section per angle.
584
+ // 2. FLAT per-finding (consolidateFanin OUTPUT / toFindingsLogShape):
585
+ // [{ severity, summary, angle?, file?|files?, line?, disposition? }]
586
+ // → GROUPED by each finding's `.angle` into per-angle sections.
587
+ // Returns null when the input is empty/non-array (caller falls back to the
588
+ // free-text summary). THROWS when the input is non-empty but matches NEITHER
589
+ // shape, so a wrong input shape can never silently render an all-clean verdict.
590
+ function normalizeStructuredFindings(input) {
591
+ if (!Array.isArray(input) || input.length === 0) {
592
+ return null;
593
+ }
594
+ // A gate verdict comment must NEVER silently hide/drop findings. If ANY item
595
+ // in a non-empty payload is neither a recognizable per-angle entry nor a
596
+ // recognizable flat finding, THROW rather than filter-and-proceed — an
597
+ // unrecognized item (producer drift, malformed entry) could otherwise carry a
598
+ // dropped finding the reviewer never sees.
599
+ const unrecognized = input.filter(
600
+ (item) => !looksLikePerAngleEntry(item) && !looksLikeFlatFinding(item),
601
+ );
602
+ if (unrecognized.length === input.length) {
603
+ throw new Error(
604
+ "--findings-json input is non-empty but matches neither recognized shape: "
605
+ + "a per-angle array ([{ angle, verdict?, findings: [...] }]) or a flat "
606
+ + "per-finding array ([{ severity, summary, angle?, ... }]). Refusing to "
607
+ + "render an all-clean verdict from unrecognized findings.",
608
+ );
609
+ }
610
+ if (unrecognized.length > 0) {
611
+ throw new Error(
612
+ "--findings-json input contains "
613
+ + `${unrecognized.length} of ${input.length} item(s) that match neither a `
614
+ + "per-angle entry (with a nested `findings` array) nor a flat per-finding "
615
+ + "entry (with a non-empty `summary`). Refusing to silently drop them from a "
616
+ + "gate verdict; fix the producer or remove the malformed entries.",
617
+ );
618
+ }
619
+ const nestedCount = input.filter(looksLikePerAngleEntry).length;
620
+ let angles;
621
+ if (nestedCount > 0) {
622
+ // Treat as per-angle. Any flat items mixed in are ambiguous; reject rather
623
+ // than guess (mixing the two shapes is not a supported producer output).
624
+ if (nestedCount !== input.length) {
625
+ throw new Error(
626
+ "--findings-json input mixes per-angle entries (with a nested `findings` "
627
+ + "array) and flat per-finding entries; supply one shape or the other.",
628
+ );
629
+ }
630
+ angles = [];
631
+ for (const raw of input) {
632
+ angles.push(buildAngleSectionFromNested(raw));
633
+ }
634
+ } else {
635
+ angles = groupFlatFindingsByAngle(input);
636
+ }
637
+ return angles.length > 0 ? angles : null;
638
+ }
639
+ // Render the consolidated per-angle fan-in findings as a readable, multi-line
640
+ // markdown block: one section per angle (angle label + per-angle verdict),
641
+ // nested findings carrying severity and an optional file:line reference. Newlines
642
+ // are intentionally PRESERVED — this block is NOT run through collapseWhitespace /
643
+ // summarizeCheckpointVerdictText. The whole block is bounded by
644
+ // MAX_GATE_COMMENT_TEXT_LENGTH. The leading single-line digest is what the marker
645
+ // parser captures for the `**Findings summary:**` field; the structured body is
646
+ // nested below it and is deliberately written so no nested line matches a gate
647
+ // field regex (no `verdict:` / `next action:` / `execution mode:` line starts).
648
+ function renderStructuredFindings(angles) {
649
+ const lines = [];
650
+ for (const { angle, verdict, findings } of angles) {
651
+ const angleLabel = sanitizeStructuredCodeSpan(angle);
652
+ lines.push(`- \`${angleLabel}\` → ${sanitizeStructuredInline(verdict)}`);
653
+ for (const finding of findings) {
654
+ const severity = sanitizeStructuredInline(finding.severity) || "finding";
655
+ const summary = sanitizeStructuredInline(finding.summary);
656
+ let location = "";
657
+ if (finding.file) {
658
+ const fileRef = sanitizeStructuredCodeSpan(finding.file);
659
+ const lineRef = Number.isFinite(finding.line) ? `:${finding.line}` : "";
660
+ if (fileRef.length > 0) {
661
+ location = ` (\`${fileRef}${lineRef}\`)`;
662
+ }
663
+ }
664
+ const dispositionSuffix = finding.disposition
665
+ ? ` — _${sanitizeStructuredInline(finding.disposition)}_`
666
+ : "";
667
+ lines.push(` - [${severity}] ${summary}${location}${dispositionSuffix}`);
668
+ }
669
+ }
670
+ return smartTruncate(lines.join("\n"), MAX_GATE_COMMENT_TEXT_LENGTH);
671
+ }
672
+ // Build the single-line digest shown on the `**Findings summary:**` line when a
673
+ // structured per-angle block is rendered. The marker/parse contract requires this
674
+ // line to carry non-empty, single-line content (parseGateReviewCommentFields
675
+ // captures only the remainder of this one line), so the structured block below it
676
+ // is purely presentational.
677
+ function buildStructuredFindingsDigest(angles) {
678
+ const totalFindings = angles.reduce((sum, a) => sum + a.findings.length, 0);
679
+ const angleWord = angles.length === 1 ? "angle" : "angles";
680
+ if (totalFindings === 0) {
681
+ return `${angles.length} ${angleWord} reviewed; no findings (see per-angle breakdown below).`;
682
+ }
683
+ const findingWord = totalFindings === 1 ? "finding" : "findings";
684
+ return `${angles.length} ${angleWord} reviewed; ${totalFindings} ${findingWord} (see per-angle breakdown below).`;
685
+ }
686
+ function renderExecutionModeLine(executionMode, inlineReason) {
687
+ const mode = executionMode ?? DEFAULT_EXECUTION_MODE;
688
+ if (mode === "inline_single_agent") {
689
+ const reason = typeof inlineReason === "string" ? collapseWhitespace(inlineReason) : "";
690
+ return reason.length > 0
691
+ ? `**Execution mode:** inline_single_agent — ${reason}`
692
+ : "**Execution mode:** inline_single_agent";
693
+ }
694
+ return `**Execution mode:** ${mode}`;
695
+ }
696
+ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSummary, nextAction, blockCleanOnFindingSeverities, executionMode, inlineReason, structuredFindings, gateEvidenceNote }) {
325
697
  const lines = [
326
698
  `### Gate review: \`${gate}\``,
327
699
  "",
328
700
  `**Reviewed head SHA:** \`${headSha}\``,
329
701
  `**Verdict:** ${verdict}`,
702
+ renderExecutionModeLine(executionMode, inlineReason),
330
703
  ];
331
704
  if ((verdict === "findings_present" || verdict === "blocked") && blockCleanOnFindingSeverities && blockCleanOnFindingSeverities.length > 0) {
332
705
  const sevs = blockCleanOnFindingSeverities.join(", ");
333
706
  lines.push(`**Blocking severities:** ${sevs} (clean requires no findings matching these severities)`);
334
707
  }
708
+ // When structured per-angle fan-in data is supplied, render it as a readable
709
+ // multi-line block. The `**Findings summary:**` line still carries a non-empty
710
+ // single-line digest so the marker/parse contract (which captures only that
711
+ // one line) keeps round-tripping; the structured breakdown is nested below it
712
+ // with newlines preserved (NOT collapsed to a run-on line).
713
+ const angles = normalizeStructuredFindings(structuredFindings);
714
+ if (angles) {
715
+ // Build the single-line digest and append the gate-evidence note (parity with
716
+ // the free-text appendGateEvidenceNote path), so a structured verdict carries
717
+ // the gate-evidence note (e.g. the round-cap / round-exhaustion fallback note)
718
+ // on the `**Findings summary:**` line just like a free-text verdict does.
719
+ // appendGateEvidenceNote keeps this a single, length-bounded line, preserving
720
+ // the marker/parse contract. The per-angle bullets below are unaffected.
721
+ const structuredSummary = appendGateEvidenceNote(buildStructuredFindingsDigest(angles), gateEvidenceNote ?? null);
722
+ lines.push(
723
+ "",
724
+ `**Findings summary:** ${structuredSummary}`,
725
+ "",
726
+ renderStructuredFindings(angles),
727
+ );
728
+ } else {
729
+ lines.push(
730
+ "",
731
+ `**Findings summary:** ${findingsSummary}`,
732
+ );
733
+ }
335
734
  lines.push(
336
- "",
337
- `**Findings summary:** ${findingsSummary}`,
338
735
  "",
339
736
  `**Next action:** ${nextAction}`,
340
737
  );
@@ -380,6 +777,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
380
777
  verdict: markerSameHead.verdict,
381
778
  findingsSummary: markerSameHead.findingsSummary ?? null,
382
779
  nextAction: markerSameHead.nextAction ?? null,
780
+ executionMode: markerSameHead.executionMode ?? null,
781
+ inlineReason: markerSameHead.inlineReason ?? null,
383
782
  contractComplete: markerSameHead.contractComplete === true,
384
783
  };
385
784
  }
@@ -391,6 +790,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
391
790
  verdict: strictSameHead.verdict,
392
791
  findingsSummary: strictSameHead.findingsSummary,
393
792
  nextAction: strictSameHead.nextAction,
793
+ executionMode: strictSameHead.executionMode ?? markerSameHead?.executionMode ?? null,
794
+ inlineReason: strictSameHead.inlineReason ?? markerSameHead?.inlineReason ?? null,
394
795
  contractComplete: true,
395
796
  };
396
797
  }
@@ -402,6 +803,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
402
803
  verdict: markerSameHead.verdict,
403
804
  findingsSummary: markerSameHead.findingsSummary ?? null,
404
805
  nextAction: markerSameHead.nextAction ?? null,
806
+ executionMode: markerSameHead.executionMode ?? null,
807
+ inlineReason: markerSameHead.inlineReason ?? null,
405
808
  contractComplete: markerSameHead.contractComplete === true,
406
809
  };
407
810
  }
@@ -449,6 +852,73 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
449
852
  }
450
853
  }
451
854
 
855
+ // Post a draft_gate verdict on a PR that is currently READY (non-draft) by
856
+ // briefly transitioning it back to draft, posting the verdict (which is only
857
+ // legal while the PR is a draft), then restoring the ready state. The caller's
858
+ // options — verdict, execution mode (e.g. fanout_fanin), findings, ledger — are
859
+ // preserved verbatim by re-entering upsertCheckpointVerdict once the PR is a
860
+ // draft. Unlike `reconcile-draft-gate` (which posts an inline verdict and so
861
+ // cannot satisfy requireFanoutEvidence on draft_gate), this preserves fanout
862
+ // evidence. On any failure mid-transition the PR is best-effort restored to
863
+ // ready before rethrowing. (#891)
864
+ //
865
+ // Durability note: there is a bounded window between convertPrToDraft and
866
+ // markPrReady (the recursive verdict post does network I/O) in which a process
867
+ // crash would leave the PR in draft INDEFINITELY — until a later dev-loop run (or a
868
+ // manual `gh pr ready`) restores it. Recovery is automatic but NOT instantaneous:
869
+ // the next run re-enters as a draft, posts normally, and restores ready. The
870
+ // transition is logged (below) so a stuck-draft PR leaves a breadcrumb. There is no
871
+ // mutual exclusion around the GitHub draft toggle itself; the convert and
872
+ // markPrReady mutations are individually idempotent, so concurrent cooperating
873
+ // runners cause at most a transient draft flicker (not a stuck draft) — only a hard
874
+ // crash mid-transition can leave the PR drafted until a subsequent run.
875
+ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot }) {
876
+ const { convertPrToDraft, markPrReady } = await import("./reconcile-draft-gate.mjs");
877
+ process.stderr.write(
878
+ `[draft_gate] ${options.repo}#${options.pr} is ready but needs clean draft_gate evidence; ` +
879
+ `temporarily converting to draft to post the verdict, then restoring ready.\n`,
880
+ );
881
+ const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
882
+ let result;
883
+ try {
884
+ // The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
885
+ // the caller's full options; prIsDraft is now true so this branch is skipped.
886
+ result = await upsertCheckpointVerdict(options, { env, ghCommand, repoRoot });
887
+ } catch (error) {
888
+ if (conversion.alreadyDraft !== true) {
889
+ try {
890
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
891
+ process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after a failed verdict post.\n`);
892
+ } catch (restoreError) {
893
+ // Best-effort restore; surface the original error but log the restore failure
894
+ // so the transient draft state is not silent.
895
+ process.stderr.write(
896
+ `[draft_gate] WARNING: failed to restore ${options.repo}#${options.pr} to ready after a failed verdict post; ` +
897
+ `it may be left in draft: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}\n`,
898
+ );
899
+ }
900
+ }
901
+ throw error;
902
+ }
903
+ if (conversion.alreadyDraft !== true) {
904
+ try {
905
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
906
+ } catch (restoreError) {
907
+ // The verdict WAS posted successfully; only the ready-restore failed. Make that
908
+ // explicit so the caller does not re-post the gate (the comment already exists)
909
+ // and knows the PR may be left in draft until restored. (#891, Copilot review)
910
+ throw new Error(
911
+ `draft_gate verdict was posted to ${options.repo}#${options.pr} (comment ${result.commentId ?? "?"}), ` +
912
+ `but restoring the PR to ready failed; it may be left in draft. Do not re-post the gate — re-run ` +
913
+ `\`gh pr ready ${options.pr}\` (or the dev-loop) to restore ready. Cause: ` +
914
+ `${restoreError instanceof Error ? restoreError.message : String(restoreError)}`,
915
+ );
916
+ }
917
+ process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after posting draft_gate evidence.\n`);
918
+ }
919
+ return { ...result, draftTransition: true };
920
+ }
921
+
452
922
  export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
453
923
  // Root cause 1: allow resurrected sessions to claim ownership when the previous
454
924
  // run's coordination record is stale. Without this, a new run ID is rejected even
@@ -517,14 +987,70 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
517
987
  }
518
988
  }
519
989
  const requestedGateAction = resolveGateAction(options.gate);
990
+ const prIsDraft = Boolean(coordinationContext.prData?.isDraft);
520
991
  if (options.gate === "draft_gate" && coordination.draftGateAlreadySatisfied) {
521
- throw new Error(
522
- `Cannot enter draft_gate on ${options.repo}#${options.pr}: draft gate was already satisfied ` +
523
- `(clean evidence exists, PR is no longer draft). ` +
524
- `Do not re-post draft_gate. The draft→ready transition was already recorded.`,
525
- );
992
+ // The draft gate is a one-time boundary: a non-draft PR with clean draft_gate
993
+ // evidence (on any head) has already passed it, and the pre-merge gate check
994
+ // accepts that evidence. Re-posting is therefore a no-op, not an error
995
+ // return idempotent success so scripted/automated callers are not dead-ended
996
+ // by a hard throw. (#891)
997
+ const satisfied = coordinationContext.gateEvidence?.draftGate ?? {};
998
+ // executionMode lives on the gate MARKER summary, not the COMMENT (strict)
999
+ // summary: the strict `draftGate` summary is parsed from the visible comment
1000
+ // body via normalizeGateSummary, which carries no executionMode field, so it
1001
+ // would always collapse to inline_single_agent — misleading when the satisfied
1002
+ // gate actually ran fanout_fanin. Prefer the marker's executionMode; if the
1003
+ // marker is unavailable, OMIT the field rather than report a misleading default.
1004
+ const satisfiedExecutionMode =
1005
+ coordinationContext.gateEvidence?.draftGateMarker?.executionMode
1006
+ ?? satisfied.executionMode
1007
+ ?? null;
1008
+ return {
1009
+ ok: true,
1010
+ action: "noop",
1011
+ reason: "draft_gate already satisfied (clean evidence exists; draft→ready boundary recorded)",
1012
+ repo: options.repo,
1013
+ pr: options.pr,
1014
+ gate: "draft_gate",
1015
+ // Report the head the existing clean evidence was recorded on (which may be a
1016
+ // stale head — the draft gate is a one-time boundary accepted on any head),
1017
+ // not the request's canonical head, so the field is not misleading.
1018
+ headSha: satisfied.headSha ?? canonicalHeadSha,
1019
+ currentHeadSha: evidence.currentHeadSha,
1020
+ draftGateAlreadySatisfied: true,
1021
+ // Mirror the field shape of the other success paths for consistent consumers.
1022
+ blockCleanOnFindingSeverities: draftGateConfig.blockCleanOnFindingSeverities,
1023
+ ...(satisfiedExecutionMode != null ? { executionMode: satisfiedExecutionMode } : {}),
1024
+ ...(satisfied.commentId != null ? { commentId: satisfied.commentId } : {}),
1025
+ ...(satisfied.commentUrl ? { commentUrl: satisfied.commentUrl } : {}),
1026
+ };
526
1027
  }
527
1028
  const gateActionForbidden = coordination.forbiddenActions.includes(requestedGateAction);
1029
+ // Draft gate can only be posted while the PR is a draft (RUN_DRAFT_GATE is
1030
+ // forbidden once the PR is ready). A PR opened directly as ready — or any ready
1031
+ // PR that still needs clean draft_gate evidence for the pre-merge check — would
1032
+ // otherwise dead-end: the poster refuses, yet the pre-merge gate check fails
1033
+ // closed on "missing visible clean draft_gate comment". Rather than force the
1034
+ // operator to manually toggle the PR back to draft, perform the
1035
+ // draft→post→ready transition here, preserving the caller's verdict, execution
1036
+ // mode (e.g. fanout_fanin), findings, and ledger. This is the fanout-aware
1037
+ // analogue of `reconcile-draft-gate` (which only posts inline and so cannot
1038
+ // satisfy requireFanoutEvidence on draft_gate). (#891)
1039
+ //
1040
+ // Trigger ONLY when coordination explicitly allows RECONCILE_DRAFT_GATE — i.e. the
1041
+ // state machine determined this ready PR genuinely needs draft-gate evidence
1042
+ // reconciled (a converged/merge-progression state with no clean draft evidence).
1043
+ // RUN_DRAFT_GATE is forbidden on a ready PR in many OTHER states too (merge
1044
+ // conflicts, waiting-for-CI, unresolved feedback, blocked); converting those to
1045
+ // draft would be wrong, so we must NOT key off `gateActionForbidden` alone. (#891)
1046
+ if (
1047
+ options.gate === "draft_gate"
1048
+ && !prIsDraft
1049
+ && !coordination.draftGateAlreadySatisfied
1050
+ && coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
1051
+ ) {
1052
+ return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
1053
+ }
528
1054
  if (gateActionForbidden) {
529
1055
  throw new Error(buildGateEntryRefusalError({ options, coordination }));
530
1056
  }
@@ -556,7 +1082,42 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
556
1082
  );
557
1083
  }
558
1084
  }
559
- if (options.findingsFile) {
1085
+ // Structured per-angle findings (consolidated fan-in shape) take precedence
1086
+ // over the free-text summary: when present, the verdict comment renders a
1087
+ // multi-line per-angle breakdown and the `**Findings summary:**` line carries a
1088
+ // single-line digest (so the marker/parse contract still round-trips).
1089
+ let structuredFindings = null;
1090
+ if (options.findingsJson) {
1091
+ let raw;
1092
+ try {
1093
+ raw = await readFile(options.findingsJson, "utf8");
1094
+ } catch (err) {
1095
+ throw new Error(`Cannot read --findings-json "${options.findingsJson}": ${err instanceof Error ? err.message : String(err)}`);
1096
+ }
1097
+ let parsed;
1098
+ try {
1099
+ parsed = JSON.parse(raw);
1100
+ } catch {
1101
+ throw new Error(`--findings-json "${options.findingsJson}" is not valid JSON`);
1102
+ }
1103
+ // Accept either a bare array of per-angle entries or an object wrapping it
1104
+ // under `angles` / `findings` (defensive against caller shape drift).
1105
+ const candidate = Array.isArray(parsed)
1106
+ ? parsed
1107
+ : (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
1108
+ try {
1109
+ structuredFindings = normalizeStructuredFindings(candidate);
1110
+ } catch (err) {
1111
+ throw new Error(`--findings-json "${options.findingsJson}": ${err instanceof Error ? err.message : String(err)}`);
1112
+ }
1113
+ if (!structuredFindings) {
1114
+ 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)`);
1115
+ }
1116
+ }
1117
+ // --findings-json takes precedence; when structured findings are present, do not
1118
+ // read --findings-file at all (avoids a spurious hard failure if a caller passes
1119
+ // both and the file is missing/invalid even though it would be ignored anyway).
1120
+ if (!structuredFindings && options.findingsFile) {
560
1121
  try {
561
1122
  const fileContent = await readFile(options.findingsFile, "utf8");
562
1123
  const trimmedEnd = fileContent.replace(/\n+$/, "");
@@ -571,24 +1132,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
571
1132
  throw new Error(`Cannot read --findings-file "${options.findingsFile}": ${err instanceof Error ? err.message : String(err)}`);
572
1133
  }
573
1134
  }
574
- const effectiveFindingsSummary = options.findingsFile
575
- ? options.findingsSummary
576
- : appendGateEvidenceNote(options.findingsSummary, coordination.gateEvidenceNote ?? null);
1135
+ // The findings-summary the comment is compared/round-tripped against. With a
1136
+ // structured render this is the single-line digest (what the marker parser
1137
+ // recovers from the `**Findings summary:**` line); otherwise the free-text path.
1138
+ const effectiveFindingsSummary = structuredFindings
1139
+ ? appendGateEvidenceNote(buildStructuredFindingsDigest(structuredFindings), coordination.gateEvidenceNote ?? null)
1140
+ : (options.findingsFile
1141
+ ? options.findingsSummary
1142
+ : appendGateEvidenceNote(options.findingsSummary, coordination.gateEvidenceNote ?? null));
577
1143
  const desiredBody = renderGateReviewCommentBody({
578
1144
  ...options,
579
1145
  headSha: canonicalHeadSha,
580
1146
  findingsSummary: effectiveFindingsSummary,
1147
+ structuredFindings,
1148
+ // In structured mode the renderer rebuilds the digest internally and appends
1149
+ // this note, so the rendered `**Findings summary:**` line matches
1150
+ // effectiveFindingsSummary (the value used for the noop/round-trip compare).
1151
+ gateEvidenceNote: coordination.gateEvidenceNote ?? null,
581
1152
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
582
1153
  });
583
1154
  const gateEvidence = selectGateEvidence(evidence, options.gate);
584
1155
  const existing = summarizeExistingComment({ ...gateEvidence, headSha: canonicalHeadSha });
585
1156
  const warning = detectStaleGateCommentWarning({ strict: gateEvidence.strict, headSha: canonicalHeadSha, gate: options.gate });
1157
+ const desiredExecutionMode = options.executionMode ?? DEFAULT_EXECUTION_MODE;
1158
+ // inlineReason is only meaningful for inline mode and is dropped for
1159
+ // fanout_fanin at parse time, so normalize both sides to null when the
1160
+ // resolved mode is not inline. This makes the noop short-circuit fire only
1161
+ // when verdict/summary/nextAction/executionMode AND the inline reason all
1162
+ // match, so a changed/added --inline-reason forces a comment update.
1163
+ const desiredInlineReason = desiredExecutionMode === "inline_single_agent"
1164
+ ? (options.inlineReason ?? null)
1165
+ : null;
1166
+ const existingInlineReason = (existing?.executionMode ?? DEFAULT_EXECUTION_MODE) === "inline_single_agent"
1167
+ ? (existing?.inlineReason ?? null)
1168
+ : null;
586
1169
  if (
587
1170
  existing
588
1171
  && existing.contractComplete
589
1172
  && existing.verdict === options.verdict
590
1173
  && existing.findingsSummary === effectiveFindingsSummary
591
1174
  && existing.nextAction === options.nextAction
1175
+ && (existing.executionMode ?? DEFAULT_EXECUTION_MODE) === desiredExecutionMode
1176
+ && existingInlineReason === desiredInlineReason
592
1177
  ) {
593
1178
  return {
594
1179
  ok: true,
@@ -601,6 +1186,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
601
1186
  commentId: existing.commentId,
602
1187
  commentUrl: existing.commentUrl,
603
1188
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1189
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1190
+ ...(existingInlineReason ? { inlineReason: existingInlineReason } : {}),
604
1191
  ...(warning ? { warning } : {}),
605
1192
  };
606
1193
  }
@@ -630,6 +1217,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
630
1217
  commentId: updated.commentId,
631
1218
  commentUrl: updated.commentUrl,
632
1219
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1220
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1221
+ ...(options.inlineReason ? { inlineReason: options.inlineReason } : {}),
633
1222
  ...(warning ? { warning } : {}),
634
1223
  ...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
635
1224
  };
@@ -665,10 +1254,20 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
665
1254
  commentId: created.commentId,
666
1255
  commentUrl: created.commentUrl,
667
1256
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1257
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1258
+ ...(options.inlineReason ? { inlineReason: options.inlineReason } : {}),
668
1259
  ...(warning ? { warning } : {}),
669
1260
  ...(verificationWarning ? { verificationWarning } : {}),
670
1261
  };
671
1262
  }
1263
+ export function buildInlineExecutionWarning(executionMode, inlineReason) {
1264
+ if ((executionMode ?? DEFAULT_EXECUTION_MODE) !== "inline_single_agent") {
1265
+ return null;
1266
+ }
1267
+ const reason = typeof inlineReason === "string" ? inlineReason.trim() : "";
1268
+ const base = "WARNING: gate ran inline_single_agent (not via the fan-out/fan-in review sub-loop).";
1269
+ return reason.length > 0 ? `${base} Reason: ${reason}` : base;
1270
+ }
672
1271
  async function main() {
673
1272
  let options;
674
1273
  try {
@@ -682,8 +1281,14 @@ async function main() {
682
1281
  process.stdout.write(`${USAGE}\n`);
683
1282
  return;
684
1283
  }
1284
+ const inlineWarning = buildInlineExecutionWarning(options.executionMode, options.inlineReason);
685
1285
  try {
686
1286
  const result = await upsertCheckpointVerdict(options);
1287
+ // Emit the inline-execution warning only on success so the JSON error
1288
+ // envelope on stderr stays clean and machine-parseable on failures.
1289
+ if (inlineWarning) {
1290
+ process.stderr.write(`${inlineWarning}\n`);
1291
+ }
687
1292
  process.stdout.write(`${JSON.stringify(result)}\n`);
688
1293
  } catch (error) {
689
1294
  process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);