dev-loops 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/skills/copilot-pr-followup/SKILL.md +57 -3
  7. package/.claude/skills/dev-loop/SKILL.md +5 -5
  8. package/.claude/skills/docs/anti-patterns.md +2 -0
  9. package/.claude/skills/docs/copilot-loop-operations.md +2 -2
  10. package/.claude/skills/local-implementation/SKILL.md +17 -3
  11. package/AGENTS.md +1 -1
  12. package/CHANGELOG.md +43 -0
  13. package/agents/developer.agent.md +1 -0
  14. package/agents/fixer.agent.md +1 -0
  15. package/agents/review.agent.md +30 -0
  16. package/cli/index.mjs +39 -6
  17. package/package.json +2 -2
  18. package/scripts/README.md +6 -5
  19. package/scripts/_core-helpers.mjs +1 -0
  20. package/scripts/claude/headless-dev-loop.mjs +53 -13
  21. package/scripts/claude/headless-info-smoke.mjs +45 -11
  22. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  23. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  24. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  25. package/scripts/github/post-gate-findings.mjs +392 -0
  26. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  27. package/scripts/github/request-copilot-review.mjs +69 -8
  28. package/scripts/github/upsert-checkpoint-verdict.mjs +597 -15
  29. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  30. package/scripts/github/write-gate-context.mjs +634 -0
  31. package/scripts/github/write-gate-findings-log.mjs +1 -1
  32. package/scripts/loop/detect-change-scope.mjs +36 -11
  33. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  34. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  35. package/scripts/loop/run-queue.mjs +87 -15
  36. package/scripts/projects/add-queue-item.mjs +60 -43
  37. package/scripts/projects/archive-done-items.mjs +135 -39
  38. package/scripts/projects/ensure-queue-board.mjs +65 -64
  39. package/scripts/projects/list-queue-items.mjs +57 -56
  40. package/scripts/projects/move-queue-item.mjs +123 -124
  41. package/scripts/projects/reorder-queue-item.mjs +62 -44
  42. package/scripts/projects/sync-item-status.mjs +198 -0
  43. package/skills/copilot-pr-followup/SKILL.md +57 -3
  44. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  45. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  46. package/skills/docs/anti-patterns.md +2 -0
  47. package/skills/docs/copilot-loop-operations.md +2 -2
  48. package/skills/local-implementation/SKILL.md +17 -3
@@ -2,17 +2,20 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
- const USAGE = `Usage: create-draft-pr.mjs [gh pr create args...]
6
- Thin wrapper around \`gh pr create\` that enforces draft-first PR creation.
5
+ const USAGE = `Usage: create-pr.mjs [gh pr create args...]
6
+ Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
7
+ tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
7
8
  Behavior:
