dev-loops 0.2.7 → 0.4.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 (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -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/hooks/_run-context.mjs +9 -16
  7. package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
  8. package/.claude/skills/dev-loop/SKILL.md +6 -6
  9. package/.claude/skills/docs/anti-patterns.md +2 -0
  10. package/.claude/skills/docs/copilot-loop-operations.md +3 -3
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/merge-preconditions.md +65 -1
  13. package/.claude/skills/docs/stop-conditions.md +1 -0
  14. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  15. package/.claude/skills/local-implementation/SKILL.md +25 -5
  16. package/AGENTS.md +1 -1
  17. package/CHANGELOG.md +69 -0
  18. package/README.md +8 -2
  19. package/agents/developer.agent.md +1 -0
  20. package/agents/fixer.agent.md +1 -0
  21. package/agents/review.agent.md +30 -0
  22. package/cli/index.mjs +60 -8
  23. package/extension/README.md +1 -1
  24. package/package.json +3 -3
  25. package/scripts/README.md +8 -7
  26. package/scripts/_core-helpers.mjs +1 -0
  27. package/scripts/claude/headless-dev-loop.mjs +53 -13
  28. package/scripts/claude/headless-info-smoke.mjs +45 -11
  29. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  30. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  31. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  32. package/scripts/github/offer-human-handoff.mjs +147 -0
  33. package/scripts/github/post-gate-findings.mjs +392 -0
  34. package/scripts/github/probe-ci-status.mjs +468 -0
  35. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  36. package/scripts/github/request-copilot-review.mjs +72 -11
  37. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
  39. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  40. package/scripts/github/write-gate-context.mjs +634 -0
  41. package/scripts/github/write-gate-findings-log.mjs +1 -1
  42. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  43. package/scripts/loop/_worktree-path.mjs +27 -0
  44. package/scripts/loop/cleanup-worktree.mjs +175 -0
  45. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  46. package/scripts/loop/detect-change-scope.mjs +36 -11
  47. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  48. package/scripts/loop/detect-stale-runner.mjs +3 -4
  49. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  50. package/scripts/loop/ensure-worktree.mjs +219 -0
  51. package/scripts/loop/outer-loop.mjs +1 -1
  52. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  53. package/scripts/loop/pre-flight-gate.mjs +10 -7
  54. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  55. package/scripts/loop/provision-worktree.mjs +243 -0
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  57. package/scripts/loop/run-queue.mjs +112 -16
  58. package/scripts/loop/run-watch-cycle.mjs +75 -22
  59. package/scripts/projects/add-queue-item.mjs +80 -48
  60. package/scripts/projects/archive-done-items.mjs +136 -39
  61. package/scripts/projects/ensure-queue-board.mjs +67 -65
  62. package/scripts/projects/list-queue-items.mjs +59 -57
  63. package/scripts/projects/move-queue-item.mjs +125 -125
  64. package/scripts/projects/reorder-queue-item.mjs +67 -48
  65. package/scripts/projects/sync-item-status.mjs +199 -0
  66. package/skills/copilot-pr-followup/SKILL.md +62 -6
  67. package/skills/dev-loop/SKILL.md +2 -2
  68. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  69. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  70. package/skills/docs/anti-patterns.md +2 -0
  71. package/skills/docs/copilot-loop-operations.md +3 -3
  72. package/skills/docs/issue-intake-procedure.md +4 -0
  73. package/skills/docs/merge-preconditions.md +65 -1
  74. package/skills/docs/stop-conditions.md +1 -0
  75. package/skills/docs/tracker-first-loop-state.md +6 -6
  76. package/skills/local-implementation/SKILL.md +25 -5
@@ -15,13 +15,16 @@ import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
15
15
  import { detectInternalOnly } from "../loop/detect-internal-only-pr.mjs";
16
16
  const GATE_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
17
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";
18
20
  const MAX_GATE_COMMENT_TEXT_LENGTH = 2000;
19
21
  const MAX_GATE_COMMENT_EXCERPT_LENGTH = 120;
20
22
  const REMOVED_FLAGS = new Set([
21
23
  "--force",
22
24
  "--force-reason",
23
25
  ]);
24
- 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.
25
28
  Create or update the visible checkpoint verdict comment for a gate/head pair.
26
29
  Same-head reruns are idempotent: if a visible marker already exists for the same
27
30
  \`gate + headSha\`, this helper updates it in place when correction is needed and
@@ -40,6 +43,29 @@ Required:
40
43
  alternative to --findings-summary
41
44
  (preserves newlines; takes precedence
42
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.
43
69
  --next-action <text>
44
70
  Optional:
45
71
  --gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
@@ -49,6 +75,19 @@ Optional:
49
75
  (e.g. '{"must-fix":0,"worth-fixing-now":0}').
50
76
  Required for --verdict clean when
51
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.
52
91
  Output (stdout, JSON):
53
92
  {
54
93
  "ok": true,
@@ -87,6 +126,10 @@ function normalizeHeadSha(value) {
87
126
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
88
127
  return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
89
128
  }
129
+ function normalizeExecutionMode(value) {
130
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
131
+ return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
132
+ }
90
133
  function normalizeRequiredText(value, flag) {
91
134
  const normalized = typeof value === "string" ? value.trim() : "";
92
135
  if (normalized.length === 0) {
@@ -210,8 +253,11 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
210
253
  verdict: { type: "string" },
211
254
  "findings-summary": { type: "string" },
212
255
  "findings-file": { type: "string" },
256
+ "findings-json": { type: "string" },
213
257
  "next-action": { type: "string" },
214
258
  "findings-severity-counts": { type: "string" },
259
+ "execution-mode": { type: "string" },
260
+ "inline-reason": { type: "string" },
215
261
  },
216
262
  allowPositionals: true,
217
263
  strict: false,
@@ -226,8 +272,11 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
226
272
  verdict: undefined,
227
273
  findingsSummary: undefined,
228
274
  findingsFile: undefined,
275
+ findingsJson: undefined,
229
276
  nextAction: undefined,
230
277
  findingsSeverityCounts: undefined,
278
+ executionMode: undefined,
279
+ inlineReason: undefined,
231
280
  };
232
281
  for (const token of tokens) {
233
282
  if (token.kind === "positional") {
@@ -287,6 +336,14 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
287
336
  options.findingsFile = rawPath;
288
337
  continue;
289
338
  }
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;
345
+ continue;
346
+ }
290
347
  if (token.name === "next-action") {
291
348
  options.nextAction = normalizeRequiredText(requireTokenValue(token, parseError), "--next-action");
292
349
  continue;
@@ -312,16 +369,51 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
312
369
  options.findingsSeverityCounts = counts;
313
370
  continue;
314
371
  }
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
+ }
315
388
  throw parseError(`Unknown argument: ${token.rawName}`);
316
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;
396
+ }
317
397
  const missing = ["repo", "pr", "headSha", "verdict", "findingsSummary", "nextAction"]
318
398
  .filter((key) => options[key] === undefined);
319
- 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) {
320
402
  const fsIdx = missing.indexOf("findingsSummary");
321
403
  if (fsIdx !== -1) missing.splice(fsIdx, 1);
322
404
  }
323
405
  if (missing.length > 0) {
324
- 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
+ );
325
417
  }
326
418
  try {
327
419
  parseRepoSlug(options.repo);
@@ -344,20 +436,302 @@ function appendGateEvidenceNote(summary, note) {
344
436
  }
345
437
  return smartTruncate(`${normalizedSummary}; ${normalizedNote}`, MAX_GATE_COMMENT_TEXT_LENGTH);
346
438
  }
347
- 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 }) {
348
697
  const lines = [
349
698
  `### Gate review: \`${gate}\``,
350
699
  "",
351
700
  `**Reviewed head SHA:** \`${headSha}\``,
352
701
  `**Verdict:** ${verdict}`,
702
+ renderExecutionModeLine(executionMode, inlineReason),
353
703
  ];
