dev-loops 0.5.0 → 0.7.1

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 (177) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/loop-auto.md +7 -0
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -17,6 +17,7 @@ import {
17
17
  writeLinkedPrNumber,
18
18
  PLAN_FILE_PROMOTE_ACTION,
19
19
  } from "@dev-loops/core/loop/plan-file-promote-contract";
20
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
20
21
 
21
22
  const CREATE_PR_PATH = fileURLToPath(new URL("../github/create-pr.mjs", import.meta.url));
22
23
 
@@ -42,9 +43,14 @@ Optional:
42
43
  --branch <name> Branch name to commit the plan on (default: derived from the plan path)
43
44
  --json Machine-readable JSON output
44
45
  --help Show this help
46
+
47
+ ${JQ_OUTPUT_USAGE}
48
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
49
+
45
50
  Exit codes:
46
51
  0 Promotion succeeded (or idempotent no-op for an already-linked plan)
47
- 1 Argument error, or fail-closed (plan not ready / git / PR-create failure); no issue ever created`.trim();
52
+ 1 Argument error, or fail-closed (plan not ready / git / PR-create failure); no issue ever created
53
+ 2 Invalid --jq filter`.trim();
48
54
 
49
55
  const parseError = buildParseError(USAGE);
50
56
 
@@ -57,6 +63,7 @@ export function parsePromotePlanCliArgs(argv) {
57
63
  base: { type: "string" },
58
64
  branch: { type: "string" },
59
65
  json: { type: "boolean" },
66
+ ...JQ_OUTPUT_PARSE_OPTIONS,
60
67
  },
61
68
  allowPositionals: true,
62
69
  strict: false,
@@ -88,6 +95,7 @@ export function parsePromotePlanCliArgs(argv) {
88
95
  options.json = true;
89
96
  continue;
90
97
  }
98
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
91
99
  throw parseError(`Unknown argument: ${token.rawName}`);
92
100
  }
93
101
  if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
@@ -123,11 +131,14 @@ function parsePrNumberFromGhOutput(text) {
123
131
  return match ? Number.parseInt(match[1], 10) : null;
124
132
  }
125
133
 
