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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- 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
|
-
|
|
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
|
-
|
|
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, "<!--")
|
|
448
|
+
.replace(/-->/gu, "-->")
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
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
|
|
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`);
|