354
704
  if ((verdict === "findings_present" || verdict === "blocked") && blockCleanOnFindingSeverities && blockCleanOnFindingSeverities.length > 0) {
355
705
  const sevs = blockCleanOnFindingSeverities.join(", ");
356
706
  lines.push(`**Blocking severities:** ${sevs} (clean requires no findings matching these severities)`);
357
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
+ }
358
734
  lines.push(
359
- "",
360
- `**Findings summary:** ${findingsSummary}`,
361
735
  "",
362
736
  `**Next action:** ${nextAction}`,
363
737
  );
@@ -403,6 +777,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
403
777
  verdict: markerSameHead.verdict,
404
778
  findingsSummary: markerSameHead.findingsSummary ?? null,
405
779
  nextAction: markerSameHead.nextAction ?? null,
780
+ executionMode: markerSameHead.executionMode ?? null,
781
+ inlineReason: markerSameHead.inlineReason ?? null,
406
782
  contractComplete: markerSameHead.contractComplete === true,
407
783
  };
408
784
  }
@@ -414,6 +790,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
414
790
  verdict: strictSameHead.verdict,
415
791
  findingsSummary: strictSameHead.findingsSummary,
416
792
  nextAction: strictSameHead.nextAction,
793
+ executionMode: strictSameHead.executionMode ?? markerSameHead?.executionMode ?? null,
794
+ inlineReason: strictSameHead.inlineReason ?? markerSameHead?.inlineReason ?? null,
417
795
  contractComplete: true,
418
796
  };
419
797
  }
@@ -425,6 +803,8 @@ function summarizeExistingComment({ strict, marker, headSha }) {
425
803
  verdict: markerSameHead.verdict,
426
804
  findingsSummary: markerSameHead.findingsSummary ?? null,
427
805
  nextAction: markerSameHead.nextAction ?? null,
806
+ executionMode: markerSameHead.executionMode ?? null,
807
+ inlineReason: markerSameHead.inlineReason ?? null,
428
808
  contractComplete: markerSameHead.contractComplete === true,
429
809
  };
430
810
  }
@@ -472,6 +852,73 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
472
852
  }
473
853
  }
474
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
+
475
922
  export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
