dev-loops 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/commands/auto.md +7 -0
  5. package/.claude/commands/continue.md +15 -0
  6. package/.claude/commands/info.md +7 -0
  7. package/.claude/commands/start-spike.md +16 -0
  8. package/.claude/commands/start.md +7 -0
  9. package/.claude/commands/status.md +6 -0
  10. package/.claude/hooks/_run-context.mjs +11 -4
  11. package/.claude/skills/dev-loop/SKILL.md +21 -6
  12. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  13. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  14. package/.claude/skills/docs/local-planning-flow.md +63 -0
  15. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  16. package/.claude/skills/docs/merge-preconditions.md +35 -0
  17. package/.claude/skills/docs/plan-file-contract.md +37 -0
  18. package/.claude/skills/docs/release-runbook.md +45 -0
  19. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  20. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  21. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  22. package/.claude/skills/local-implementation/SKILL.md +1 -1
  23. package/CHANGELOG.md +73 -0
  24. package/README.md +21 -1
  25. package/agents/dev-loop.agent.md +8 -1
  26. package/agents/refiner.agent.md +1 -0
  27. package/cli/index.mjs +2 -0
  28. package/extension/index.ts +10 -1
  29. package/extension/presentation.ts +15 -0
  30. package/lib/dev-loops-core.mjs +141 -0
  31. package/package.json +8 -3
  32. package/scripts/claude/generate-claude-assets.mjs +15 -1
  33. package/scripts/github/capture-review-threads.mjs +20 -2
  34. package/scripts/github/comment-issue.mjs +181 -0
  35. package/scripts/github/fetch-ci-logs.mjs +215 -0
  36. package/scripts/github/list-issues.mjs +191 -0
  37. package/scripts/github/probe-copilot-review.mjs +69 -3
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  39. package/scripts/lib/jq-output.mjs +297 -0
  40. package/scripts/loop/_handoff-contract.mjs +1 -0
  41. package/scripts/loop/check-retro-tooling.mjs +246 -0
  42. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  43. package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
  44. package/scripts/loop/docs-grill-contract.mjs +70 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  47. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  48. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  49. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  50. package/scripts/loop/run-watch-cycle.mjs +77 -3
  51. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  52. package/scripts/pages/build-site.mjs +136 -0
  53. package/scripts/projects/add-queue-item.mjs +12 -2
  54. package/scripts/projects/list-queue-items.mjs +12 -2
  55. package/scripts/projects/move-queue-item.mjs +12 -2
  56. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  57. package/scripts/refine/_refine-helpers.mjs +20 -0
  58. package/scripts/refine/exit-spike.mjs +186 -0
  59. package/scripts/refine/promote-plan.mjs +387 -0
  60. package/scripts/refine/refine-plan-file.mjs +165 -0
  61. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  62. package/scripts/refine/validate-plan-file.mjs +64 -0
  63. package/scripts/refine/validate-spike-file.mjs +87 -0
  64. package/scripts/release/extract-changelog-section.mjs +111 -0
  65. package/skills/dev-loop/SKILL.md +24 -2
  66. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  67. package/skills/docs/artifact-authority-contract.md +86 -31
  68. package/skills/docs/local-planning-flow.md +63 -0
  69. package/skills/docs/local-planning-worked-example.md +139 -0
  70. package/skills/docs/merge-preconditions.md +35 -0
  71. package/skills/docs/plan-file-contract.md +37 -0
  72. package/skills/docs/release-runbook.md +45 -0
  73. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  74. package/skills/docs/spike-mode-contract.md +237 -0
  75. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  76. package/skills/local-implementation/SKILL.md +1 -1
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+
8
+ const DEFAULT_TAIL_LINES = 200;
9
+
10
+ const USAGE = `Usage: fetch-ci-logs.mjs --repo <owner/name> --pr <number> [--failed-only] [--tail <n>]
11
+ Fetch the GitHub Actions CI run log tail for a PR's current head SHA. Thin wrapper
12
+ over \`gh run list\` + \`gh run view --log\` — use this instead of an agent-level raw
13
+ \`gh run view\` so the loop's internal-tooling record stays clean (#993). Complements
14
+ probe-ci-status.mjs, which reports failed-check NAMES; this returns the LOG tail.
15
+ (Siblings: list-issues.mjs, comment-issue.mjs.)
16
+ Required:
17
+ --repo <owner/name> Repository slug (e.g. owner/repo)
18
+ --pr <number> Pull request number
19
+ Optional:
20
+ --failed-only Only include runs whose conclusion is failure
21
+ (default: all Actions runs for the head SHA)
22
+ --tail <n> Lines of log tail to return per run (default 200)
23
+ Output (stdout, JSON):
24
+ { "ok": true, "repo": "owner/repo", "pr": 17, "headSha": "abc123",
25
+ "runs": [{ "runId": 42, "name": "ci", "conclusion": "failure", "logTail": "..." }] }
26
+ Notes:
27
+ Actions-only (gh run is GitHub Actions). CircleCI / external commit-status logs
28
+ are NOT covered — use the provider's UI for those (probe-ci-status names the check).
29
+ Error output (stderr, JSON):
30
+ { "ok": false, "error": "...", "usage"?: "..." }
31
+ ${JQ_OUTPUT_USAGE}
32
+ Exit codes:
33
+ 0 Success
34
+ 1 Argument error or gh failure
35
+ 2 Invalid --jq filter`.trim();
36
+ const parseError = buildParseError(USAGE);
37
+
38
+ export function parseFetchCiLogsCliArgs(argv) {
39
+ const { tokens } = parseArgs({
40
+ args: [...argv],
41
+ options: {
42
+ help: { type: "boolean", short: "h" },
43
+ repo: { type: "string" },
44
+ pr: { type: "string" },
45
+ "failed-only": { type: "boolean" },
46
+ tail: { type: "string" },
47
+ ...JQ_OUTPUT_PARSE_OPTIONS,
48
+ },
49
+ allowPositionals: true,
50
+ strict: false,
51
+ tokens: true,
52
+ });
53
+ const options = {
54
+ help: false,
55
+ repo: undefined,
56
+ pr: undefined,
57
+ failedOnly: false,
58
+ tail: DEFAULT_TAIL_LINES,
59
+ jq: undefined,
60
+ silent: false,
61
+ };
62
+ for (const token of tokens) {
63
+ if (token.kind === "positional") {
64
+ throw parseError(`Unknown argument: ${token.value}`);
65
+ }
66
+ if (token.kind !== "option") {
67
+ continue;
68
+ }
69
+ if (token.name === "help") {
70
+ options.help = true;
71
+ return options;
72
+ }
73
+ if (token.name === "repo") {
74
+ options.repo = requireTokenValue(token, parseError).trim();
75
+ continue;
76
+ }
77
+ if (token.name === "pr") {
78
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
79
+ continue;
80
+ }
81
+ if (token.name === "failed-only") {
82
+ options.failedOnly = true;
83
+ continue;
84
+ }
85
+ if (token.name === "tail") {
86
+ const raw = requireTokenValue(token, parseError);
87
+ const value = Number(raw);
88
+ if (!Number.isInteger(value) || value < 1) {
89
+ throw parseError(`--tail must be a positive integer, got "${raw}"`);
90
+ }
91
+ options.tail = value;
92
+ continue;
93
+ }
94
+ if (token.name === "jq") {
95
+ options.jq = requireTokenValue(token, parseError);
96
+ continue;
97
+ }
98
+ if (token.name === "silent") {
99
+ options.silent = true;
100
+ continue;
101
+ }
102
+ throw parseError(`Unknown argument: ${token.rawName}`);
103
+ }
104
+ if (options.repo === undefined || options.pr === undefined) {
105
+ throw parseError("Fetching CI logs requires both --repo <owner/name> and --pr <number>");
106
+ }
107
+ try {
108
+ parseRepoSlug(options.repo);
109
+ } catch (error) {
110
+ throw parseError(error instanceof Error ? error.message : String(error));
111
+ }
112
+ return options;
113
+ }
114
+
115
+ async function ghJson(run, ghCommand, args, env, label) {
116
+ const result = await run(ghCommand, args, env);
117
+ if (result.code !== 0) {
118
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
119
+ throw new Error(`${label} failed: ${detail}`);
120
+ }
121
+ return parseJsonText(result.stdout, { label });
122
+ }
123
+
124
+ function tailLines(text, n) {
125
+ const lines = String(text).replace(/\r?\n$/u, "").split(/\r?\n/u);
126
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
127
+ }
128
+
129
+ export async function fetchCiLogs(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
130
+ // 1. Resolve the PR's current head SHA — logs must be scoped to the head being
131
+ // evaluated, not a stale push.
132
+ const pr = await ghJson(
133
+ run,
134
+ ghCommand,
135
+ ["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"],
136
+ env,
137
+ "gh pr view",
138
+ );
139
+ const headSha = typeof pr.headRefOid === "string" ? pr.headRefOid.trim() : "";
140
+ if (headSha.length === 0) {
141
+ throw new Error("gh pr view did not return headRefOid");
142
+ }
143
+
144
+ // 2. List Actions runs for that exact commit.
145
+ const runs = await ghJson(
146
+ run,
147
+ ghCommand,
148
+ ["run", "list", "--repo", options.repo, "--commit", headSha, "--json", "databaseId,name,conclusion,status"],
149
+ env,
150
+ "gh run list",
151
+ );
152
+ if (!Array.isArray(runs)) {
153
+ throw new Error("gh run list did not return a JSON array");
154
+ }
155
+ const selected = options.failedOnly
156
+ ? runs.filter((r) => String(r?.conclusion).toLowerCase() === "failure")
157
+ : runs;
158
+
159
+ // 3. Fetch each run's log tail. --log-failed restricts to failed steps when the
160
+ // run failed; for non-failed runs it returns nothing, so fall back to --log.
161
+ const out = [];
162
+ for (const r of selected) {
163
+ const runId = Number.isInteger(r?.databaseId) ? r.databaseId : null;
164
+ if (runId === null) continue;
165
+ const conclusion = typeof r?.conclusion === "string" ? r.conclusion.toLowerCase() : null;
166
+ const logFlag = conclusion === "failure" ? "--log-failed" : "--log";
167
+ const logResult = await run(
168
+ ghCommand,
169
+ ["run", "view", String(runId), "--repo", options.repo, logFlag],
170
+ env,
171
+ );
172
+ // A log fetch failure for one run shouldn't abort the others (logs expire);
173
+ // record an empty tail with a note rather than throwing.
174
+ const logTail =
175
+ logResult.code === 0
176
+ ? tailLines(logResult.stdout, options.tail)
177
+ : `<log unavailable: ${logResult.stderr.trim() || `exit ${logResult.code}`}>`;
178
+ out.push({
179
+ runId,
180
+ name: typeof r?.name === "string" ? r.name : null,
181
+ conclusion,
182
+ logTail,
183
+ });
184
+ }
185
+ return { ok: true, repo: options.repo, pr: options.pr, headSha, runs: out };
186
+ }
187
+
188
+ export async function runCli(
189
+ argv = process.argv.slice(2),
190
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
191
+ ) {
192
+ let options;
193
+ try {
194
+ options = parseFetchCiLogsCliArgs(argv);
195
+ } catch (error) {
196
+ stderr.write(`${formatCliError(error)}\n`);
197
+ return 1;
198
+ }
199
+ if (options.help) {
200
+ stdout.write(`${USAGE}\n`);
201
+ return 0;
202
+ }
203
+ let result;
204
+ try {
205
+ result = await fetchCiLogs(options, { env, ghCommand, run });
206
+ } catch (error) {
207
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
208
+ return 1;
209
+ }
210
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
211
+ }
212
+
213
+ if (isDirectCliRun(import.meta.url)) {
214
+ runCli().then((code) => { process.exitCode = code; });
215
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+
8
+ const STATES = new Set(["open", "closed", "all"]);
9
+
10
+ const USAGE = `Usage: list-issues.mjs --repo <owner/name> [--state <open|closed|all>] [--label <l>] [--limit <n>]
11
+ List/filter repository issues. Thin wrapper over \`gh issue list\` — use this instead
12
+ of an agent-level raw \`gh issue list\` so the loop's internal-tooling record stays
13
+ clean (#993). The queue tool lists the project board, not arbitrary issue queries;
14
+ this fills that gap (siblings: comment-issue.mjs, fetch-ci-logs.mjs).
15
+ Required:
16
+ --repo <owner/name> Repository slug (e.g. owner/repo)
17
+ Optional:
18
+ --state <open|closed|all> Issue state filter (default open)
19
+ --label <l> Filter by label (repeatable; AND-combined by gh)
20
+ --limit <n> Return at most <n> issues (default 30)
21
+ Output (stdout, JSON):
22
+ { "ok": true, "issues": [{ "number": 17, "title": "...", "state": "open", "labels": ["bug"] }, ...] }
23
+ Error output (stderr, JSON):
24
+ { "ok": false, "error": "...", "usage"?: "..." }
25
+ ${JQ_OUTPUT_USAGE}
26
+ Exit codes:
27
+ 0 Success
28
+ 1 Argument error or gh failure
29
+ 2 Invalid --jq filter`.trim();
30
+ const parseError = buildParseError(USAGE);
31
+
32
+ export function parseListIssuesCliArgs(argv) {
33
+ const { tokens } = parseArgs({
34
+ args: [...argv],
35
+ options: {
36
+ help: { type: "boolean", short: "h" },
37
+ repo: { type: "string" },
38
+ state: { type: "string" },
39
+ label: { type: "string", multiple: true },
40
+ limit: { type: "string" },
41
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
+ },
43
+ allowPositionals: true,
44
+ strict: false,
45
+ tokens: true,
46
+ });
47
+ const options = {
48
+ help: false,
49
+ repo: undefined,
50
+ state: "open",
51
+ labels: [],
52
+ limit: 30,
53
+ jq: undefined,
54
+ silent: false,
55
+ };
56
+ for (const token of tokens) {
57
+ if (token.kind === "positional") {
58
+ throw parseError(`Unknown argument: ${token.value}`);
59
+ }
60
+ if (token.kind !== "option") {
61
+ continue;
62
+ }
63
+ if (token.name === "help") {
64
+ options.help = true;
65
+ return options;
66
+ }
67
+ if (token.name === "repo") {
68
+ options.repo = requireTokenValue(token, parseError).trim();
69
+ continue;
70
+ }
71
+ if (token.name === "state") {
72
+ const state = requireTokenValue(token, parseError).trim().toLowerCase();
73
+ if (!STATES.has(state)) {
74
+ throw parseError("--state must be one of: open, closed, all");
75
+ }
76
+ options.state = state;
77
+ continue;
78
+ }
79
+ if (token.name === "label") {
80
+ const label = requireTokenValue(token, parseError).trim();
81
+ if (label.length === 0) {
82
+ throw parseError("--label must be a non-empty string");
83
+ }
84
+ options.labels.push(label);
85
+ continue;
86
+ }
87
+ if (token.name === "limit") {
88
+ const raw = requireTokenValue(token, parseError);
89
+ const value = Number(raw);
90
+ if (!Number.isInteger(value) || value < 1) {
91
+ throw parseError(`--limit must be a positive integer, got "${raw}"`);
92
+ }
93
+ options.limit = value;
94
+ continue;
95
+ }
96
+ if (token.name === "jq") {
97
+ options.jq = requireTokenValue(token, parseError);
98
+ continue;
99
+ }
100
+ if (token.name === "silent") {
101
+ options.silent = true;
102
+ continue;
103
+ }
104
+ throw parseError(`Unknown argument: ${token.rawName}`);
105
+ }
106
+ if (options.repo === undefined) {
107
+ throw parseError("Listing issues requires --repo <owner/name>");
108
+ }
109
+ try {
110
+ parseRepoSlug(options.repo);
111
+ } catch (error) {
112
+ throw parseError(error instanceof Error ? error.message : String(error));
113
+ }
114
+ return options;
115
+ }
116
+
117
+ // Returns a well-typed issue, or null if the gh entry is missing/invalid in any
118
+ // required field. Callers filter the nulls so the emitted shape stays
119
+ // well-typed (documented output) and --jq filters never hit null number/title/state.
120
+ function normalizeIssue(raw) {
121
+ if (!Number.isInteger(raw?.number) || typeof raw?.title !== "string" || typeof raw?.state !== "string") {
122
+ return null;
123
+ }
124
+ return {
125
+ number: raw.number,
126
+ title: raw.title,
127
+ // gh reports issue state UPPERCASE (OPEN/CLOSED); normalize to lowercase to
128
+ // match the --state flag vocabulary.
129
+ state: raw.state.toLowerCase(),
130
+ labels: Array.isArray(raw?.labels)
131
+ ? raw.labels.map((l) => (typeof l?.name === "string" ? l.name : null)).filter((n) => n !== null)
132
+ : [],
133
+ };
134
+ }
135
+
136
+ export async function listIssues(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
137
+ const args = [
138
+ "issue",
139
+ "list",
140
+ "--repo",
141
+ options.repo,
142
+ "--state",
143
+ options.state,
144
+ "--limit",
145
+ String(options.limit),
146
+ "--json",
147
+ "number,title,state,labels",
148
+ ];
149
+ for (const label of options.labels) {
150
+ args.push("--label", label);
151
+ }
152
+ const result = await run(ghCommand, args, env);
153
+ if (result.code !== 0) {
154
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
155
+ throw new Error(`gh issue list failed: ${detail}`);
156
+ }
157
+ const payload = parseJsonText(result.stdout, { label: "gh issue list" });
158
+ if (!Array.isArray(payload)) {
159
+ throw new Error("gh issue list did not return a JSON array");
160
+ }
161
+ return { ok: true, issues: payload.map(normalizeIssue).filter((issue) => issue !== null) };
162
+ }
163
+
164
+ export async function runCli(
165
+ argv = process.argv.slice(2),
166
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
167
+ ) {
168
+ let options;
169
+ try {
170
+ options = parseListIssuesCliArgs(argv);
171
+ } catch (error) {
172
+ stderr.write(`${formatCliError(error)}\n`);
173
+ return 1;
174
+ }
175
+ if (options.help) {
176
+ stdout.write(`${USAGE}\n`);
177
+ return 0;
178
+ }
179
+ let result;
180
+ try {
181
+ result = await listIssues(options, { env, ghCommand, run });
182
+ } catch (error) {
183
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
184
+ return 1;
185
+ }
186
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
187
+ }
188
+
189
+ if (isDirectCliRun(import.meta.url)) {
190
+ runCli().then((code) => { process.exitCode = code; });
191
+ }
@@ -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
  });
@@ -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
@@ -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;