dev-loops 0.3.0 → 0.5.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 (85) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. package/skills/local-implementation/SKILL.md +8 -2
@@ -3,6 +3,7 @@ import { setTimeout as delay } from "node:timers/promises";
3
3
  import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, parseJsonText, parseReviewThreads } from "../_core-helpers.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
  import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
8
  import {
8
9
  DEFAULT_POLL_INTERVAL_MS,
@@ -24,6 +25,10 @@ Required:
24
25
  Output (stdout, JSON):
25
26
  { "ok": true, "status": "changed"|"timeout"|"idle", "repo": "...", "pr": N, "attempts": N,
26
27
  "newComments": [...], "newReviews": [...], "newIssueComments": [...] }
28
+ Concise mode:
29
+ --concise, --summary Human-readable summary: status, attempts, new-activity
30
+ counts, AND the current round's new Copilot comment bodies.
31
+ ${JQ_OUTPUT_USAGE}
27
32
  Activity statuses:
28
33
  changed Fresh Copilot review activity found (check newComments/newReviews/newIssueComments)
29
34
  timeout Watch period elapsed with no fresh Copilot activity
@@ -91,7 +96,14 @@ function rejectRemovedFlag(token) {
91
96
  export function parseWatchCliArgs(argv) {
92
97
  const { tokens } = parseArgs({
93
98
  args: [...argv],
94
- options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, pr: { type: "string" } },
99
+ options: {
100
+ help: { type: "boolean", short: "h" },
101
+ repo: { type: "string" },
102
+ pr: { type: "string" },
103
+ concise: { type: "boolean" },
104
+ summary: { type: "boolean" },
105
+ ...JQ_OUTPUT_PARSE_OPTIONS,
106
+ },
95
107
  allowPositionals: true,
96
108
  strict: false,
97
109
  tokens: true,
@@ -102,6 +114,9 @@ export function parseWatchCliArgs(argv) {
102
114
  pr: undefined,
103
115
  pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
104
116
  timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
117
+ concise: false,
118
+ jq: undefined,
119
+ silent: false,
105
120
  };
106
121
  for (const token of tokens) {
107
122
  if (token.kind === "positional") {
@@ -125,6 +140,18 @@ export function parseWatchCliArgs(argv) {
125
140
  options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
126
141
  continue;
127
142
  }
143
+ if (token.name === "concise" || token.name === "summary") {
144
+ options.concise = true;
145
+ continue;
146
+ }
147
+ if (token.name === "jq") {
148
+ options.jq = requireTokenValue(token, parseError);
149
+ continue;
150
+ }
151
+ if (token.name === "silent") {
152
+ options.silent = true;
153
+ continue;
154
+ }
128
155
  throw parseError(`Unknown argument: ${token.rawName}`);
129
156
  }
130
157
  if (options.repo === undefined || options.pr === undefined) {
@@ -311,6 +338,37 @@ export function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, at
311
338
  const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, attempt * pollIntervalMs);
312
339
  return Math.max(0, scheduledAtMs - nowMs);
313
340
  }
341
+ // Human-readable concise summary of a probe result, including the current
342
+ // round's new Copilot comment bodies (the field `loop info --pr` omits).
343
+ export function formatProbeConcise(result) {
344
+ const newComments = result.newComments ?? [];
345
+ const newReviews = result.newReviews ?? [];
346
+ const newIssueComments = result.newIssueComments ?? [];
347
+ const lines = [
348
+ `Copilot probe: PR #${result.pr} (${result.repo})`,
349
+ ` status: ${result.status}`,
350
+ ` attempts: ${result.attempts}`,
351
+ ` new threadComments: ${newComments.length}`,
352
+ ` new reviews: ${newReviews.length}`,
353
+ ` new issueComments: ${newIssueComments.length}`,
354
+ ];
355
+ const bodies = [
356
+ ...newReviews.map((r) => ({ kind: "review", body: r.body })),
357
+ ...newComments.map((c) => ({ kind: "threadComment", body: c.body })),
358
+ ...newIssueComments.map((c) => ({ kind: "issueComment", body: c.body })),
359
+ ].filter((entry) => typeof entry.body === "string" && entry.body.trim().length > 0);
360
+ if (bodies.length > 0) {
361
+ lines.push(" new Copilot comment bodies this round:");
362
+ for (const entry of bodies) {
363
+ const indented = entry.body.trim().split("\n").map((l) => ` ${l}`).join("\n");
364
+ lines.push(` [${entry.kind}]`);
365
+ lines.push(indented);
366
+ }
367
+ } else {
368
+ lines.push(" new Copilot comment bodies this round: (none)");
369
+ }
370
+ return lines.join("\n");
371
+ }
314
372
  export async function runCli(
315
373
  argv = process.argv.slice(2),
316
374
  {
@@ -325,10 +383,18 @@ export async function runCli(
325
383
  return;
326
384
  }
327
385
  const result = await watchCopilotReview(options, { env, ghCommand });
328
- stdout.write(`${JSON.stringify(result)}\n`);
386
+ if (options.concise && options.jq === undefined && !options.silent) {
387
+ stdout.write(`${formatProbeConcise(result)}\n`);
388
+ return result.ok === false ? 1 : 0;
389
+ }
390
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
329
391
  }
330
392
  if (isDirectCliRun(import.meta.url)) {
331
- runCli().catch((error) => {
393
+ runCli().then((code) => {
394
+ if (typeof code === "number") {
395
+ process.exitCode = code;
396
+ }
397
+ }).catch((error) => {
332
398
  process.stderr.write(`${formatCliError(error)}\n`);
333
399
  process.exitCode = 1;
334
400
  });
@@ -30,7 +30,7 @@ Optional:
30
30
  the last Copilot review. Refused when the PR head
31
31
  has not changed since the last review.
32
32
  Debug:
33
- PI_DEV_LOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
33
+ DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
34
34
  convergence detection falls back to unsuppressed behavior
35
35
  Output (stdout, JSON):
36
36
  { "ok": true, "status": "requested"|"already-requested"|"unavailable"|"suppressed_same_head_clean"|"blocked_by_copilot_comment"|"round_cap_reached"|"no_changes_since_last_review"|"suppressed_draft",
@@ -262,7 +262,7 @@ async function detectSameHeadCleanConvergence(options, runtime, priorReviewState
262
262
  const interpretation = interpretLoopState(snapshot);
263
263
  return interpretation.sameHeadCleanConverged;
264
264
  } catch (error) {
265
- if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
265
+ if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
266
266
  const detail = error instanceof Error ? error.message : String(error);
267
267
  process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
268
268
  }
@@ -302,7 +302,7 @@ async function detectRoundCapAutoRerequestEligibility(options, runtime, priorRev
302
302
  interpretation,
303
303
  };
304
304
  } catch (error) {
305
- if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
305
+ if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
306
306
  const detail = error instanceof Error ? error.message : String(error);
307
307
  process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
308
308
  }
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env node
2
+ import { readFile as fsReadFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
+ import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import {
9
+ loadDevLoopConfig,
10
+ resolveHumanHandoffConfig,
11
+ } from "@dev-loops/core/config";
12
+
13
+ const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
14
+ Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
15
+ for a PR, from the sources configured under \`approval.humanHandoff\` (#920).
16
+ This is the "offer" side: it surfaces candidates only — it never assigns anyone.
17
+
18
+ Sources (in priority order):
19
+ assignees Static configured list (approval.humanHandoff.assignees).
20
+ codeowners .github/CODEOWNERS / CODEOWNERS / docs/CODEOWNERS matched
21
+ against the PR's changed paths (last-match-wins per
22
+ CODEOWNERS semantics). Team handles (@org/team) are
23
+ included, flagged with isTeam:true.
24
+ recent-committers git log authors on the PR's changed paths (recent commits),
25
+ by GitHub login where derivable from the commit email,
26
+ excluding the PR author and bots.
27
+
28
+ Which sources run is controlled by approval.humanHandoff.candidatesFrom.
29
+ Disabled (default) => no-op: ok:true with an empty candidate list.
30
+
31
+ Required:
32
+ --repo <owner/name>
33
+ --pr <number>
34
+ Optional:
35
+ --changed-files <csv> Comma-separated changed paths. When omitted, derived
36
+ from \`gh pr view --json files\`.
37
+ --pr-author <login> PR author login (excluded from recent-committers).
38
+ When omitted, derived from \`gh pr view --json author\`.
39
+ Output (stdout, JSON):
40
+ { "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
41
+ "changedFiles": [...], "sources": [...], "warnings": [...] }
42
+ Exit codes:
43
+ 0 Success (including disabled no-op and fail-soft per-source skips)
44
+ 1 Argument error`.trim();
45
+
46
+ const parseError = buildParseError(USAGE);
47
+
48
+ function nextValue(args, i, flag) {
49
+ const value = args[i];
50
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
51
+ throw parseError(`Missing value for ${flag}`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ const RECENT_COMMITTERS_LIMIT = 50;
57
+ const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
58
+ // Bot author handles to exclude from recent-committers.
59
+ const BOT_PATTERN = /\[bot\]$|^(?:dependabot|github-actions|copilot|renovate)$/i;
60
+
61
+ export function parseResolveCandidatesCliArgs(argv) {
62
+ const args = [...argv];
63
+ if (args.includes("--help") || args.includes("-h")) {
64
+ return { help: true };
65
+ }
66
+ const options = { help: false, repo: undefined, pr: undefined, changedFiles: undefined, prAuthor: undefined };
67
+ for (let i = 0; i < args.length; i += 1) {
68
+ const token = args[i];
69
+ if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
70
+ if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
71
+ if (token === "--changed-files") {
72
+ options.changedFiles = nextValue(args, ++i, "--changed-files")
73
+ .split(",").map((s) => s.trim()).filter((s) => s.length > 0);
74
+ continue;
75
+ }
76
+ if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
77
+ throw parseError(`Unknown argument: ${token}`);
78
+ }
79
+ if (options.repo === undefined || options.pr === undefined) {
80
+ throw parseError("Resolving handoff candidates requires both --repo <owner/name> and --pr <number>");
81
+ }
82
+ try {
83
+ parseRepoSlug(options.repo);
84
+ } catch (error) {
85
+ throw parseError(error instanceof Error ? error.message : String(error));
86
+ }
87
+ return options;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // CODEOWNERS — parse + match
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Parse CODEOWNERS content into ordered { pattern, owners } rules.
96
+ * Comments (#...) and blank lines are skipped. Owners keep their leading `@`.
97
+ * @param {string} content
98
+ * @returns {{ pattern: string, owners: string[] }[]}
99
+ */
100
+ export function parseCodeowners(content) {
101
+ const rules = [];
102
+ for (const rawLine of String(content).split("\n")) {
103
+ // A `#` starts a comment only at line start (after leading whitespace) or
104
+ // when preceded by whitespace; a mid-token `#` is part of the pattern/owner
105
+ // (gitignore/CODEOWNERS semantics).
106
+ const line = rawLine.replace(/(^|\s)#.*$/, "").trim();
107
+ if (line === "") continue;
108
+ const [pattern, ...owners] = line.split(/\s+/);
109
+ if (!pattern) continue;
110
+ rules.push({ pattern, owners: owners.filter((o) => o.length > 0) });
111
+ }
112
+ return rules;
113
+ }
114
+
115
+ /**
116
+ * Match a CODEOWNERS pattern against a repo-relative path (gitignore-ish).
117
+ * Leading `/` anchors to repo root; trailing `/` matches a directory subtree;
118
+ * `*` matches within a path segment, `**` across segments; a bare pattern with
119
+ * no slash matches the path's basename or any segment.
120
+ * @param {string} pattern
121
+ * @param {string} filePath
122
+ * @returns {boolean}
123
+ */
124
+ export function codeownersMatch(pattern, filePath) {
125
+ const file = filePath.replace(/^\.?\//, "");
126
+ let pat = pattern;
127
+ const dirOnly = pat.endsWith("/");
128
+ if (dirOnly) pat = pat.slice(0, -1);
129
+ // Bare name (no slash, not anchored): match any path segment subtree.
130
+ const anchored = pat.startsWith("/");
131
+ if (anchored) pat = pat.slice(1);
132
+
133
+ const toRegex = (p) => {
134
+ let re = "";
135
+ for (let i = 0; i < p.length; i += 1) {
136
+ const c = p[i];
137
+ if (c === "*") {
138
+ if (p[i + 1] === "*") { re += ".*"; i += 1; }
139
+ else re += "[^/]*";
140
+ } else if ("\\^$.|?+()[]{}".includes(c)) {
141
+ re += `\\${c}`;
142
+ } else {
143
+ re += c;
144
+ }
145
+ }
146
+ return re;
147
+ };
148
+
149
+ // Bare-vs-anchored is decided on the NORMALIZED pattern: a trailing-only `/`
150
+ // (e.g. `build/`) normalizes to a bare token with no leading/internal slash,
151
+ // so it matches at any depth like gitignore. Only a leading or internal slash
152
+ // anchors the match.
153
+ if (!anchored && !pat.includes("/")) {
154
+ // Bare token: match a full segment anywhere, plus its subtree.
155
+ const seg = toRegex(pat);
156
+ return new RegExp(`(?:^|/)${seg}(?:/|$)`).test(file);
157
+ }
158
+
159
+ const body = toRegex(pat);
160
+ // Anchored (or contains slash): match from root, allow subtree under it.
161
+ const re = new RegExp(`^${body}(?:/|$)`);
162
+ if (re.test(file)) return true;
163
+ // Directory pattern also matches the directory's whole subtree.
164
+ if (dirOnly) return new RegExp(`^${body}/`).test(file);
165
+ return false;
166
+ }
167
+
168
+ /**
169
+ * Resolve CODEOWNERS owners for a set of changed paths. Last-match-wins per
170
+ * CODEOWNERS semantics: the final matching rule for each path wins.
171
+ * @param {{ pattern: string, owners: string[] }[]} rules
172
+ * @param {string[]} changedFiles
173
+ * @returns {Map<string, Set<string>>} owner login (no `@`) -> set of paths
174
+ */
175
+ export function ownersForPaths(rules, changedFiles) {
176
+ /** @type {Map<string, Set<string>>} */
177
+ const byOwner = new Map();
178
+ for (const file of changedFiles) {
179
+ let winner = null;
180
+ for (const rule of rules) {
181
+ if (codeownersMatch(rule.pattern, file)) winner = rule;
182
+ }
183
+ if (!winner || winner.owners.length === 0) continue;
184
+ for (const owner of winner.owners) {
185
+ const login = owner.replace(/^@/, "");
186
+ if (login === "") continue;
187
+ if (!byOwner.has(login)) byOwner.set(login, new Set());
188
+ byOwner.get(login).add(file);
189
+ }
190
+ }
191
+ return byOwner;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Source resolvers (each fail-soft: returns candidates + a warning on error)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ async function resolveCodeownersSource(changedFiles, { repoRoot, readFile }) {
199
+ const warnings = [];
200
+ for (const rel of CODEOWNERS_PATHS) {
201
+ let content;
202
+ try {
203
+ content = await readFile(path.join(repoRoot, rel), "utf8");
204
+ } catch (error) {
205
+ // ENOENT => genuinely absent at this location: try the next path quietly.
206
+ // Any other error (EACCES / IO) is real: surface it rather than silently
207
+ // claiming "no CODEOWNERS file found".
208
+ if (error && error.code === "ENOENT") continue;
209
+ warnings.push(`codeowners: failed to read ${rel}: ${error instanceof Error ? error.message : String(error)}`);
210
+ continue;
211
+ }
212
+ const rules = parseCodeowners(content);
213
+ const byOwner = ownersForPaths(rules, changedFiles);
214
+ return {
215
+ candidates: [...byOwner.entries()].map(([login, paths]) => ({
216
+ login,
217
+ source: "codeowners",
218
+ isTeam: login.includes("/"),
219
+ paths: [...paths],
220
+ })),
221
+ warnings,
222
+ };
223
+ }
224
+ // Fail-soft: no CODEOWNERS file anywhere -> no candidates, no abort. Keep any
225
+ // non-ENOENT read warnings so a permissions/IO failure is not masked.
226
+ return { candidates: [], warnings: [...warnings, "codeowners: no CODEOWNERS file found"] };
227
+ }
228
+
229
+ /**
230
+ * Map a git author email to a GitHub login. `<login>@users.noreply.github.com`
231
+ * (optionally `<id>+<login>@...`) carries the login directly; otherwise fall
232
+ * back to the local-part of the email as a best-effort handle.
233
+ */
234
+ export function loginFromCommitEmail(email) {
235
+ const e = String(email).trim().toLowerCase();
236
+ const noreply = e.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
237
+ if (noreply) return noreply[1];
238
+ const at = e.indexOf("@");
239
+ return at > 0 ? e.slice(0, at) : null;
240
+ }
241
+
242
+ async function resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run }) {
243
+ if (changedFiles.length === 0) {
244
+ return { candidates: [], warnings: [] };
245
+ }
246
+ let stdout;
247
+ try {
248
+ const result = await run("git", [
249
+ "-C", repoRoot,
250
+ "log", `-n${RECENT_COMMITTERS_LIMIT}`, "--no-merges", "--pretty=format:%ae",
251
+ "--", ...changedFiles,
252
+ ]);
253
+ if (result.code !== 0) {
254
+ return { candidates: [], warnings: [`recent-committers: git log exited ${result.code}`] };
255
+ }
256
+ stdout = result.stdout;
257
+ } catch (error) {
258
+ return { candidates: [], warnings: [`recent-committers: ${error instanceof Error ? error.message : String(error)}`] };
259
+ }
260
+ const author = prAuthor ? prAuthor.toLowerCase() : null;
261
+ const seen = new Set();
262
+ const candidates = [];
263
+ for (const line of stdout.split("\n")) {
264
+ const email = line.trim();
265
+ if (email === "") continue;
266
+ const login = loginFromCommitEmail(email);
267
+ if (!login) continue;
268
+ const key = login.toLowerCase();
269
+ if (seen.has(key)) continue;
270
+ if (BOT_PATTERN.test(login)) continue;
271
+ if (author && key === author) continue;
272
+ seen.add(key);
273
+ candidates.push({ login, source: "recent-committers" });
274
+ }
275
+ return { candidates, warnings: [] };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // gh lookups for changed files / author (only when not supplied)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ async function fetchPrFacts(repo, pr, { run, ghCommand }) {
283
+ const result = await run(ghCommand, [
284
+ "pr", "view", String(pr), "--repo", repo, "--json", "files,author",
285
+ ]);
286
+ if (result.code !== 0) {
287
+ throw new Error(result.stderr.trim() || `gh pr view exited ${result.code}`);
288
+ }
289
+ const payload = JSON.parse(result.stdout);
290
+ const files = Array.isArray(payload?.files)
291
+ ? payload.files.map((f) => (typeof f?.path === "string" ? f.path : "")).filter((p) => p.length > 0)
292
+ : [];
293
+ const author = typeof payload?.author?.login === "string" ? payload.author.login : null;
294
+ return { files, author };
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Orchestration
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Resolve handoff candidates. Pure-ish: all IO is injected.
303
+ * @param {{ repo: string, pr: number, changedFiles?: string[], prAuthor?: string|null }} input
304
+ * @param {object} deps
305
+ * @returns {Promise<object>}
306
+ */
307
+ export async function resolveHandoffCandidates(input, deps = {}) {
308
+ const {
309
+ config,
310
+ repoRoot = process.cwd(),
311
+ ghCommand = "gh",
312
+ run = (cmd, args) => runChild(cmd, args),
313
+ readFile = fsReadFile,
314
+ } = deps;
315
+
316
+ const handoff = resolveHumanHandoffConfig(config);
317
+ const warnings = [];
318
+
319
+ if (!handoff.enabled) {
320
+ return {
321
+ ok: true,
322
+ enabled: false,
323
+ candidates: [],
324
+ changedFiles: input.changedFiles ?? [],
325
+ sources: [],
326
+ warnings: [],
327
+ };
328
+ }
329
+
330
+ let changedFiles = input.changedFiles;
331
+ let prAuthor = input.prAuthor ?? null;
332
+ if (changedFiles === undefined || prAuthor === null) {
333
+ try {
334
+ const facts = await fetchPrFacts(input.repo, input.pr, { run, ghCommand });
335
+ if (changedFiles === undefined) changedFiles = facts.files;
336
+ if (prAuthor === null) prAuthor = facts.author;
337
+ } catch (error) {
338
+ warnings.push(`pr-facts: ${error instanceof Error ? error.message : String(error)}`);
339
+ if (changedFiles === undefined) changedFiles = [];
340
+ }
341
+ }
342
+
343
+ const ordered = [];
344
+ // 1. Static assignees — highest priority.
345
+ for (const login of handoff.assignees) {
346
+ ordered.push({ login: login.replace(/^@/, ""), source: "assignees", isTeam: login.includes("/") });
347
+ }
348
+
349
+ // 2/3. Other sources, in the CANONICAL priority order
350
+ // (assignees > codeowners > recent-committers), regardless of the order they
351
+ // appear in candidatesFrom. candidatesFrom only selects WHICH sources run,
352
+ // not their relative rank.
353
+ const enabledSources = new Set(handoff.candidatesFrom);
354
+ if (enabledSources.has("codeowners")) {
355
+ const result = await resolveCodeownersSource(changedFiles, { repoRoot, readFile });
356
+ ordered.push(...result.candidates);
357
+ warnings.push(...result.warnings);
358
+ }
359
+ if (enabledSources.has("recent-committers")) {
360
+ const result = await resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run });
361
+ ordered.push(...result.candidates);
362
+ warnings.push(...result.warnings);
363
+ }
364
+
365
+ // Dedup by login (case-insensitive), first occurrence wins (preserves the
366
+ // assignees > codeowners > recent-committers priority).
367
+ const seen = new Set();
368
+ const candidates = [];
369
+ for (const c of ordered) {
370
+ const key = c.login.toLowerCase();
371
+ if (seen.has(key)) continue;
372
+ seen.add(key);
373
+ candidates.push(c);
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ enabled: true,
379
+ candidates,
380
+ changedFiles,
381
+ sources: handoff.candidatesFrom,
382
+ warnings,
383
+ };
384
+ }
385
+
386
+ export async function main(argv = process.argv.slice(2), deps = {}) {
387
+ let options;
388
+ try {
389
+ options = parseResolveCandidatesCliArgs(argv);
390
+ } catch (error) {
391
+ process.stderr.write(`${formatCliError(error)}\n`);
392
+ return 1;
393
+ }
394
+ if (options.help) {
395
+ process.stdout.write(`${USAGE}\n`);
396
+ return 0;
397
+ }
398
+ const repoRoot = deps.repoRoot ?? process.cwd();
399
+ const { config } = deps.config !== undefined
400
+ ? { config: deps.config }
401
+ : await loadDevLoopConfig({ repoRoot });
402
+ const result = await resolveHandoffCandidates(
403
+ { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
404
+ { ...deps, config, repoRoot },
405
+ );
406
+ process.stdout.write(`${JSON.stringify(result)}\n`);
407
+ return 0;
408
+ }
409
+
410
+ if (isDirectCliRun(import.meta.url)) {
411
+ main().then((code) => { process.exitCode = code; });
412
+ }
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
4
  import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
5
5
  import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
7
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
7
8
  import { truncateText } from "@dev-loops/core/bash-exit-one";
8
9
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
@@ -105,9 +106,11 @@ exists on a different head SHA (the old comment is stale for the current head).
105
106
  Error output (stderr, JSON):
106
107
  { "ok": false, "error": "...", "usage": "..." }
107
108
  { "ok": false, "error": "..." }
109
+ ${JQ_OUTPUT_USAGE}
108
110
  Exit codes:
109
111
  0 Success
110
- 1 Argument error, gh failure, or contradictory gate evidence`.trim();
112
+ 1 Argument error, gh failure, or contradictory gate evidence
113
+ 2 Invalid --jq filter`.trim();
111
114
  const parseError = buildParseError(USAGE);
112
115
  function rejectRemovedFlag(token) {
113
116
  throw parseError(
@@ -258,6 +261,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
258
261
  "findings-severity-counts": { type: "string" },
259
262
  "execution-mode": { type: "string" },
260
263
  "inline-reason": { type: "string" },
264
+ ...JQ_OUTPUT_PARSE_OPTIONS,
261
265
  },
262
266
  allowPositionals: true,
263
267
  strict: false,
@@ -277,6 +281,8 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
277
281
  findingsSeverityCounts: undefined,
278
282
  executionMode: undefined,
279
283
  inlineReason: undefined,
284
+ jq: undefined,
285
+ silent: false,
280
286
  };
281
287
  for (const token of tokens) {
282
288
  if (token.kind === "positional") {
@@ -385,6 +391,14 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
385
391
  options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
386
392
  continue;
387
393
  }
394
+ if (token.name === "jq") {
395
+ options.jq = requireTokenValue(token, parseError);
396
+ continue;
397
+ }
398
+ if (token.name === "silent") {
399
+ options.silent = true;
400
+ continue;
401
+ }
388
402
  throw parseError(`Unknown argument: ${token.rawName}`);
389
403
  }
390
404
  // Default execution mode to inline_single_agent when omitted. inlineReason is
@@ -1194,7 +1208,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1194
1208
  if (existing) {
1195
1209
  const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
1196
1210
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
1197
- // A run id is set (production context) — DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias.
1211
+ // A run id is set (production context) — DEVLOOPS_RUN_ID.
1198
1212
  let updateVerificationWarning = null;
1199
1213
  if (envRunId) {
1200
1214
  let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
@@ -1229,7 +1243,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1229
1243
  // comment is not yet returned by paginated list endpoints. A direct fetch
1230
1244
  // by comment ID confirms the comment is persisted, preventing the evidence
1231
1245
  // checker from falsely reporting "missing" and triggering a duplicate post.
1232
- // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID or the PI_SUBAGENT_RUN_ID alias.
1246
+ // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID.
1233
1247
  let verified = true;
1234
1248
  let verificationWarning = null;
1235
1249
  if (envRunId) {
@@ -1286,10 +1300,11 @@ async function main() {
1286
1300
  const result = await upsertCheckpointVerdict(options);
1287
1301
  // Emit the inline-execution warning only on success so the JSON error
1288
1302
  // envelope on stderr stays clean and machine-parseable on failures.
1289
- if (inlineWarning) {
1303
+ // Suppress it under --silent, which contracts to zero output (exit code only).
1304
+ if (inlineWarning && !options.silent) {
1290
1305
  process.stderr.write(`${inlineWarning}\n`);
1291
1306
  }
1292
- process.stdout.write(`${JSON.stringify(result)}\n`);
1307
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
1293
1308
  } catch (error) {
1294
1309
  process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
1295
1310
  process.exitCode = 1;