dev-loops 0.4.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 (49) 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/skills/dev-loop/SKILL.md +16 -6
  5. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  6. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  7. package/.claude/skills/docs/local-planning-flow.md +63 -0
  8. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  9. package/.claude/skills/docs/merge-preconditions.md +35 -0
  10. package/.claude/skills/docs/plan-file-contract.md +37 -0
  11. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  12. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  13. package/CHANGELOG.md +43 -0
  14. package/README.md +1 -0
  15. package/agents/refiner.agent.md +1 -0
  16. package/package.json +5 -3
  17. package/scripts/github/capture-review-threads.mjs +20 -2
  18. package/scripts/github/probe-copilot-review.mjs +69 -3
  19. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  20. package/scripts/lib/jq-output.mjs +297 -0
  21. package/scripts/loop/check-retro-tooling.mjs +246 -0
  22. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  23. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  24. package/scripts/loop/docs-grill-contract.mjs +70 -0
  25. package/scripts/loop/info.mjs +21 -2
  26. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  27. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  28. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  29. package/scripts/loop/run-watch-cycle.mjs +77 -3
  30. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  31. package/scripts/pages/build-site.mjs +136 -0
  32. package/scripts/projects/add-queue-item.mjs +12 -2
  33. package/scripts/projects/list-queue-items.mjs +12 -2
  34. package/scripts/projects/move-queue-item.mjs +12 -2
  35. package/scripts/refine/_refine-helpers.mjs +20 -0
  36. package/scripts/refine/exit-spike.mjs +186 -0
  37. package/scripts/refine/promote-plan.mjs +387 -0
  38. package/scripts/refine/refine-plan-file.mjs +165 -0
  39. package/scripts/refine/validate-plan-file.mjs +64 -0
  40. package/scripts/refine/validate-spike-file.mjs +87 -0
  41. package/skills/dev-loop/SKILL.md +11 -1
  42. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  43. package/skills/docs/artifact-authority-contract.md +86 -31
  44. package/skills/docs/local-planning-flow.md +63 -0
  45. package/skills/docs/local-planning-worked-example.md +139 -0
  46. package/skills/docs/merge-preconditions.md +35 -0
  47. package/skills/docs/plan-file-contract.md +37 -0
  48. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  49. package/skills/docs/spike-mode-contract.md +237 -0