476
923
  // Root cause 1: allow resurrected sessions to claim ownership when the previous
477
924
  // run's coordination record is stale. Without this, a new run ID is rejected even
@@ -540,14 +987,70 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
540
987
  }
541
988
  }
542
989
  const requestedGateAction = resolveGateAction(options.gate);
990
+ const prIsDraft = Boolean(coordinationContext.prData?.isDraft);
543
991
  if (options.gate === "draft_gate" && coordination.draftGateAlreadySatisfied) {
544
- throw new Error(
545
- `Cannot enter draft_gate on ${options.repo}#${options.pr}: draft gate was already satisfied ` +
546
- `(clean evidence exists, PR is no longer draft). ` +
547
- `Do not re-post draft_gate. The draft→ready transition was already recorded.`,
548
- );
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
+ };
549
1027
  }
550
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
+ }
551
1054
  if (gateActionForbidden) {
552
1055
  throw new Error(buildGateEntryRefusalError({ options, coordination }));
553
1056
  }
@@ -579,7 +1082,42 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
579
1082
  );
580
1083
  }
581
1084
  }
582
- 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) {
583
1121
  try {
584
1122
  const fileContent = await readFile(options.findingsFile, "utf8");
585
1123
  const trimmedEnd = fileContent.replace(/\n+$/, "");
@@ -594,24 +1132,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
594
1132
  throw new Error(`Cannot read --findings-file "${options.findingsFile}": ${err instanceof Error ? err.message : String(err)}`);
595
1133
  }
596
1134
  }
597
- const effectiveFindingsSummary = options.findingsFile
598
- ? options.findingsSummary
599
- : 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));
600
1143
  const desiredBody = renderGateReviewCommentBody({
601
1144
  ...options,
602
1145
  headSha: canonicalHeadSha,
603
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,
604
1152
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
605
1153
  });
606
1154
  const gateEvidence = selectGateEvidence(evidence, options.gate);
607
1155
  const existing = summarizeExistingComment({ ...gateEvidence, headSha: canonicalHeadSha });
608
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;
609
1169
  if (
610
1170
  existing
611
1171
  && existing.contractComplete
612
1172
  && existing.verdict === options.verdict
613
1173
  && existing.findingsSummary === effectiveFindingsSummary
614
1174
  && existing.nextAction === options.nextAction
1175
+ && (existing.executionMode ?? DEFAULT_EXECUTION_MODE) === desiredExecutionMode
1176
+ && existingInlineReason === desiredInlineReason
615
1177
  ) {
616
1178
  return {
617
1179
  ok: true,
@@ -624,13 +1186,15 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
624
1186
  commentId: existing.commentId,
625
1187
  commentUrl: existing.commentUrl,
626
1188
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1189
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1190
+ ...(existingInlineReason ? { inlineReason: existingInlineReason } : {}),
627
1191
  ...(warning ? { warning } : {}),
628
1192
  };
629
1193
  }
630
1194
  if (existing) {
631
1195
  const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
632
1196
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
633
- // A run id is set (production context) — DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias.
1197
+ // A run id is set (production context) — DEVLOOPS_RUN_ID.
634
1198
  let updateVerificationWarning = null;
635
1199
  if (envRunId) {
636
1200
  let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
@@ -653,6 +1217,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
653
1217
  commentId: updated.commentId,
654
1218
  commentUrl: updated.commentUrl,
655
1219
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1220
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1221
+ ...(options.inlineReason ? { inlineReason: options.inlineReason } : {}),
656
1222
  ...(warning ? { warning } : {}),
657
1223
  ...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
658
1224
  };
@@ -663,7 +1229,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
663
1229
  // comment is not yet returned by paginated list endpoints. A direct fetch
664
1230
  // by comment ID confirms the comment is persisted, preventing the evidence
665
1231
  // checker from falsely reporting "missing" and triggering a duplicate post.
666
- // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID or the PI_SUBAGENT_RUN_ID alias.
1232
+ // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID.
667
1233
  let verified = true;
668
1234
  let verificationWarning = null;
669
1235
  if (envRunId) {
@@ -688,10 +1254,20 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
688
1254
  commentId: created.commentId,
689
1255
  commentUrl: created.commentUrl,
690
1256
  blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
1257
+ executionMode: options.executionMode ?? DEFAULT_EXECUTION_MODE,
1258
+ ...(options.inlineReason ? { inlineReason: options.inlineReason } : {}),
691
1259
  ...(warning ? { warning } : {}),
692
1260
  ...(verificationWarning ? { verificationWarning } : {}),
693
1261
  };
694
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
+ }
695
1271
  async function main() {
696
1272
  let options;
697
1273
  try {
@@ -705,8 +1281,14 @@ async function main() {
705
1281
  process.stdout.write(`${USAGE}\n`);
706
1282
  return;
707
1283
  }
1284
+ const inlineWarning = buildInlineExecutionWarning(options.executionMode, options.inlineReason);
708
1285
  try {
709
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
+ }
710
1292
  process.stdout.write(`${JSON.stringify(result)}\n`);
711
1293
  } catch (error) {
712
1294
  process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);