126
- function emit(stdout, json, summary, humanLines) {
127
- if (json) {
128
- stdout.write(`${JSON.stringify(summary)}\n`);
134
+ // Sets process.exitCode from `summary.ok` (0/1), or the shared --jq/--silent
135
+ // contract's exit code (0/1/2) when --json + --jq/--silent are in play.
136
+ function emit(stdout, options, summary, humanLines) {
137
+ if (options.json) {
138
+ process.exitCode = emitResult(summary, { jq: options.jq, silent: options.silent, stdout });
129
139
  } else {
130
140
  stdout.write(`${humanLines.join("\n")}\n`);
141
+ process.exitCode = summary.ok ? 0 : 1;
131
142
  }
132
143
  }
133
144
 
@@ -161,8 +172,7 @@ export async function runCli(argv = process.argv.slice(2), {
161
172
  if (!decision.ok) {
162
173
  // Fail closed: no git, no gh, no issue — surface the reason and stop.
163
174
  const summary = { ok: false, reason: decision.reason, planFileIntakeState: decision.planFileIntakeState ?? null };
164
- emit(stdout, options.json, summary, [`promote-plan: FAIL (${decision.reason})`]);
165
- process.exitCode = 1;
175
+ emit(stdout, options, summary, [`promote-plan: FAIL (${decision.reason})`]);
166
176
  return summary;
167
177
  }
168
178
 
@@ -174,7 +184,7 @@ export async function runCli(argv = process.argv.slice(2), {
174
184
  planFile: planPath,
175
185
  prNumber: decision.existingPrNumber,
176
186
  };
177
- emit(stdout, options.json, summary, [
187
+ emit(stdout, options, summary, [
178
188
  "promote-plan: PASS (already promoted)",
179
189
  ` plan: ${planPath}`,
180
190
  ` pr: #${decision.existingPrNumber} (existing; opened nothing)`,
@@ -207,8 +217,7 @@ export async function runCli(argv = process.argv.slice(2), {
207
217
 
208
218
  const fail = (reason, detail) => {
209
219
  const summary = { ok: false, reason, detail: detail ?? null, planFile: planPath };
210
- emit(stdout, options.json, summary, [`promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`]);
211
- process.exitCode = 1;
220
+ emit(stdout, options, summary, [`promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`]);
212
221
  return summary;
213
222
  };
214
223
 
@@ -294,11 +303,10 @@ export async function runCli(argv = process.argv.slice(2), {
294
303
  branch,
295
304
  recovery: `gh pr create failed, so no PR was opened, but the plan is committed and branch ${branch} is pushed. Re-run promote-plan to recover: the commit step is idempotent (skips when nothing is staged) and the push is idempotent, so a clean re-run will retry opening the PR.`,
296
305
  };
297
- emit(stdout, options.json, summary, [
306
+ emit(stdout, options, summary, [
298
307
  `promote-plan: FAIL (pr_create_failed)${detail ? `: ${detail}` : ""}`,
299
308
  ` branch: ${branch} (committed and pushed; no PR opened — re-run promote-plan to recover, it is idempotent and will retry opening the PR)`,
300
309
  ]);
301
- process.exitCode = 1;
302
310
  return summary;
303
311
  }
304
312
  const prNumber = parsePrNumberFromGhOutput(prCreate.stdout);
@@ -315,11 +323,10 @@ export async function runCli(argv = process.argv.slice(2), {
315
323
  branch,
316
324
  recovery: `A draft PR was opened on branch ${branch} but its number could not be parsed from gh output. Find it with: gh pr list --head "${branch}" --json number,url. Then record the link with: node scripts/refine/promote-plan.mjs is idempotent only once the front-matter prNumber is set, so add it manually (prNumber: <n>) and commit ${planDocRelPath}.`,
317
325
  };
318
- emit(stdout, options.json, summary, [
326
+ emit(stdout, options, summary, [
319
327
  `promote-plan: FAIL (pr_number_unparseable)`,
320
328
  ` branch: ${branch} (a draft PR is open; its number could not be parsed — recover via 'gh pr list --head "${branch}"')`,
321
329
  ]);
322
- process.exitCode = 1;
323
330
  return summary;
324
331
  }
325
332
 
@@ -340,11 +347,10 @@ export async function runCli(argv = process.argv.slice(2), {
340
347
  prNumber,
341
348
  recovery: `PR #${prNumber} is open but the plan->PR link commit failed. ${planDocRelPath} now carries prNumber: ${prNumber} in its front-matter; record the link with: git add -- "${planDocRelPath}" && git commit -m "docs(plan): link to PR #${prNumber}" && git push.`,
342
349
  };
343
- emit(stdout, options.json, summary, [
350
+ emit(stdout, options, summary, [
344
351
  `promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`,
345
352
  ` pr: #${prNumber} (open; plan->PR link NOT committed — recover manually)`,
346
353
  ]);
347
- process.exitCode = 1;
348
354
  return summary;
349
355
  };
350
356
  const linkedMarkdown = writeLinkedPrNumber(markdownText, prNumber);
@@ -370,7 +376,7 @@ export async function runCli(argv = process.argv.slice(2), {
370
376
  branch,
371
377
  prNumber,
372
378
  };
373
- emit(stdout, options.json, summary, [
379
+ emit(stdout, options, summary, [
374
380
  "promote-plan: PASS",
375
381
  ` plan: ${planPath}`,
376
382
  ` branch: ${branch}`,
@@ -80,7 +80,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
80
80
  }
81
81
  const tree = await loadTreeFromInput(options.input);
82
82
  const result = runProseLinkageDetector(tree);
83
- writeCheckerOutput(result, { stdout, json: options.json });
83
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
84
84
  return result;
85
85
  }
86
86
 
@@ -13,6 +13,7 @@ import {
13
13
  } from "@dev-loops/core/loop/plan-file-refine-contract";
14
14
  import { PLAN_FILE_REFINEMENT_SECTIONS } from "@dev-loops/core/loop/plan-file-intake-contract";
15
15
  import { classifyDocsGrillFinding } from "../loop/docs-grill-contract.mjs";
16
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
16
17
 
17
18
  const USAGE = `Usage:
18
19
  refine-plan-file.mjs --plan-file <path> --payload <path> [--json]
@@ -31,9 +32,14 @@ Required:
31
32
  Optional:
32
33
  --json Machine-readable JSON output
33
34
  --help Show this help
35
+
36
+ ${JQ_OUTPUT_USAGE}
37
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
38
+
34
39
  Exit codes:
35
40
  0 Refinement succeeded; plan written in place; stop for local human review
36
- 1 Argument error, or fail-closed refine/grill/ambiguous state (no write)`.trim();
41
+ 1 Argument error, or fail-closed refine/grill/ambiguous state (no write)
42
+ 2 Invalid --jq filter`.trim();
37
43
 
38
44
  const parseError = buildParseError(USAGE);
39
45
 
@@ -45,6 +51,7 @@ export function parseRefinePlanFileCliArgs(argv) {
45
51
  "plan-file": { type: "string" },
46
52
  payload: { type: "string" },
47
53
  json: { type: "boolean" },
54
+ ...JQ_OUTPUT_PARSE_OPTIONS,
48
55
  },
49
56
  allowPositionals: true,
50
57
  strict: false,
@@ -72,6 +79,7 @@ export function parseRefinePlanFileCliArgs(argv) {
72
79
  options.json = true;
73
80
  continue;
74
81
  }
82
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
75
83
  throw parseError(`Unknown argument: ${token.rawName}`);
76
84
  }
77
85
  if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
@@ -123,11 +131,12 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
123
131
  if (!result.ok) {
124
132
  // Fail closed: surface the reason, do not write, do not advance, do not promote.
125
133
  if (options.json) {
126
- stdout.write(`${JSON.stringify({ ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null })}\n`);
134
+ const failurePayload = { ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null };
135
+ process.exitCode = emitResult(failurePayload, { jq: options.jq, silent: options.silent, stdout });
127
136
  } else {
128
137
  stdout.write(`refine-plan-file: FAIL (${result.reason})\n`);
138
+ process.exitCode = 1;
129
139
  }
130
- process.exitCode = 1;
131
140
  return result;
132
141
  }
133
142
 
@@ -142,7 +151,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
142
151
  grillDispositions: result.grillDispositions,
143
152
  };
144
153
  if (options.json) {
145
- stdout.write(`${JSON.stringify(summary)}\n`);
154
+ process.exitCode = emitResult(summary, { jq: options.jq, silent: options.silent, stdout });
146
155
  } else {
147
156
  stdout.write(
148
157
  [
@@ -76,7 +76,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
76
76
  }
77
77
  const tree = await loadTreeFromInput(options.input);
78
78
  const result = runRefinementCompletenessChecker(tree);
79
- writeCheckerOutput(result, { stdout, json: options.json });
79
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
80
80
  return result;
81
81
  }
82
82
 
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ // Scaffold a STARTABLE spike findings artifact from an inline question (#988 P2).
3
+ //
4
+ // `/start-spike <question>` needs a findings artifact that passes
5
+ // `validateSpikeExplorationSections` (non-empty Question/Approach/Findings) so
6
+ // `resolve-dev-loop-startup --spike <path>` accepts it. This is the only new
7
+ // piece behind /start-spike: a pure section builder + a thin file writer. The
8
+ // Recommendation is left for the spike to fill in (the exit marker), matching
9
+ // the spike-mode contract — so this scaffolds an in-progress spike, not a
10
+ // ready-for-exit one. No new spike behavior; the intake/gate/exit machinery is
11
+ // the shipped #964/#965/#966 surface.
12
+ import { mkdir, writeFile } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { parseArgs } from "node:util";
15
+
16
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
17
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
+ import { SPIKE_FILE_EXPLORATION_SECTIONS } from "./validate-spike-file.mjs";
19
+
20
+ // Placeholder bodies the operator fills in during the spike. Non-empty so the
21
+ // exploration scaffold validates; explicitly marked TBD so they are obviously
22
+ // stubs, not findings. (`Recommendation` is intentionally omitted — it is the
23
+ // exit marker, written when the spike concludes.)
24
+ const PLACEHOLDER = "TBD — filled in during the spike.";
25
+
26
+ const USAGE = `Usage:
27
+ scaffold-spike-file.mjs --question <text> --out <path>
28
+
29
+ Scaffold a startable spike findings artifact from an inline question. Writes a
30
+ file carrying the exploration scaffold (## Question filled from --question,
31
+ ## Approach/## Findings stubbed) so \`resolve-dev-loop-startup --spike <path>\`
32
+ accepts it. The ## Recommendation is left for the spike to fill in.
33
+
34
+ Options:
35
+ --question <text> Required. The question the spike investigates.
36
+ --out <path> Required. Where to write the findings artifact.
37
+ --help, -h Show this help.
38
+
39
+ Output (stdout):
40
+ JSON: { ok: true, path: "<abs>", question: "<text>" }
41
+
42
+ ${JQ_OUTPUT_USAGE}
43
+ `.trim();
44
+
45
+ // Pure builder: question text -> a findings-artifact markdown body carrying the
46
+ // exploration scaffold. Question gets the operator's text; Approach/Findings get
47
+ // non-empty placeholder bodies so the exploration scaffold validates.
48
+ export function buildSpikeScaffold(question) {
49
+ const trimmed = String(question ?? "").trim();
50
+ if (trimmed.length === 0) {
51
+ throw Object.assign(new Error("--question must be non-empty"), { code: "INVALID_ARGS" });
52
+ }
53
+ const bodies = { Question: trimmed, Approach: PLACEHOLDER, Findings: PLACEHOLDER };
54
+ const sections = SPIKE_FILE_EXPLORATION_SECTIONS.map(
55
+ (heading) => `## ${heading}\n\n${bodies[heading]}\n`,
56
+ );
57
+ return `# Spike\n\n${sections.join("\n")}`;
58
+ }
59
+
60
+ function parseCliArgs(argv) {
61
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
62
+ const requireValue = (token, message) => {
63
+ const v = token.value;
64
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
65
+ throw parseError(message);
66
+ }
67
+ return v;
68
+ };
69
+ // The question is free text embedded in the artifact body, never forwarded as
70
+ // a CLI option — so unlike --out/--jq it may legitimately begin with `-`
71
+ // (e.g. "- should we ...?"). Only require a non-empty value, matching the
72
+ // core free-text parser; leave the leading-`-` guard to the path/filter flags.
73
+ const requireQuestion = (token, message) => {
74
+ const v = token.value;
75
+ if (typeof v !== "string" || v.length === 0) {
76
+ throw parseError(message);
77
+ }
78
+ return v;
79
+ };
80
+
81
+ const args = {};
82
+ const { tokens } = parseArgs({
83
+ args: [...argv],
84
+ options: {
85
+ question: { type: "string" },
86
+ out: { type: "string" },
87
+ json: { type: "boolean" },
88
+ help: { type: "boolean", short: "h" },
89
+ ...JQ_OUTPUT_PARSE_OPTIONS,
90
+ },
91
+ allowPositionals: true,
92
+ strict: false,
93
+ tokens: true,
94
+ });
95
+
96
+ for (const token of tokens) {
97
+ if (token.kind === "positional") {
98
+ throw parseError(`Unexpected argument: ${token.value}`);
99
+ }
100
+ if (token.kind !== "option") {
101
+ continue;
102
+ }
103
+ switch (token.name) {
104
+ case "help":
105
+ args.help = true;
106
+ break;
107
+ case "json":
108
+ // Output is JSON by default; accepted as a no-op for callers that pass it.
109
+ break;
110
+ case "question":
111
+ args.question = requireQuestion(token, "--question requires a value");
112
+ break;
113
+ case "out":
114
+ args.out = requireValue(token, "--out requires a value (path)");
115
+ break;
116
+ default: {
117
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
118
+ throw parseError(`Unknown flag: ${token.rawName}`);
119
+ }
120
+ }
121
+ }
122
+ return args;
123
+ }
124
+
125
+ export async function main(args, { writeFileImpl = writeFile, mkdirImpl = mkdir } = {}) {
126
+ if (!args.question) {
127
+ throw Object.assign(new Error("--question is required"), { usage: USAGE, code: "INVALID_ARGS" });
128
+ }
129
+ if (!args.out) {
130
+ throw Object.assign(new Error("--out is required"), { usage: USAGE, code: "INVALID_ARGS" });
131
+ }
132
+ const body = buildSpikeScaffold(args.question);
133
+ const outPath = path.resolve(args.out);
134
+ await mkdirImpl(path.dirname(outPath), { recursive: true });
135
+ // Fail closed on an existing file: the slash-command picks a fresh path and
136
+ // must never clobber a spike already in progress. `wx` makes write+exclusive
137
+ // atomic, so a concurrent create still loses (no overwrite).
138
+ try {
139
+ await writeFileImpl(outPath, body, { encoding: "utf8", flag: "wx" });
140
+ } catch (err) {
141
+ if (err?.code === "EEXIST") {
142
+ throw Object.assign(new Error(`--out already exists, refusing to overwrite: ${outPath}`), {
143
+ usage: USAGE,
144
+ code: "INVALID_ARGS",
145
+ });
146
+ }
147
+ throw err;
148
+ }
149
+ return { ok: true, path: outPath, question: String(args.question).trim() };
150
+ }
151
+
152
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
153
+ let args;
154
+ try {
155
+ args = parseCliArgs(argv);
156
+ } catch (err) {
157
+ stderr.write(`${formatCliError(err)}\n`);
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ if (args.help) {
162
+ stdout.write(`${USAGE}\n`);
163
+ return;
164
+ }
165
+ try {
166
+ const result = await main(args);
167
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
168
+ } catch (err) {
169
+ stderr.write(`${formatCliError(err)}\n`);
170
+ process.exitCode = err.code === "INVALID_ARGS" ? 1 : 2;
171
+ }
172
+ }
173
+
174
+ if (isDirectCliRun(import.meta.url)) {
175
+ runCli(process.argv.slice(2)).catch((error) => {
176
+ process.stderr.write(`${formatCliError(error)}\n`);
177
+ process.exitCode = 2;
178
+ });
179
+ }
@@ -151,7 +151,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
151
151
  }
152
152
  const tree = await loadTreeFromInput(options.input);
153
153
  const result = runScopeBoundaryCrossChecker(tree);
154
- writeCheckerOutput(result, { stdout, json: options.json });
154
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
155
155
  return result;
156
156
  }
157
157
 
@@ -199,7 +199,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
199
199
  }
200
200
  const tree = await loadTreeFromInput(options.input);
201
201
  const result = runTreeIntegrityValidator(tree);
202
- writeCheckerOutput(result, { stdout, json: options.json });
202
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
203
203
  return result;
204
204
  }
205
205
 
@@ -52,7 +52,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
52
52
  }
53
53
  const markdownText = await readFile(options.input, "utf8");
54
54
  const result = validatePlanFile(markdownText);
55
- writeCheckerOutput(result, { stdout, json: options.json });
55
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
56
56
  return result;
57
57
  }
58
58
 
@@ -75,7 +75,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
75
75
  }
76
76
  const markdownText = await readFile(options.input, "utf8");
77
77
  const result = validateSpikeFile(markdownText);
78
- writeCheckerOutput(result, { stdout, json: options.json });
78
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
79
79
  return result;
80
80
  }
81
81
 
@@ -9,6 +9,7 @@ import { runProseLinkageDetector } from "./prose-linkage-detector.mjs";
9
9
  import { runScopeBoundaryCrossChecker } from "./scope-boundary-cross-checker.mjs";
10
10
  import { runRefinementCompletenessChecker } from "./refinement-completeness-checker.mjs";
11
11
  import { runTreeIntegrityValidator } from "./tree-integrity-validator.mjs";
12
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
12
13
 
13
14
  const USAGE = `Usage:
14
15
  dev-loops refine verify --issue <number> [--repo <owner/name>] [--json]
@@ -26,7 +27,10 @@ Required (exactly one mode):
26
27
  Optional:
27
28
  --repo <owner/name> Repository slug for online mode
28
29
  --json Machine-readable JSON output (default: human-readable summary)
29
- --help Show this help`.trim();
30
+ --help Show this help
31
+
32
+ ${JQ_OUTPUT_USAGE}
33
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)`.trim();
30
34
 
31
35
  const parseError = buildParseError(USAGE);
32
36
 
@@ -39,6 +43,7 @@ export function parseRefineVerifyCliArgs(argv) {
39
43
  repo: { type: "string" },
40
44
  input: { type: "string" },
41
45
  json: { type: "boolean" },
46
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
47
  },
43
48
  allowPositionals: true,
44
49
  strict: false,
@@ -79,6 +84,7 @@ export function parseRefineVerifyCliArgs(argv) {
79
84
  options.json = true;
80
85
  continue;
81
86
  }
87
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
82
88
  throw parseError(`Unknown argument: ${token.rawName}`);
83
89
  }
84
90
 
@@ -177,13 +183,12 @@ export async function runCli(
177
183
  };
178
184
 
179
185
  if (options.json) {
180
- stdout.write(`${JSON.stringify(payload)}\n`);
186
+ process.exitCode = emitResult(payload, { jq: options.jq, silent: options.silent, stdout });
181
187
  } else {
182
188
  writeHumanOutput(result, tree, { stdout });
183
- }
184
-
185
- if (!result.ok) {
186
- process.exitCode = 1;
189
+ if (!result.ok) {
190
+ process.exitCode = 1;
191
+ }
187
192
  }
188
193
  return payload;
189
194
  }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release-time guard: fail closed when the published `dev-loops` package's
4
+ * `@dev-loops/core` dependency does not resolve to the same major.minor as the
5
+ * version being released. This is the check that would have caught #1033
6
+ * (`dev-loops@0.6.2` shipping `@dev-loops/core@^0.2.6`).
7
+ *
8
+ * Runs in release.yml BEFORE the GitHub Release is created, so a mismatched
9
+ * manifest never becomes a release (and never fires npm-publish). release.yml
10
+ * has no `npm ci`, so this script imports only `node:` builtins — a workspace
11
+ * import here would ERR_MODULE_NOT_FOUND and skip the guard entirely (same
12
+ * constraint as extract-changelog-section.mjs).
13
+ *
14
+ * Usage:
15
+ * node scripts/release/assert-core-dependency-version.mjs [--release-version <v>] [--manifest <path>]
16
+ *
17
+ * --release-version defaults to the manifest `version`. Pass the tag-derived
18
+ * version in release.yml so a manifest that forgot its own bump is also caught.
19
+ * Exits 0 on lockstep, 1 on mismatch, 2 on usage/parse errors.
20
+ */
21
+ import { realpathSync } from "node:fs";
22
+ import { readFile } from "node:fs/promises";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const CORE_DEP = "@dev-loops/core";
26
+
27
+ function isDirectCliRun(importMetaUrl, argv1 = process.argv[1]) {
28
+ if (typeof argv1 !== "string" || argv1.length === 0) return false;
29
+ try {
30
+ return realpathSync(argv1) === realpathSync(fileURLToPath(importMetaUrl));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Extract the `major.minor` token from a semver version or npm range.
38
+ * Handles leading range operators (^, ~, >=, >, <=, <, =, v) and compound
39
+ * ranges by taking the first `<digits>.<digits>` occurrence.
40
+ * @param {string} spec
41
+ * @returns {string} e.g. "0.6"
42
+ */
43
+ export function extractMajorMinor(spec) {
44
+ const match = String(spec).match(/(\d+)\.(\d+)/);
45
+ if (!match) {
46
+ throw new Error(`cannot parse a major.minor version from "${spec}"`);
47
+ }
48
+ return `${match[1]}.${match[2]}`;
49
+ }
50
+
51
+ /**
52
+ * Assert the `@dev-loops/core` dependency range is in lockstep (same
53
+ * major.minor) with the release version. Throws on mismatch (fail closed).
54
+ * @param {{releaseVersion: string, coreRange: string}} input
55
+ * @returns {{releaseVersion: string, coreRange: string, majorMinor: string}}
56
+ */
57
+ export function assertCoreDependencyInLockstep({ releaseVersion, coreRange } = {}) {
58
+ if (!releaseVersion) throw new Error("releaseVersion is required");
59
+ if (!coreRange) {
60
+ throw new Error(`root package must declare a "${CORE_DEP}" dependency`);
61
+ }
62
+ const releaseMajorMinor = extractMajorMinor(releaseVersion);
63
+ const coreMajorMinor = extractMajorMinor(coreRange);
64
+ if (releaseMajorMinor !== coreMajorMinor) {
65
+ throw new Error(
66
+ `${CORE_DEP} dependency "${coreRange}" (major.minor ${coreMajorMinor}) does not match ` +
67
+ `the release version ${releaseVersion} (major.minor ${releaseMajorMinor}). ` +
68
+ `Bump the ${CORE_DEP} range to ^${releaseMajorMinor}.0 before releasing (root cause of #1033).`,
69
+ );
70
+ }
71
+ return { releaseVersion, coreRange, majorMinor: releaseMajorMinor };
72
+ }
73
+
74
+ function usageError(message) {
75
+ const err = new Error(message);
76
+ err.usage = true;
77
+ return err;
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const out = { manifest: "package.json", releaseVersion: null };
82
+ for (let i = 0; i < argv.length; i += 1) {
83
+ const arg = argv[i];
84
+ if (arg === "--release-version" || arg === "--manifest") {
85
+ const value = argv[++i];
86
+ // Fail closed like extract-changelog-section.mjs: a flag missing its value
87
+ // is a usage error (exit 2), not a silent fallback that defeats the guard.
88
+ if (value === undefined) throw usageError(`${arg} requires a value`);
89
+ if (arg === "--release-version") out.releaseVersion = value;
90
+ else out.manifest = value;
91
+ } else {
92
+ throw usageError(`unknown argument: ${arg}`);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ async function main(argv) {
99
+ const { manifest, releaseVersion } = parseArgs(argv);
100
+ // An unreadable or invalid manifest is a usage/parse error (exit 2), not a
101
+ // lockstep mismatch (exit 1) — matches the header contract and extract-changelog-section.mjs.
102
+ let pkg;
103
+ try {
104
+ pkg = JSON.parse(await readFile(manifest, "utf8"));
105
+ } catch (err) {
106
+ throw usageError(`cannot read or parse manifest "${manifest}": ${err.message}`);
107
+ }
108
+ const coreRange = pkg.dependencies?.[CORE_DEP];
109
+ const result = assertCoreDependencyInLockstep({
110
+ releaseVersion: releaseVersion ?? pkg.version,
111
+ coreRange,
112
+ });
113
+ process.stdout.write(
114
+ `${CORE_DEP} ${result.coreRange} is in lockstep with release ${result.releaseVersion} (major.minor ${result.majorMinor}).\n`,
115
+ );
116
+ }
117
+
118
+ if (isDirectCliRun(import.meta.url)) {
119
+ main(process.argv.slice(2)).catch((err) => {
120
+ process.stderr.write(`::error::${err.message}\n`);
121
+ process.exit(err.usage ? 2 : 1);
122
+ });
123
+ }