@@ -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;
@@ -0,0 +1,297 @@
1
+ // Shared --jq / --silent output helper for the JSON-emitting dev-loops scripts
2
+ // (issue #981, subsumes #963). One helper applied uniformly so the loop never
3
+ // falls back to `gh api | python3` or inline `node -e` to read tool JSON.
4
+ //
5
+ // Convention (also documented in skills/dev-loop/SKILL.md):
6
+ // prefer the dev-loops subcommand
7
+ // -> `--silent`/`-s` for a yes/no status check (zero output, exit code only)
8
+ // -> `--jq <filter>` to extract a field from the result
9
+ // -> `gh --jq` on a raw gh call
10
+ // -> NEVER `| python3` or `node -e`.
11
+ //
12
+ // jq subset (NOT full jq — fails closed clearly on anything outside this):
13
+ // . identity
14
+ // .field field access (also .a.b.c chains)
15
+ // .field[] iterate an array field
16
+ // .[] iterate an array / object values
17
+ // .[N] / .field[N] index access
18
+ // a | b pipe
19
+ // select(<pred>) filter the current value(s) by a predicate
20
+ // length count of array/string/object
21
+ // keys sorted object keys (or array indices)
22
+ // predicates inside select: .x == <lit>, !=, <, <=, >, >=, and a bare path
23
+ // (truthy test). <lit> is a JSON string/number/true/false/null.
24
+ // Anything else -> JqFilterError (fail closed).
25
+
26
+ export class JqFilterError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "JqFilterError";
30
+ this.isJqFilterError = true;
31
+ }
32
+ }
33
+
34
+ // A jq "stream" is an array of values (jq filters are many-valued). We thread an
35
+ // array through each pipe stage. Identity is [value].
36
+
37
+ function parseLiteral(token) {
38
+ const trimmed = token.trim();
39
+ if (trimmed === "true") return { ok: true, value: true };
40
+ if (trimmed === "false") return { ok: true, value: false };
41
+ if (trimmed === "null") return { ok: true, value: null };
42
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return { ok: true, value: Number(trimmed) };
43
+ if (
44
+ (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
45
+ (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
46
+ ) {
47
+ return { ok: true, value: trimmed.slice(1, -1) };
48
+ }
49
+ return { ok: false };
50
+ }
51
+
52
+ // Resolve a single-value path expression like `.`, `.a.b`, `.a[0].b`, `.[2]`.
53
+ // Returns undefined for a missing path (jq yields null; we map missing->undefined
54
+ // then callers coerce). Throws JqFilterError on malformed path syntax.
55
+ function resolvePath(value, path) {
56
+ const trimmed = path.trim();
57
+ if (trimmed === "") {
58
+ // An empty path is never valid jq (no empty filter). Fail closed so
59
+ // malformed predicates like `select()` or `==5` don't pass as identity.
60
+ throw new JqFilterError("Empty path expression");
61
+ }
62
+ if (trimmed === ".") return value;
63
+ if (!trimmed.startsWith(".")) {
64
+ throw new JqFilterError(`Unsupported path (must start with '.'): ${path}`);
65
+ }
66
+ // Tokenize into .field and [index] steps.
67
+ const stepRe = /\.([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]/g;
68
+ let cursor = value;
69
+ // Root index access (`.[N]`) starts with a leading '.' that is not part of any
70
+ // step token; consume it so the first `[N]` match aligns at the cursor.
71
+ let lastIndex = trimmed.startsWith(".[") ? 1 : 0;
72
+ stepRe.lastIndex = lastIndex;
73
+ let match;
74
+ let consumedAny = false;
75
+ while ((match = stepRe.exec(trimmed)) !== null) {
76
+ if (match.index !== lastIndex) {
77
+ throw new JqFilterError(`Unsupported path syntax near: ${trimmed.slice(lastIndex)}`);
78
+ }
79
+ lastIndex = stepRe.lastIndex;
80
+ consumedAny = true;
81
+ if (cursor === undefined || cursor === null) {
82
+ cursor = undefined;
83
+ continue;
84
+ }
85
+ if (match[1] !== undefined) {
86
+ cursor = typeof cursor === "object" && !Array.isArray(cursor) ? cursor[match[1]] : undefined;
87
+ } else {
88
+ const idx = Number(match[2]);
89
+ cursor = Array.isArray(cursor) ? cursor[idx] : undefined;
90
+ }
91
+ }
92
+ if (!consumedAny || lastIndex !== trimmed.length) {
93
+ throw new JqFilterError(`Unsupported path syntax: ${path}`);
94
+ }
95
+ return cursor;
96
+ }
97
+
98
+ function evaluatePredicate(value, predicate) {
99
+ const cmpRe = /^(.*?)(==|!=|<=|>=|<|>)(.*)$/;
100
+ const m = predicate.match(cmpRe);
101
+ if (!m) {
102
+ // Bare path => truthy test.
103
+ const resolved = resolvePath(value, predicate);
104
+ return resolved !== undefined && resolved !== null && resolved !== false;
105
+ }
106
+ const [, leftRaw, op, rightRaw] = m;
107
+ // jq maps a missing path to null; resolvePath yields undefined -> normalize.
108
+ const resolvedLeft = resolvePath(value, leftRaw);
109
+ const left = resolvedLeft === undefined ? null : resolvedLeft;
110
+ const lit = parseLiteral(rightRaw);
111
+ if (!lit.ok) {
112
+ throw new JqFilterError(`Unsupported predicate operand: ${rightRaw.trim()}`);
113
+ }
114
+ const right = lit.value;
115
+ switch (op) {
116
+ case "==":
117
+ return left === right;
118
+ case "!=":
119
+ return left !== right;
120
+ case "<":
121
+ case "<=":
122
+ case ">":
123
+ case ">=": {
124
+ // jq never coerces across types (number < string always); JS would. Fail
125
+ // closed when operand types differ so numeric-string fields don't misfire.
126
+ if (typeof left !== typeof right) {
127
+ throw new JqFilterError(`Cannot order-compare ${typeof left} and ${typeof right}`);
128
+ }
129
+ if (op === "<") return left < right;
130
+ if (op === "<=") return left <= right;
131
+ if (op === ">") return left > right;
132
+ return left >= right;
133
+ }
134
+ default:
135
+ throw new JqFilterError(`Unsupported operator: ${op}`);
136
+ }
137
+ }
138
+
139
+ // Apply one pipe stage to a stream (array of values), returning a new stream.
140
+ function applyStage(stream, rawStage) {
141
+ const stage = rawStage.trim();
142
+ if (stage === "") {
143
+ throw new JqFilterError("Empty filter stage");
144
+ }
145
+ if (stage === "length") {
146
+ return stream.map((v) => {
147
+ if (Array.isArray(v) || typeof v === "string") return v.length;
148
+ if (v && typeof v === "object") return Object.keys(v).length;
149
+ throw new JqFilterError("length: input must be array, string, or object");
150
+ });
151
+ }
152
+ if (stage === "keys") {
153
+ return stream.map((v) => {
154
+ if (Array.isArray(v)) return v.map((_, i) => i);
155
+ if (v && typeof v === "object") return Object.keys(v).sort();
156
+ throw new JqFilterError("keys: input must be array or object");
157
+ });
158
+ }
159
+ const selectMatch = stage.match(/^select\((.*)\)$/);
160
+ if (selectMatch) {
161
+ return stream.filter((v) => evaluatePredicate(v, selectMatch[1]));
162
+ }
163
+ // Top-level comparison expression yields a boolean per input (e.g. .x=="y").
164
+ if (/(==|!=|<=|>=|<|>)/.test(stage)) {
165
+ return stream.map((v) => evaluatePredicate(v, stage));
166
+ }
167
+ // Iteration: a path ending in [] (incl. bare .[]).
168
+ if (stage === ".[]") {
169
+ return stream.flatMap((v) => iterate(v));
170
+ }
171
+ if (stage.endsWith("[]")) {
172
+ const base = stage.slice(0, -2);
173
+ return stream.flatMap((v) => iterate(resolvePath(v, base)));
174
+ }
175
+ // Plain path access (single value per input).
176
+ return stream.map((v) => resolvePath(v, stage));
177
+ }
178
+
179
+ function iterate(v) {
180
+ if (Array.isArray(v)) return v;
181
+ if (v && typeof v === "object") return Object.values(v);
182
+ throw new JqFilterError("Cannot iterate (.[]) over a non-array/object value");
183
+ }
184
+
185
+ // Split on top-level pipes, ignoring | inside quotes or parentheses.
186
+ function splitPipes(filter) {
187
+ const parts = [];
188
+ let depth = 0;
189
+ let quote = null;
190
+ let current = "";
191
+ for (const ch of filter) {
192
+ if (quote) {
193
+ current += ch;
194
+ if (ch === quote) quote = null;
195
+ continue;
196
+ }
197
+ if (ch === '"' || ch === "'") {
198
+ quote = ch;
199
+ current += ch;
200
+ continue;
201
+ }
202
+ if (ch === "(") depth += 1;
203
+ if (ch === ")") depth -= 1;
204
+ if (ch === "|" && depth === 0) {
205
+ parts.push(current);
206
+ current = "";
207
+ continue;
208
+ }
209
+ current += ch;
210
+ }
211
+ parts.push(current);
212
+ return parts;
213
+ }
214
+
215
+ // Evaluate a jq-subset filter against a value. Returns the jq "stream" (array of
216
+ // result values). Throws JqFilterError on unsupported syntax.
217
+ export function evaluateJqFilter(value, filter) {
218
+ if (typeof filter !== "string" || filter.trim() === "") {
219
+ throw new JqFilterError("Empty --jq filter");
220
+ }
221
+ let stream = [value];
222
+ for (const stage of splitPipes(filter)) {
223
+ stream = applyStage(stream, stage);
224
+ }
225
+ return stream;
226
+ }
227
+
228
+ // jq truthiness for --silent: matches `jq -e` exit semantics — the status is
229
+ // based on the LAST output value (empty output is falsy; a value is truthy
230
+ // unless it is null or false).
231
+ function streamIsTruthy(stream) {
232
+ if (stream.length === 0) return false;
233
+ const last = stream[stream.length - 1];
234
+ return last !== null && last !== false && last !== undefined;
235
+ }
236
+
237
+ function renderJqStream(stream) {
238
+ // jq prints each value on its own line; single value => just that value.
239
+ return stream
240
+ .map((v) => (typeof v === "string" ? v : JSON.stringify(v ?? null)))
241
+ .join("\n");
242
+ }
243
+
244
+ // Standard option block to merge into a parseArgs `options` map so every script
245
+ // exposes the same flags.
246
+ export const JQ_OUTPUT_PARSE_OPTIONS = {
247
+ jq: { type: "string" },
248
+ silent: { type: "boolean", short: "s" },
249
+ };
250
+
251
+ // Shared USAGE fragment so every script documents the flags identically.
252
+ export const JQ_OUTPUT_USAGE = `Output filtering:
253
+ --jq <filter> Apply a jq-subset filter to the result and print it
254
+ (field access, .[]/.[N], pipes, select(...), ==,!=,<,<=,>,>=, length, keys).
255
+ Invalid filter fails closed (stderr + exit 2).
256
+ --silent, -s Suppress stdout; map result to exit code only
257
+ (0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
258
+
259
+ // Apply --jq / --silent to a result object and emit. Returns the exit code the
260
+ // CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
261
+ // Without jq/silent the result is printed verbatim as JSON (unchanged shape).
262
+ //
263
+ // ok: success of the underlying result (defaults to result.ok !== false).
264
+ export function emitResult(
265
+ result,
266
+ {
267
+ jq = undefined,
268
+ silent = false,
269
+ stdout = process.stdout,
270
+ stderr = process.stderr,
271
+ ok = result?.ok !== false,
272
+ } = {},
273
+ ) {
274
+ if (jq !== undefined) {
275
+ let stream;
276
+ try {
277
+ stream = evaluateJqFilter(result, jq);
278
+ } catch (error) {
279
+ if (error instanceof JqFilterError) {
280
+ // Fail closed, distinct from a clean "predicate false". Exit 2.
281
+ stderr.write(`${JSON.stringify({ ok: false, error: `--jq: ${error.message}` })}\n`);
282
+ return 2;
283
+ }
284
+ throw error;
285
+ }
286
+ if (silent) {
287
+ return streamIsTruthy(stream) ? 0 : 1;
288
+ }
289
+ stdout.write(`${renderJqStream(stream)}\n`);
290
+ return ok ? 0 : 1;
291
+ }
292
+ if (silent) {
293
+ return ok ? 0 : 1;
294
+ }
295
+ stdout.write(`${JSON.stringify(result)}\n`);
296
+ return ok ? 0 : 1;
297
+ }