8
- - injects exactly one \`--draft\` when absent
9
+ - injects exactly one \`--draft\` when absent (draft is the only mode)
10
+ - defaults \`--assignee @me\` when no assignee is given (self-assigned by default)
11
+ - honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
9
12
  - rejects \`--ready\` before invoking \`gh\`
10
13
  - detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
11
14
  - forwards every other argument to \`gh pr create\` unchanged
12
15
  - preserves the underlying \`gh pr create\` stdout, stderr, and exit code
13
16
  Examples:
14
- node scripts/github/create-draft-pr.mjs --repo owner/repo --assignee @me --base main --head feature --title "..." --body-file pr.md
15
- node <resolved-skill-scripts>/github/create-draft-pr.mjs --repo owner/repo --assignee @me --base main --head feature --title "..." --body-file pr.md
17
+ node scripts/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
18
+ node <resolved-skill-scripts>/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
16
19
  Notes:
17
20
  - Use \`gh pr ready\` later to leave draft state; this wrapper never opens a ready PR.
18
21
  - Wrapper-owned validation is limited to \`--ready\`; all other argument validation is left to \`gh pr create\`.
@@ -25,6 +28,12 @@ const parseError = buildParseError(USAGE);
25
28
  const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
26
29
  const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
27
30
  const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
31
+ // Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
32
+ // short flag that `gh pr create` documents, so an explicit assignee in either
33
+ // form suppresses the `--assignee @me` default (otherwise a caller passing
34
+ // `-a <login>` would get a conflicting `--assignee @me` injected). (#894)
35
+ const ASSIGNEE_FLAG_PATTERN = /^(?:--assignee(?:$|=)|-a$)/u;
36
+ const DEFAULT_ASSIGNEE = "@me";
28
37
  const CLOSING_KEYWORD_PATTERN = /Closes\s+#\d+|Fixes\s+#\d+/i;
29
38
  const MAX_BODY_SCAN_BYTES = 16 * 1024;
30
39
  export function detectClosingKeyword(body) {
@@ -52,12 +61,12 @@ async function warnMissingClosingKeyword(args) {
52
61
  if (body === null) return; // no --body or --body-file, skip
53
62
  if (!detectClosingKeyword(body)) {
54
63
  process.stderr.write(
55
- "[create-draft-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
64
+ "[create-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
56
65
  "GitHub will not auto-close the linked issue on merge.\n",
57
66
  );
58
67
  }
59
68
  }
60
- export function buildCreateDraftPrArgs(argv) {
69
+ export function buildCreatePrArgs(argv) {
61
70
  const args = [...argv];
62
71
  if (args.includes("--help") || args.includes("-h")) {
63
72
  return {
@@ -66,17 +75,24 @@ export function buildCreateDraftPrArgs(argv) {
66
75
  };
67
76
  }
68
77
  if (args.some((token) => READY_FLAG_PATTERN.test(token))) {
69
- throw parseError("create-draft-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
78
+ throw parseError("create-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
70
79
  }
71
80
  const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
72
81
  const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
73
82
  const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
83
+ const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
74
84
  return {
75
85
  help: false,
76
- ghArgs: ["pr", "create", ...args, ...(lastDraftSuppliesDraft ? [] : ["--draft"])],
86
+ ghArgs: [
87
+ "pr",
88
+ "create",
89
+ ...args,
90
+ ...(hasAssignee ? [] : ["--assignee", DEFAULT_ASSIGNEE]),
91
+ ...(lastDraftSuppliesDraft ? [] : ["--draft"]),
92
+ ],
77
93
  };
78
94
  }
79
- export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
95
+ export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
80
96
  return new Promise((resolve, reject) => {
81
97
  const child = spawn(ghCommand, ghArgs, {
82
98
  env,
@@ -89,13 +105,13 @@ export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env
89
105
  });
90
106
  }
91
107
  export async function main(argv = process.argv.slice(2), runtime = {}) {
92
- const { help, ghArgs } = buildCreateDraftPrArgs(argv);
108
+ const { help, ghArgs } = buildCreatePrArgs(argv);
93
109
  if (help) {
94
110
  process.stdout.write(`${USAGE}\n`);
95
111
  return 0;
96
112
  }
97
113
  await warnMissingClosingKeyword(argv);
98
- return spawnCreateDraftPr(ghArgs, runtime);
114
+ return spawnCreatePr(ghArgs, runtime);
99
115
  }
100
116
  if (isDirectCliRun(import.meta.url)) {
101
117
  try {
@@ -9,9 +9,13 @@ import {
9
9
  summarizeGateReviewCommentMarkers,
10
10
  summarizeGateReviewComments,
11
11
  } from "../_core-helpers.mjs";
12
+ import { access } from "node:fs/promises";
13
+ import path from "node:path";
12
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
13
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
14
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
+ import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
18
+ import { buildLogPath } from "./write-gate-findings-log.mjs";
15
19
  import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
16
20
  import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
17
21
  const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
@@ -200,6 +204,8 @@ function emptyGateMarkerSummary() {
200
204
  verdict: null,
201
205
  findingsSummary: null,
202
206
  nextAction: null,
207
+ executionMode: null,
208
+ inlineReason: null,
203
209
  contractComplete: false,
204
210
  commentId: null,
205
211
  commentUrl: null,
@@ -216,13 +222,15 @@ function normalizeGateMarkerSummary(summary) {
216
222
  verdict: summary.verdict,
217
223
  findingsSummary: summary.findingsSummary,
218
224
  nextAction: summary.nextAction,
225
+ executionMode: summary.executionMode ?? null,
226
+ inlineReason: summary.inlineReason ?? null,
219
227
  contractComplete: summary.contractComplete === true,
220
228
  commentId: summary.commentId,
221
229
  commentUrl: summary.commentUrl,
222
230
  updatedAt: summary.updatedAt,
223
231
  };
224
232
  }
225
- export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null) {
233
+ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
226
234
  const failures = [];
227
235
  if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
228
236
  failures.push("missing visible clean draft_gate comment");
@@ -236,6 +244,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
236
244
  )) {
237
245
  failures.push("missing visible clean current-head pre_approval_gate comment");
238
246
  }
247
+ // Fail-closed fan-out evidence enforcement (gates.requireFanoutEvidence, ON by
248
+ // default / opt-out). When disabled or config-unavailable, fanoutEnforcement is
249
+ // { required: false, gates: [] } so the `.required` guard skips this block.
250
+ if (fanoutEnforcement && fanoutEnforcement.required) {
251
+ for (const gate of fanoutEnforcement.gates) {
252
+ if (gate.executionMode !== "fanout_fanin") {
253
+ failures.push(
254
+ `${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
255
+ );
256
+ continue;
257
+ }
258
+ if (!gate.ledgerExists) {
259
+ failures.push(
260
+ `${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
261
+ );
262
+ }
263
+ }
264
+ }
239
265
  if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
240
266
  if (unresolvedThreadCount === -1) {
241
267
  failures.push("could not fetch review thread state from GitHub API; re-run gate evidence check when API connectivity is restored");
@@ -253,6 +279,52 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
253
279
  failures,
254
280
  };
255
281
  }
282
+ async function ledgerExists(fullPath) {
283
+ try {
284
+ await access(fullPath);
285
+ return true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+ /**
291
+ * Build the fan-out evidence enforcement descriptor.
292
+ *
293
+ * Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
294
+ * Returns { required: false } when enforcement is disabled OR when config is
295
+ * unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
296
+ * fail open and never enable enforcement. When enabled, records per-required-gate
297
+ * executionMode and whether the deterministic findings-log ledger exists for the
298
+ * reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
299
+ * missing ledgers.
300
+ */
301
+ async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
302
+ // Fail open when config could not be loaded/validated. `== null` covers both
303
+ // null and undefined; the loader only ever yields null on failure, but the
304
+ // loose check defensively treats an absent config as unavailable.
305
+ if (config == null || !resolveRequireFanoutEvidence(config)) {
306
+ return { required: false, gates: [] };
307
+ }
308
+ const draftRequired = resolveGateConfig(config, "draft").required;
309
+ const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
310
+ const gateSpecs = [
311
+ { name: "draft_gate", marker: draftGateMarker, required: draftRequired },
312
+ { name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
313
+ ].filter((spec) => spec.required && spec.marker.visible);
314
+ const gates = [];
315
+ for (const spec of gateSpecs) {
316
+ const headSha = spec.marker.headSha ?? currentHeadSha;
317
+ const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
318
+ const fullPath = path.resolve(cwd, ledgerPath);
319
+ gates.push({
320
+ name: spec.name,
321
+ executionMode: spec.marker.executionMode ?? null,
322
+ ledgerPath,
323
+ ledgerExists: await ledgerExists(fullPath),
324
+ });
325
+ }
326
+ return { required: true, gates };
327
+ }
256
328
  export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
257
329
  const runnerOwnership = await ensureAsyncRunnerOwnership({
258
330
  repo: options.repo,
@@ -299,6 +371,24 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
299
371
  const allComments = [...commentsPayload, ...prReviews];
300
372
  const commentSummary = summarizeGateReviewComments(allComments);
301
373
  const markerSummary = summarizeGateReviewCommentMarkers(allComments, { headSha: currentHeadSha });
374
+ const draftGateMarker = normalizeGateMarkerSummary(markerSummary.draft_gate);
375
+ const preApprovalGateMarker = normalizeGateMarkerSummary(markerSummary.pre_approval_gate);
376
+ // loadDevLoopConfig never throws: it returns { config, warnings, errors }.
377
+ // A non-empty errors array means the config could not be loaded/validated, so
378
+ // treat it as config-unavailable and leave fan-out enforcement disabled
379
+ // (preserves default behavior). Other gate checks remain unaffected.
380
+ let config = null;
381
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
382
+ config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
383
+ const fanoutEnforcement = await buildFanoutEnforcement({
384
+ repo: options.repo,
385
+ pr: options.pr,
386
+ currentHeadSha,
387
+ draftGateMarker,
388
+ preApprovalGateMarker,
389
+ config,
390
+ cwd,
391
+ });
302
392
  return {
303
393
  ok: true,
304
394
  repo: options.repo,
@@ -306,9 +396,10 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
306
396
  currentHeadSha,
307
397
  draftGate: normalizeGateSummary(commentSummary.draft_gate),
308
398
  preApprovalGate: normalizeGateSummary(commentSummary.pre_approval_gate),
309
- draftGateMarker: normalizeGateMarkerSummary(markerSummary.draft_gate),
310
- preApprovalGateMarker: normalizeGateMarkerSummary(markerSummary.pre_approval_gate),
399
+ draftGateMarker,
400
+ preApprovalGateMarker,
311
401
  draftGateSatisfied: commentSummary.draft_gate?.verdict === "clean" && typeof commentSummary.draft_gate?.headSha === "string",
402
+ fanoutEnforcement,
312
403
  ...(runnerOwnership.status !== "skipped_no_async_run_id" ? { runnerOwnership } : {}),
313
404
  staleRunner: {
314
405
  status: staleRunnerDetection.status,
@@ -351,7 +442,7 @@ async function main() {
351
442
  ? [`exit signal recorded for run ${result.staleRunner.activeRun?.runId}: refuse to merge`]
352
443
  : [],
353
444
  };
354
- const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck);
445
+ const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck, result.fanoutEnforcement);
355
446
  const output = { ...result, preMergeGateCheck, staleRunnerCheck };
356
447
  if (!preMergeGateCheck.ok) {
357
448
  process.stderr.write(`${JSON.stringify({
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
+ import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+
7
+ const USAGE = `Usage: post-gate-findings.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --findings <json>
8
+ Post (or idempotently update) a visible, marker-tagged PR issue comment that lists the
9
+ consolidated gate fan-out findings, grouped by severity. The comment is idempotent
10
+ per gate: there is exactly one comment per gate, updated in place on each run
11
+ (the reviewed head is shown in the body) instead of duplicating it.
12
+
13
+ The disposition ledger (write-gate-findings-log.mjs) is the durable source of truth and is
14
+ written regardless of this comment. This helper only posts the auditable PR summary, and
15
+ no-ops when gates.postFindingsComments is set to false in config.
16
+
17
+ Required:
18
+ --repo <owner/name>
19
+ --pr <number>
20
+ --gate <draft_gate|pre_approval_gate>
21
+ --head-sha <sha> Current head SHA or hexadecimal prefix
22
+ --findings <json> JSON array of findings in the findings-log shape
23
+ ([{severity, angle, summary, disposition?, files?}])
24
+ Output (stdout, JSON):
25
+ { "ok": true, "action": "created"|"updated"|"noop"|"skipped", ... }
26
+ Exit codes:
27
+ 0 Success
28
+ 1 Argument error or gh failure`.trim();
29
+
30
+ const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
31
+ // Severity ordering for grouped rendering (most-blocking first).
32
+ const SEVERITY_ORDER = ["must-fix", "worth-fixing-now", "defer"];
33
+ const SEVERITY_LABELS = {
34
+ "must-fix": "Must fix",
35
+ "worth-fixing-now": "Worth fixing now",
36
+ "defer": "Defer",
37
+ };
38
+
39
+ function parseError(message) {
40
+ return Object.assign(new Error(message), { usage: USAGE });
41
+ }
42
+
43
+ function normalizeGate(value) {
44
+ const gates = new Set(["draft_gate", "pre_approval_gate"]);
45
+ const normalized = String(value).trim().toLowerCase();
46
+ return gates.has(normalized) ? normalized : null;
47
+ }
48
+
49
+ function normalizeHeadSha(value) {
50
+ const normalized = String(value).trim().toLowerCase();
51
+ return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
52
+ }
53
+
54
+ // Validate via the centralized repo-slug validator shared by sibling GitHub
55
+ // scripts (parseRepoSlug). It enforces owner/name structure and rejects unsafe
56
+ // segments (".", "..", slashes, whitespace); we re-throw as a parseError so the
57
+ // CLI usage banner is preserved.
58
+ function validateRepo(repo) {
59
+ try {
60
+ parseRepoSlug(repo);
61
+ } catch (error) {
62
+ throw parseError(error instanceof Error ? error.message : String(error));
63
+ }
64
+ return repo;
65
+ }
66
+
67
+ export function parseFindings(raw) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch {
72
+ throw parseError("--findings must be valid JSON");
73
+ }
74
+ if (!Array.isArray(parsed)) {
75
+ throw parseError("--findings must be a JSON array");
76
+ }
77
+ return parsed.map((f, i) => {
78
+ if (!f || typeof f !== "object") {
79
+ throw parseError(`--findings[${i}] must be an object`);
80
+ }
81
+ if (!f.severity || !VALID_SEVERITIES.has(f.severity)) {
82
+ throw parseError(`--findings[${i}].severity must be one of: must-fix, worth-fixing-now, defer`);
83
+ }
84
+ if (!f.angle || typeof f.angle !== "string" || f.angle.trim().length === 0) {
85
+ throw parseError(`--findings[${i}].angle is required`);
86
+ }
87
+ if (!f.summary || typeof f.summary !== "string" || f.summary.trim().length === 0) {
88
+ throw parseError(`--findings[${i}].summary is required`);
89
+ }
90
+ const entry = {
91
+ severity: f.severity,
92
+ angle: f.angle.trim(),
93
+ summary: f.summary.trim(),
94
+ };
95
+ if ("disposition" in f && typeof f.disposition === "string" && f.disposition.trim().length > 0) {
96
+ entry.disposition = f.disposition.trim();
97
+ }
98
+ if (Array.isArray(f.files)) {
99
+ entry.files = f.files.filter(x => typeof x === "string" && x.trim().length > 0).map(x => x.trim());
100
+ }
101
+ return entry;
102
+ });
103
+ }
104
+
105
+ export function parsePostGateFindingsCliArgs(argv) {
106
+ const args = [...argv];
107
+ const options = {
108
+ help: false,
109
+ repo: undefined,
110
+ pr: undefined,
111
+ gate: undefined,
112
+ headSha: undefined,
113
+ findings: undefined,
114
+ };
115
+ while (args.length > 0) {
116
+ const token = args.shift();
117
+ if (token === "--help" || token === "-h") {
118
+ options.help = true;
119
+ return options;
120
+ }
121
+ if (token === "--repo") {
122
+ options.repo = validateRepo(requireOptionValue(args, "--repo", parseError).trim());
123
+ continue;
124
+ }
125
+ if (token === "--pr") {
126
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
127
+ continue;
128
+ }
129
+ if (token === "--gate") {
130
+ const gate = normalizeGate(requireOptionValue(args, "--gate", parseError));
131
+ if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
132
+ options.gate = gate;
133
+ continue;
134
+ }
135
+ if (token === "--head-sha") {
136
+ const sha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
137
+ if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
138
+ options.headSha = sha;
139
+ continue;
140
+ }
141
+ if (token === "--findings") {
142
+ options.findings = requireOptionValue(args, "--findings", parseError);
143
+ continue;
144
+ }
145
+ throw parseError(`Unknown argument: ${token}`);
146
+ }
147
+ const missing = ["repo", "pr", "gate", "headSha", "findings"]
148
+ .filter(k => options[k] === undefined);
149
+ if (missing.length > 0) {
150
+ throw parseError(`Missing required arguments: ${missing.join(", ")}`);
151
+ }
152
+ return options;
153
+ }
154
+
155
+ // Hidden marker keyed by GATE ONLY. There is exactly one findings comment per
156
+ // gate, updated in place each run. The marker deliberately does NOT include the
157
+ // head SHA: --head-sha accepts any 7-64 hex prefix, so keying on its literal
158
+ // value would let a different prefix length (or the full SHA) for the same head
159
+ // miss the marker and post a duplicate. The reviewed head is still shown in the
160
+ // comment body for context. The HTML comment is not rendered by GitHub but is
161
+ // matched on the comment body.
162
+ export function buildFindingsMarker({ gate }) {
163
+ return `<!-- dev-loops:gate-findings gate=${gate} -->`;
164
+ }
165
+
166
+ // Collapse any run of whitespace (newlines, tabs, repeated spaces) to a single
167
+ // space and trim. LLM-generated free text often carries embedded newlines, which
168
+ // would otherwise break a single Markdown list item across lines.
169
+ //
170
+ // Additionally neutralize any embedded HTML-comment delimiters (`<!--` / `-->`).
171
+ // The findings comment is keyed by a hidden marker that IS an HTML comment
172
+ // (buildFindingsMarker), and free text comes from scoped-review agents. Without
173
+ // this, a finding field could inject a second `<!-- dev-loops:gate-findings ... -->`
174
+ // marker (breaking idempotent comment matching) or otherwise smuggle an HTML
175
+ // comment into the rendered body. We escape the opening/closing angle brackets so
176
+ // the delimiter renders as visible literal text and cannot form a real comment.
177
+ function sanitizeInline(value) {
178
+ return String(value)
179
+ .replace(/\s+/g, " ")
180
+ .replace(/<!--/g, "&lt;!--")
181
+ .replace(/-->/g, "--&gt;")
182
+ .trim();
183
+ }
184
+
185
+ // Sanitize free text that is rendered INSIDE an inline backtick code span
186
+ // (`angle`, file refs). On top of sanitizeInline, strip any literal backtick:
187
+ // a backtick inside the span would prematurely close it, breaking out into raw
188
+ // Markdown (injection) for the remainder of the list item. Backticks are never
189
+ // meaningful in an angle label or a file path, so dropping them is safe.
190
+ function sanitizeCodeSpan(value) {
191
+ return sanitizeInline(String(value).replace(/`/g, ""));
192
+ }
193
+
194
+ export function renderFindingsCommentBody({ gate, headSha, findings }) {
195
+ const marker = buildFindingsMarker({ gate });
196
+ const lines = [
197
+ marker,
198
+ `### Gate fan-out findings: ${gate}`,
199
+ "",
200
+ // Plain text head SHA (no backticks) so GitHub autolinks the commit.
201
+ `Reviewed head: ${headSha}`,
202
+ "",
203
+ ];
204
+ if (findings.length === 0) {
205
+ lines.push("No findings. All review angles passed for this head.");
206
+ return lines.join("\n");
207
+ }
208
+ const grouped = new Map();
209
+ for (const sev of SEVERITY_ORDER) {
210
+ grouped.set(sev, []);
211
+ }
212
+ for (const finding of findings) {
213
+ grouped.get(finding.severity).push(finding);
214
+ }
215
+ for (const sev of SEVERITY_ORDER) {
216
+ const group = grouped.get(sev);
217
+ if (group.length === 0) continue;
218
+ lines.push(`#### ${SEVERITY_LABELS[sev]} (${group.length})`);
219
+ for (const finding of group) {
220
+ // Sanitize free-text fields so embedded newlines/whitespace don't break
221
+ // the single-line Markdown list item.
222
+ const summary = sanitizeInline(finding.summary);
223
+ const dispositionSuffix = finding.disposition ? ` — _${sanitizeInline(finding.disposition)}_` : "";
224
+ // angle is a code/label literal → backticks; summary is prose. angle is
225
+ // free text from a scoped-review agent and is rendered inside an inline
226
+ // code span, so it must be sanitized too: an embedded backtick or newline
227
+ // would otherwise break the code span (markdown injection) or split the
228
+ // list item. Use sanitizeCodeSpan (backtick-stripping) since it lives
229
+ // inside backticks, consistent with the file refs below.
230
+ const angle = sanitizeCodeSpan(finding.angle);
231
+ lines.push(`- \`${angle}\`: ${summary}${dispositionSuffix}`);
232
+ if (Array.isArray(finding.files) && finding.files.length > 0) {
233
+ // File refs go inside backticks; sanitize each so embedded whitespace,
234
+ // newlines, or backticks can't break the single Markdown list item /
235
+ // code span, and drop any that sanitize to empty.
236
+ const refs = finding.files
237
+ .map(f => sanitizeCodeSpan(f))
238
+ .filter(f => f.length > 0)
239
+ .map(f => `\`${f}\``)
240
+ .join(", ");
241
+ if (refs.length > 0) {
242
+ lines.push(` - files: ${refs}`);
243
+ }
244
+ }
245
+ }
246
+ lines.push("");
247
+ }
248
+ // Drop trailing blank line.
249
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
250
+ lines.pop();
251
+ }
252
+ return lines.join("\n");
253
+ }
254
+
255
+ async function runGhJson(args, { env, ghCommand }) {
256
+ const result = await runChild(ghCommand, args, env);
257
+ if (result.code !== 0) {
258
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
259
+ throw new Error(`gh command failed: ${detail}`);
260
+ }
261
+ return parseJsonText(result.stdout, { label: `gh ${args.slice(0, 3).join(" ")}` });
262
+ }
263
+
264
+ async function listIssueComments({ repo, pr }, { env, ghCommand }) {
265
+ const payload = await runGhJson(
266
+ ["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`],
267
+ { env, ghCommand },
268
+ );
269
+ // --slurp returns an array of pages; flatten to a single comment list.
270
+ if (Array.isArray(payload) && payload.every(p => Array.isArray(p))) {
271
+ return payload.flat();
272
+ }
273
+ return Array.isArray(payload) ? payload : [];
274
+ }
275
+
276
+ function findMarkedComment(comments, marker) {
277
+ for (const comment of comments) {
278
+ if (comment && typeof comment.body === "string" && comment.body.includes(marker)) {
279
+ return comment;
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+
285
+ function parseCommentMutationResponse(payload) {
286
+ const commentId = Number.isInteger(payload?.id) ? payload.id : null;
287
+ const commentUrl = typeof payload?.html_url === "string" && payload.html_url.trim().length > 0
288
+ ? payload.html_url.trim()
289
+ : null;
290
+ if (commentId === null || commentUrl === null) {
291
+ throw new Error("Gate findings comment mutation did not return a comment id and html_url");
292
+ }
293
+ return { commentId, commentUrl };
294
+ }
295
+
296
+ async function createComment({ repo, pr, body }, { env, ghCommand }) {
297
+ const payload = await runGhJson(
298
+ ["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`],
299
+ { env, ghCommand },
300
+ );
301
+ return parseCommentMutationResponse(payload);
302
+ }
303
+
304
+ async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
305
+ const payload = await runGhJson(
306
+ ["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`],
307
+ { env, ghCommand },
308
+ );
309
+ return parseCommentMutationResponse(payload);
310
+ }
311
+
312
+ export async function postGateFindings(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
313
+ const findings = parseFindings(options.findings);
314
+ // loadDevLoopConfig never throws: it returns { config, warnings, errors }.
315
+ // A non-empty errors array means the config could not be loaded/validated, so
316
+ // log it (stderr) and fall back to default behavior (config-unavailable →
317
+ // null → resolveGatePostFindingsComments defaults on → proceed to post),
318
+ // rather than trusting a malformed/partial config object. Mirrors how
319
+ // detect-checkpoint-evidence treats config-unavailable.
320
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot });
321
+ let config = loadedConfig;
322
+ if (Array.isArray(configErrors) && configErrors.length > 0) {
323
+ process.stderr.write(
324
+ `post-gate-findings: dev-loop config could not be loaded/validated; using default behavior. errors=${JSON.stringify(configErrors)}\n`,
325
+ );
326
+ config = null;
327
+ }
328
+ if (!resolveGatePostFindingsComments(config)) {
329
+ return {
330
+ ok: true,
331
+ action: "skipped",
332
+ reason: "gates.postFindingsComments is false",
333
+ repo: options.repo,
334
+ pr: options.pr,
335
+ gate: options.gate,
336
+ headSha: options.headSha,
337
+ findingsCount: findings.length,
338
+ };
339
+ }
340
+ const marker = buildFindingsMarker({ gate: options.gate });
341
+ const desiredBody = renderFindingsCommentBody({ gate: options.gate, headSha: options.headSha, findings });
342
+ const comments = await listIssueComments({ repo: options.repo, pr: options.pr }, { env, ghCommand });
343
+ const existing = findMarkedComment(comments, marker);
344
+ const base = {
345
+ ok: true,
346
+ repo: options.repo,
347
+ pr: options.pr,
348
+ gate: options.gate,
349
+ headSha: options.headSha,
350
+ findingsCount: findings.length,
351
+ };
352
+ if (existing) {
353
+ if (typeof existing.body === "string" && existing.body === desiredBody) {
354
+ return {
355
+ ...base,
356
+ action: "noop",
357
+ commentId: Number.isInteger(existing.id) ? existing.id : null,
358
+ commentUrl: typeof existing.html_url === "string" ? existing.html_url : null,
359
+ };
360
+ }
361
+ const updated = await updateComment({ repo: options.repo, commentId: existing.id, body: desiredBody }, { env, ghCommand });
362
+ return { ...base, action: "updated", ...updated };
363
+ }
364
+ const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
365
+ return { ...base, action: "created", ...created };
366
+ }
367
+
368
+ async function main() {
369
+ let options;
370
+ try {
371
+ options = parsePostGateFindingsCliArgs(process.argv.slice(2));
372
+ } catch (error) {
373
+ process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
374
+ process.exitCode = 1;
375
+ return;
376
+ }
377
+ if (options.help) {
378
+ process.stdout.write(`${USAGE}\n`);
379
+ return;
380
+ }
381
+ try {
382
+ const result = await postGateFindings(options);
383
+ process.stdout.write(`${JSON.stringify(result)}\n`);
384
+ } catch (error) {
385
+ process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
386
+ process.exitCode = 1;
387
+ }
388
+ }
389
+
390
+ if (isDirectCliRun(import.meta.url)) {
391
+ await main();
392
+ }
@@ -124,7 +124,7 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
124
124
  }
125
125
  return { id: prData.id, isDraft: prData.isDraft };
126
126
  }
127
- async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
127
+ export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
128
128
  const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
129
129
  if (resolvedPr.isDraft === true) {
130
130
  return {
@@ -154,7 +154,7 @@ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
154
154
  alreadyDraft: false,
155
155
  };
156
156
  }
157
- async function markPrReady({ repo, pr }, { env, ghCommand }) {
157
+ export async function markPrReady({ repo, pr }, { env, ghCommand }) {
158
158
  const result = await runChild(ghCommand, [
159
159
  "pr", "ready", String(pr),
160
160
  "--repo", repo,