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
@@ -15,6 +15,7 @@ import {
15
15
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
16
16
  enforceExternalHealthyWaitTimeout,
17
17
  } from "@dev-loops/core/loop/timeout-policy";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
18
19
  const REMOVED_FLAGS = new Set([
19
20
  "--force-rerequest-review",
20
21
  "--probe-only",
@@ -46,9 +47,16 @@ Error output (stderr, JSON):
46
47
  { "ok": false, "error": "...", "usage": "..." }
47
48
  runtime failures:
48
49
  { "ok": false, "error": "..." }
50
+ Concise mode:
51
+ --concise, --summary Human-readable summary: loop state, copilot rounds,
52
+ unresolved/actionable thread counts, round-cap-clean
53
+ eligibility, CI status, next action, AND the current
54
+ round's new Copilot comment bodies.
55
+ ${JQ_OUTPUT_USAGE}
49
56
  Exit codes:
50
57
  0 Success
51
- 1 Argument error or runtime failure`.trim();
58
+ 1 Argument error or runtime failure
59
+ 2 Invalid --jq filter`.trim();
52
60
  const parseError = buildParseError(USAGE);
53
61
  function rejectRemovedFlag(token) {
54
62
  throw parseError(
@@ -202,6 +210,9 @@ export function parseWatchCycleCliArgs(argv) {
202
210
  help: false,
203
211
  repo: undefined,
204
212
  pr: undefined,
213
+ concise: false,
214
+ jq: undefined,
215
+ silent: false,
205
216
  };
206
217
  const { tokens } = parseArgs({
207
218
  args: [...argv],
@@ -209,6 +220,9 @@ export function parseWatchCycleCliArgs(argv) {
209
220
  help: { type: "boolean", short: "h" },
210
221
  repo: { type: "string" },
211
222
  pr: { type: "string" },
223
+ concise: { type: "boolean" },
224
+ summary: { type: "boolean" },
225
+ ...JQ_OUTPUT_PARSE_OPTIONS,
212
226
  },
213
227
  allowPositionals: true,
214
228
  strict: false,
@@ -236,6 +250,18 @@ export function parseWatchCycleCliArgs(argv) {
236
250
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
237
251
  continue;
238
252
  }
253
+ if (token.name === "concise" || token.name === "summary") {
254
+ options.concise = true;
255
+ continue;
256
+ }
257
+ if (token.name === "jq") {
258
+ options.jq = requireTokenValue(token, parseError);
259
+ continue;
260
+ }
261
+ if (token.name === "silent") {
262
+ options.silent = true;
263
+ continue;
264
+ }
239
265
  throw parseError(`Unknown argument: ${token.rawName}`);
240
266
  }
241
267
  if (options.repo === undefined || options.pr === undefined) {
@@ -395,6 +421,46 @@ export async function runWatchCycle(
395
421
  });
396
422
  return result;
397
423
  }
424
+ // Human-readable concise summary covering loop state, Copilot round count,
425
+ // unresolved/actionable thread counts, round-cap-clean eligibility, CI status,
426
+ // next action, AND the current round's new Copilot comment bodies (from the
427
+ // embedded watch probe result). This is the field set the loop needs to read
428
+ // without parsing the full JSON blob (issue #981).
429
+ export function formatWatchCycleConcise(result) {
430
+ const snapshot = result.snapshot ?? {};
431
+ const lines = [
432
+ `Watch cycle: PR #${snapshot.prNumber ?? "?"}`,
433
+ ` loop state: ${result.state}`,
434
+ ` handoff action: ${result.handoffAction}`,
435
+ ` copilot rounds: ${snapshot.copilotReviewRoundCount ?? 0}`,
436
+ ` unresolved threads: ${snapshot.unresolvedThreadCount ?? 0}`,
437
+ ` actionable threads: ${snapshot.actionableThreadCount ?? 0}`,
438
+ ` round-cap clean: ${result.roundCapCleanEligible ? "yes" : "no"}`,
439
+ ` CI status: ${snapshot.ciStatus ?? "none"}`,
440
+ ` loop disposition: ${result.loopDisposition}`,
441
+ ` cycle disposition: ${result.cycleDisposition}`,
442
+ ` watch status: ${result.watchStatus ?? "(not watched)"}`,
443
+ ` terminal: ${result.terminal ? "yes" : "no"}`,
444
+ ` next action: ${result.nextAction}`,
445
+ ];
446
+ const watch = result.watch ?? {};
447
+ const bodies = [
448
+ ...(watch.newReviews ?? []).map((r) => ({ kind: "review", body: r.body })),
449
+ ...(watch.newComments ?? []).map((c) => ({ kind: "threadComment", body: c.body })),
450
+ ...(watch.newIssueComments ?? []).map((c) => ({ kind: "issueComment", body: c.body })),
451
+ ].filter((entry) => typeof entry.body === "string" && entry.body.trim().length > 0);
452
+ if (bodies.length > 0) {
453
+ lines.push(" new Copilot comment bodies this round:");
454
+ for (const entry of bodies) {
455
+ const indented = entry.body.trim().split("\n").map((l) => ` ${l}`).join("\n");
456
+ lines.push(` [${entry.kind}]`);
457
+ lines.push(indented);
458
+ }
459
+ } else {
460
+ lines.push(" new Copilot comment bodies this round: (none)");
461
+ }
462
+ return lines.join("\n");
463
+ }
398
464
  export async function runCli(
399
465
  argv = process.argv.slice(2),
400
466
  {
@@ -417,10 +483,18 @@ export async function runCli(
417
483
  watchCopilotReviewImpl,
418
484
  detectSessionActivity: true,
419
485
  });
420
- stdout.write(`${JSON.stringify(result)}\n`);
486
+ if (options.concise && options.jq === undefined && !options.silent) {
487
+ stdout.write(`${formatWatchCycleConcise(result)}\n`);
488
+ return result.ok === false ? 1 : 0;
489
+ }
490
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
421
491
  }
422
492
  if (isDirectCliRun(import.meta.url)) {
423
- runCli().catch((error) => {
493
+ runCli().then((code) => {
494
+ if (typeof code === "number") {
495
+ process.exitCode = code;
496
+ }
497
+ }).catch((error) => {
424
498
  process.stderr.write(`${formatCliError(error)}\n`);
425
499
  process.exitCode = 1;
426
500
  });
@@ -0,0 +1,123 @@
1
+ // Bounded slides content & storytelling reviewer mode behind `dev-loop`.
2
+ // Sibling of scripts/loop/ui-designer-review-contract.mjs: this one judges a
3
+ // deck's narrative, not its pixels. Pure module, no I/O.
4
+
5
+ export const STORY_REVIEW_OUTCOMES = Object.freeze([
6
+ 'story_review_satisfied',
7
+ 'needs_iteration',
8
+ ]);
9
+
10
+ export const STORY_REVIEW_SEVERITIES = Object.freeze(['high', 'medium', 'low']);
11
+
12
+ export function validateSlidesStoryReviewInput(input = {}) {
13
+ // Coerce a null / non-object argument to {} so this validator always returns
14
+ // its structured status rather than throwing (the module's contract is a
15
+ // structured result, never an exception).
16
+ if (!input || typeof input !== 'object') input = {};
17
+ if (input.workType !== 'slides' || input.storyReviewRequested !== true) {
18
+ return {
19
+ ok: true,
20
+ status: 'skip_non_slides',
21
+ reason: 'non_slides_or_not_requested',
22
+ missing: [],
23
+ };
24
+ }
25
+ const missing = [];
26
+ const acceptanceCriteria = Array.isArray(input.acceptanceCriteria)
27
+ ? input.acceptanceCriteria.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
28
+ : [];
29
+ if (acceptanceCriteria.length === 0) {
30
+ missing.push('acceptanceCriteria');
31
+ }
32
+ if (typeof input.storytellingBrief !== 'string' || input.storytellingBrief.trim().length === 0) {
33
+ missing.push('storytellingBrief');
34
+ }
35
+ const deckBundle = input.deckBundle ?? {};
36
+ if (typeof deckBundle.deckSourcePath !== 'string' || deckBundle.deckSourcePath.trim().length === 0) {
37
+ missing.push('deckBundle.deckSourcePath');
38
+ }
39
+ if (missing.length > 0) {
40
+ return {
41
+ ok: false,
42
+ status: 'blocked_missing_required_inputs',
43
+ reason: 'required_inputs_missing',
44
+ missing,
45
+ };
46
+ }
47
+ // Captured slide screenshots are optional, but if present each entry must be complete.
48
+ const incompleteArtifacts = [];
49
+ const slideScreenshots = Array.isArray(deckBundle.slideScreenshots) ? deckBundle.slideScreenshots : [];
50
+ slideScreenshots.forEach((rawShot, index) => {
51
+ const shot = rawShot && typeof rawShot === 'object' ? rawShot : {};
52
+ if (typeof shot.slideId !== 'string' || shot.slideId.trim().length === 0) {
53
+ incompleteArtifacts.push(`deckBundle.slideScreenshots[${index}].slideId`);
54
+ }
55
+ if (typeof shot.screenshotPath !== 'string' || shot.screenshotPath.trim().length === 0) {
56
+ incompleteArtifacts.push(`deckBundle.slideScreenshots[${index}].screenshotPath`);
57
+ }
58
+ });
59
+ if (incompleteArtifacts.length > 0) {
60
+ return {
61
+ ok: false,
62
+ status: 'blocked_incomplete_deck_bundle',
63
+ reason: 'deck_bundle_incomplete',
64
+ missing: incompleteArtifacts,
65
+ };
66
+ }
67
+ return {
68
+ ok: true,
69
+ status: 'ready_for_story_review',
70
+ reason: 'deck_bundle_complete',
71
+ missing: [],
72
+ };
73
+ }
74
+
75
+ export function validateSlidesStoryReviewResult(result = {}) {
76
+ // Coerce a null / non-object argument to {} so this validator returns its
77
+ // structured `invalid` result rather than throwing.
78
+ if (!result || typeof result !== 'object') result = {};
79
+ const invalid = [];
80
+ if (!STORY_REVIEW_OUTCOMES.includes(result.outcome)) {
81
+ invalid.push('outcome');
82
+ }
83
+ if (typeof result.summary !== 'string' || result.summary.trim().length === 0) {
84
+ invalid.push('summary');
85
+ }
86
+ const findings = Array.isArray(result.findings) ? result.findings : null;
87
+ if (findings === null) {
88
+ invalid.push('findings');
89
+ } else {
90
+ findings.forEach((rawFinding, index) => {
91
+ const finding = rawFinding && typeof rawFinding === 'object' ? rawFinding : {};
92
+ if (!STORY_REVIEW_SEVERITIES.includes(finding.severity)) {
93
+ invalid.push(`findings[${index}].severity`);
94
+ }
95
+ if (typeof finding.slideId !== 'string' || finding.slideId.trim().length === 0) {
96
+ invalid.push(`findings[${index}].slideId`);
97
+ }
98
+ if (typeof finding.problem !== 'string' || finding.problem.trim().length === 0) {
99
+ invalid.push(`findings[${index}].problem`);
100
+ }
101
+ if (typeof finding.correctiveAction !== 'string' || finding.correctiveAction.trim().length === 0) {
102
+ invalid.push(`findings[${index}].correctiveAction`);
103
+ }
104
+ });
105
+ }
106
+ if (result.outcome === 'needs_iteration' && findings !== null && findings.length === 0) {
107
+ invalid.push('findings');
108
+ }
109
+ if (invalid.length > 0) {
110
+ return {
111
+ ok: false,
112
+ status: 'invalid_result_shape',
113
+ reason: 'result_shape_invalid',
114
+ invalid,
115
+ };
116
+ }
117
+ return {
118
+ ok: true,
119
+ status: 'valid_result_shape',
120
+ reason: 'result_shape_valid',
121
+ invalid: [],
122
+ };
123
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // Assembles the GitHub Pages publishable dir deterministically. The landing
3
+ // page (index.html) is the "Introducing dev-loops" article; the deep-dive
4
+ // article and the deep-dive deck are published alongside it and reached through
5
+ // a shared navigation bar injected into the article pages. The source HTML files
6
+ // under docs/ are the source of truth; site/ is assembled, never hand-maintained.
7
+ // Usage: node scripts/pages/build-site.mjs [--out <dir>] [--repo-root <dir>]
8
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
9
+ import { dirname, join, resolve, parse as parsePath } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const REPO_ROOT_DEFAULT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+
14
+ // The landing page: the intro article, published as index.html. file is
15
+ // relative to docs/articles/.
16
+ export const LANDING = { file: 'introducing-dev-loops.html' };
17
+
18
+ // The deep-dive article published alongside the landing page. file is relative
19
+ // to docs/articles/; navLabel is how the nav refers to it.
20
+ export const ARTICLES = [
21
+ { file: 'dev-loops-deep-dive.html', navLabel: 'Deep dive' },
22
+ ];
23
+
24
+ // The decks to publish. file is relative to docs/presentations/; outFile is the
25
+ // published name (defaults to file). The deep-dive article and deck share the
26
+ // source basename dev-loops-deep-dive.html under different docs/ dirs, so the
27
+ // deck publishes under a distinct name to avoid clobbering the article in site/.
28
+ export const DECKS = [
29
+ {
30
+ file: 'introducing-dev-loops.html',
31
+ title: 'Introducing dev-loops',
32
+ subtitle: 'A coordination runtime for AI-assisted development',
33
+ description: 'The concept, the data behind it, and how to run the loop on your own project.',
34
+ navLabel: 'Intro (deck)',
35
+ },
36
+ {
37
+ file: 'dev-loops-deep-dive.html',
38
+ outFile: 'dev-loops-deep-dive-deck.html',
39
+ title: 'dev-loops: A Deep Dive',
40
+ subtitle: 'Coordination delay and the waiting between actions',
41
+ description: 'How explicit handoffs on a state graph and measuring the wait between actions cut delivery delay.',
42
+ navLabel: 'Deep dive (deck)',
43
+ },
44
+ ];
45
+
46
+ // Resolve a deck's published filename: distinct outFile when set, else file.
47
+ const deckOut = (deck) => deck.outFile ?? deck.file;
48
+
49
+ // The other resources linked from the navigation, in order.
50
+ export const NAV_LINKS = [
51
+ ...ARTICLES.map((a) => ({ file: a.file, label: a.navLabel })),
52
+ ...DECKS.map((d) => ({ file: deckOut(d), label: d.navLabel })),
53
+ ];
54
+
55
+ // Nav styling, appended to each article page's own <style> block so it reuses
56
+ // the article design-system variables (--heading/--kicker/--accent-soft).
57
+ const NAV_CSS = `
58
+ .site-nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 1.1rem; max-width: 64rem; margin: 0 auto; padding: 0.9rem clamp(1.1rem, 5vw, 2rem); border-bottom: 1px solid rgba(148, 163, 184, 0.16); }
59
+ .site-nav-brand { font-weight: 700; letter-spacing: -0.01em; color: var(--heading); text-decoration: none; border: 0; margin-right: auto; }
60
+ .site-nav-links { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
61
+ .site-nav a { color: var(--kicker); text-decoration: none; font-size: 0.9rem; border: 0; }
62
+ .site-nav a:hover { color: var(--accent-soft); }`;
63
+
64
+ function navMarkup() {
65
+ const links = NAV_LINKS.map((l) => ` <a href="${l.file}">${l.label}</a>`).join('\n');
66
+ return `<nav class="site-nav" aria-label="dev-loops resources">
67
+ <a class="site-nav-brand" href="index.html">dev-loops</a>
68
+ <div class="site-nav-links">
69
+ ${links}
70
+ </div>
71
+ </nav>`;
72
+ }
73
+
74
+ // Inject the shared nav into an article page: nav CSS before </style>, nav
75
+ // markup right after <body>. Idempotent enough for assembly (each source file
76
+ // is read once). Throws if the page lacks the expected anchors so a structural
77
+ // drift fails the build rather than publishing an un-navigable page.
78
+ export function injectNav(html) {
79
+ if (!html.includes('</style>') || !/<body[^>]*>/.test(html)) {
80
+ throw new Error('cannot inject nav: page is missing a <style> block or <body> tag');
81
+ }
82
+ return html
83
+ .replace('</style>', `${NAV_CSS}\n</style>`)
84
+ .replace(/<body([^>]*)>/, `<body$1>\n ${navMarkup()}`);
85
+ }
86
+
87
+ export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
88
+ const out = outDir ? resolve(outDir) : join(repoRoot, 'site');
89
+ const articlesDir = join(repoRoot, 'docs', 'articles');
90
+ const decksDir = join(repoRoot, 'docs', 'presentations');
91
+
92
+ // Guard: out is wiped before assembly. Refuse paths that would nuke the
93
+ // filesystem root, the repo itself, or an ancestor of the repo.
94
+ const root = resolve(repoRoot);
95
+ const isAncestorOf = (a, b) => b === a || b.startsWith(a + '/');
96
+ if (out === parsePath(out).root || out === root || isAncestorOf(out, root)) {
97
+ throw new Error(`refusing to wipe unsafe output dir ${out}`);
98
+ }
99
+
100
+ await rm(out, { recursive: true, force: true });
101
+ await mkdir(out, { recursive: true });
102
+
103
+ // Landing page: the intro article, navigable, published as index.html.
104
+ const landingHtml = await readFile(join(articlesDir, LANDING.file), 'utf8');
105
+ await writeFile(join(out, 'index.html'), injectNav(landingHtml), 'utf8');
106
+
107
+ // Deep-dive article: published with the same nav so the set is navigable.
108
+ for (const article of ARTICLES) {
109
+ const html = await readFile(join(articlesDir, article.file), 'utf8');
110
+ await writeFile(join(out, article.file), injectNav(html), 'utf8');
111
+ }
112
+
113
+ // Decks: self-contained slide renders, copied as-is (no nav injection).
114
+ for (const deck of DECKS) {
115
+ await cp(join(decksDir, deck.file), join(out, deckOut(deck)));
116
+ }
117
+
118
+ return {
119
+ out,
120
+ files: ['index.html', ...ARTICLES.map((a) => a.file), ...DECKS.map((d) => deckOut(d))],
121
+ };
122
+ }
123
+
124
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
125
+ if (invokedDirectly) {
126
+ const args = process.argv.slice(2);
127
+ const getArg = (name) => {
128
+ const i = args.indexOf(name);
129
+ return i >= 0 ? args[i + 1] : undefined;
130
+ };
131
+ const result = await buildSite({
132
+ repoRoot: getArg('--repo-root') ? resolve(getArg('--repo-root')) : undefined,
133
+ outDir: getArg('--out'),
134
+ });
135
+ console.log(`Built site at ${result.out}: ${result.files.join(', ')}`);
136
+ }
@@ -2,6 +2,7 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
7
  const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
7
8
  dev-loops project add … (back-compat alias for "queue add")
@@ -19,10 +20,12 @@ Options:
19
20
  Output (stdout):
20
21
  JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, alreadyPresent } }
21
22
 
23
+ ${JQ_OUTPUT_USAGE}
24
+
22
25
  Exit codes:
23
26
  0 — success (or no-op when already present)
24
27
  1 — usage or argument error
25
- 2 — GitHub API error
28
+ 2 — GitHub API error / invalid --jq filter
26
29
  3 — project, field, column, or issue/PR not found
27
30
  `.trim();
28
31
 
@@ -46,6 +49,7 @@ function parseCliArgs(argv) {
46
49
  column: { type: "string" },
47
50
  status: { type: "string" },
48
51
  help: { type: "boolean", short: "h" },
52
+ ...JQ_OUTPUT_PARSE_OPTIONS,
49
53
  },
50
54
  allowPositionals: true,
51
55
  strict: false,
@@ -93,6 +97,12 @@ function parseCliArgs(argv) {
93
97
  // resolved by argv order.
94
98
  args.status = requireValue(token, "--status requires a value");
95
99
  break;
100
+ case "jq":
101
+ args.jq = requireValue(token, "--jq requires a filter");
102
+ break;
103
+ case "silent":
104
+ args.silent = true;
105
+ break;
96
106
  default:
97
107
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
98
108
  }
@@ -543,7 +553,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
543
553
  }
544
554
  try {
545
555
  const result = await main(args, { env });
546
- stdout.write(JSON.stringify(result) + "\n");
556
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
547
557
  } catch (err) {
548
558
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
549
559
  process.exitCode = classifyExitCode(err);
@@ -2,6 +2,7 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
7
  const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
7
8
  (dev-loops project list … is a back-compat alias)
@@ -19,10 +20,12 @@ Options:
19
20
  Output (stdout):
20
21
  JSON: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
21
22
 
23
+ ${JQ_OUTPUT_USAGE}
24
+
22
25
  Exit codes:
23
26
  0 — success
24
27
  1 — usage or argument error
25
- 2 — GitHub API error
28
+ 2 — GitHub API error / invalid --jq filter
26
29
  3 — project, field, or column not found
27
30
  `.trim();
28
31
 
@@ -45,6 +48,7 @@ function parseCliArgs(argv) {
45
48
  column: { type: "string" },
46
49
  limit: { type: "string" },
47
50
  help: { type: "boolean", short: "h" },
51
+ ...JQ_OUTPUT_PARSE_OPTIONS,
48
52
  },
49
53
  allowPositionals: true,
50
54
  strict: false,
@@ -83,6 +87,12 @@ function parseCliArgs(argv) {
83
87
  args.limit = val;
84
88
  break;
85
89
  }
90
+ case "jq":
91
+ args.jq = requireValue(token, "--jq requires a filter");
92
+ break;
93
+ case "silent":
94
+ args.silent = true;
95
+ break;
86
96
  default:
87
97
  throw parseError(`Unknown flag: ${token.rawName}`);
88
98
  }
@@ -474,7 +484,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
474
484
  }
475
485
  try {
476
486
  const result = await main(args, { env });
477
- stdout.write(JSON.stringify(result) + "\n");
487
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
478
488
  } catch (err) {
479
489
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
480
490
  process.exitCode = classifyExitCode(err);
@@ -2,6 +2,7 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
7
  const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
7
8
  (dev-loops project move … is a back-compat alias)
@@ -18,10 +19,12 @@ Options:
18
19
  Output (stdout):
19
20
  JSON: { ok: true, item: { itemId, issueNumber, prNumber, previousColumn, newColumn } }
20
21
 
22
+ ${JQ_OUTPUT_USAGE}
23
+
21
24
  Exit codes:
22
25
  0 — success
23
26
  1 — usage or argument error
24
- 2 — GitHub API error
27
+ 2 — GitHub API error / invalid --jq filter
25
28
  3 — project, field, column, or item not found
26
29
  `.trim();
27
30
 
@@ -44,6 +47,7 @@ function parseCliArgs(argv) {
44
47
  item: { type: "string" },
45
48
  "to-column": { type: "string" },
46
49
  help: { type: "boolean", short: "h" },
50
+ ...JQ_OUTPUT_PARSE_OPTIONS,
47
51
  },
48
52
  allowPositionals: true,
49
53
  strict: false,
@@ -76,6 +80,12 @@ function parseCliArgs(argv) {
76
80
  case "to-column":
77
81
  args.toColumn = requireValue(token, "--to-column requires a value");
78
82
  break;
83
+ case "jq":
84
+ args.jq = requireValue(token, "--jq requires a filter");
85
+ break;
86
+ case "silent":
87
+ args.silent = true;
88
+ break;
79
89
  default:
80
90
  throw parseError(`Unknown flag: ${token.rawName}`);
81
91
  }
@@ -532,7 +542,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
532
542
  }
533
543
  try {
534
544
  const result = await main(args, { env });
535
- stdout.write(JSON.stringify(result) + "\n");
545
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
536
546
  } catch (err) {
537
547
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
538
548
  process.exitCode = classifyExitCode(err);
@@ -233,6 +233,26 @@ export function extractSection(body, headingText) {
233
233
  return body.slice(start, end).trim();
234
234
  }
235
235
 
236
+ /**
237
+ * Shared base-section checker for the phase-doc-format validators (plan + spike).
238
+ * For each heading, reports its distinct missing_* code when the section is
239
+ * absent or has an empty body. Pure; no side effects.
240
+ *
241
+ * @param {string} markdownText
242
+ * @param {string} checker checker name echoed back in the result
243
+ * @param {Record<string,string>} sectionCodes heading → missing_* code (key order = section order)
244
+ * @returns {{ checker: string, ok: boolean, errors: { code: string, message: string }[] }}
245
+ */
246
+ export function checkBaseSections(markdownText, checker, sectionCodes) {
247
+ const errors = [];
248
+ for (const [heading, code] of Object.entries(sectionCodes)) {
249
+ if (!extractSection(markdownText, heading)) {
250
+ errors.push({ code, message: `Missing or empty ## ${heading} section.` });
251
+ }
252
+ }
253
+ return { checker, ok: errors.length === 0, errors };
254
+ }
255
+
236
256
  export function normalizeScopeToken(value) {
237
257
  return String(value ?? "")
238
258
  .trim()