dev-loops 0.6.0 → 0.7.2

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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
5
  import { parsePositiveInteger, requireTokenValue, runCommand } from "../_cli-primitives.mjs";
6
6
  import { parseArgs } from "node:util";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
8
  const DEFAULT_MAX_LINES = 1000;
8
9
  const DEFAULT_DUPLICATE_WINDOW_LINES = 4;
9
10
  const DEFAULT_BRANCH_THRESHOLD = 25;
@@ -35,7 +36,9 @@ Success output (stdout, JSON):
35
36
  }
36
37
  Failure behavior (stderr, JSON, exit 1):
37
38
  - malformed arguments, blank paths, invalid thresholds, and zero auditable files fail closed
38
- - findings are not a process failure; findings still return exit 0`.trim();
39
+ - findings are not a process failure; findings still return exit 0
40
+
41
+ ${JQ_OUTPUT_USAGE}`.trim();
39
42
  const parseError = buildParseError(USAGE);
40
43
  const BRANCH_TOKEN_PATTERN = /\b(?:if|else|switch|case|for|while|catch|finally|break|continue)\b|&&|\|\||\?(?![?.])/gu;
41
44
  const PRIORITY_ORDER = new Map([
@@ -67,6 +70,7 @@ export function parseRefinementAuditCliArgs(argv) {
67
70
  "branch-threshold": { type: "string" },
68
71
  "thin-wrapper-max-lines": { type: "string" },
69
72
  output: { type: "string" },
73
+ ...JQ_OUTPUT_PARSE_OPTIONS,
70
74
  },
71
75
  allowPositionals: true,
72
76
  strict: false,
@@ -127,6 +131,7 @@ export function parseRefinementAuditCliArgs(argv) {
127
131
  options.output = requireTokenValue(token, parseError, { flagPattern: /^-/u });
128
132
  continue;
129
133
  }
134
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
130
135
  throw parseError(`Unknown argument: ${token.rawName}`);
131
136
  }
132
137
  if (options.paths !== undefined && options.pathsFile !== undefined) {
@@ -512,22 +517,16 @@ export async function runCli(
512
517
  fullRepoScan: false,
513
518
  },
514
519
  };
515
- const serializedPayload = `${JSON.stringify(payload)}\n`;
516
520
  if (typeof options.output === "string") {
517
521
  const outputPath = path.resolve(cwd, options.output);
518
522
  await mkdir(path.dirname(outputPath), { recursive: true });
519
- await writeFile(outputPath, serializedPayload, "utf8");
523
+ await writeFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
520
524
  }
521
- stdout.write(serializedPayload);
525
+ process.exitCode = emitResult(payload, { jq: options.jq, silent: options.silent, stdout, stderr });
522
526
  return payload;
523
527
  }
524
528
  if (isDirectCliRun(import.meta.url)) {
525
529
  runCli()
526
- .then((result) => {
527
- if (result?.ok === false) {
528
- process.exitCode = 1;
529
- }
530
- })
531
530
  .catch((error) => {
532
531
  process.stderr.write(`${formatCliError(error)}\n`);
533
532
  process.exitCode = 1;
@@ -15,7 +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
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
19
19
  const REMOVED_FLAGS = new Set([
20
20
  "--force-rerequest-review",
21
21
  "--probe-only",
@@ -254,14 +254,7 @@ export function parseWatchCycleCliArgs(argv) {
254
254
  options.concise = true;
255
255
  continue;
256
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
- }
257
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
265
258
  throw parseError(`Unknown argument: ${token.rawName}`);
266
259
  }
267
260
  if (options.repo === undefined || options.pr === undefined) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Canonical sanctioned operation → wrapper command map (issue #1081).
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for "which wrapper does a dev-loop subagent use for
5
+ * which GitHub/loop operation". Previously this knowledge was re-derived per
6
+ * handoff and scattered across SKILL.md / copilot-loop-operations.md /
7
+ * pr-lifecycle-contract.md, so subagents burned cycles rediscovering e.g.
8
+ * `gh pr ready` → scripts/github/ready-for-review.mjs.
9
+ *
10
+ * This lives in the CONSUMER layer (scripts/), NOT in @dev-loops/core, because
11
+ * the values are THIS repo's `scripts/...` wrapper paths. Core stays
12
+ * consumer-agnostic: it defines the envelope SHAPE and carries whatever
13
+ * `sanctionedCommands` object the consumer supplies. The
14
+ * `loop build-envelope` CLI injects this map into every emitted envelope so a
15
+ * spawned subagent receives it verbatim by DEFAULT, regardless of who wrote
16
+ * the handoff.
17
+ *
18
+ * Wrapper paths are repo-root-relative. A contract test
19
+ * (test/contracts/sanctioned-commands-exist.test.mjs) asserts every mapped
20
+ * wrapper exists on disk and fails closed if one is renamed/removed.
21
+ */
22
+
23
+ export const SANCTIONED_COMMANDS = Object.freeze({
24
+ // Read-only lookups. Each value is the sanctioned wrapper; several also
25
+ // accept `loop info` as an equivalent aggregate read (noted in the doc).
26
+ reads: Object.freeze({
27
+ "pr-facts": "scripts/github/view-pr.mjs",
28
+ "ci-status": "scripts/github/probe-ci-status.mjs",
29
+ "ci-wait": "scripts/github/wait-pr-checks.mjs",
30
+ "ci-logs": "scripts/github/fetch-ci-logs.mjs",
31
+ "issue-list": "scripts/github/list-issues.mjs",
32
+ "copilot-review-state": "scripts/github/probe-copilot-review.mjs",
33
+ "review-threads": "scripts/github/list-review-threads.mjs",
34
+ "gate-coordination": "scripts/loop/detect-pr-gate-coordination-state.mjs",
35
+ }),
36
+
37
+ // Metadata edits.
38
+ edits: Object.freeze({
39
+ "pr-body-title-assignee-milestone": "scripts/github/edit-pr.mjs",
40
+ "issue-comment": "scripts/github/comment-issue.mjs",
41
+ }),
42
+
43
+ // Lifecycle mutations a subagent MAY perform (state transitions on the PR /
44
+ // review). Board status transitions are deliberately NOT here — they are
45
+ // orchestratorOwned (see below): in the current batch model the orchestrator
46
+ // owns board moves, and a subagent's In-Progress move rides ready-for-review.mjs
47
+ // (#1069), so there is no standalone subagent-sanctioned board-sync op.
48
+ lifecycle: Object.freeze({
49
+ "ready-for-review": "scripts/github/ready-for-review.mjs",
50
+ "pr-create": "scripts/github/create-pr.mjs",
51
+ "copilot-request": "scripts/github/request-copilot-review.mjs",
52
+ "reply-resolve-thread": "scripts/github/reply-resolve-review-thread.mjs",
53
+ "reply-resolve-threads": "scripts/github/reply-resolve-review-threads.mjs",
54
+ "gate-verdict-comment": "scripts/github/upsert-checkpoint-verdict.mjs",
55
+ }),
56
+
57
+ // Never allowed for any operation above — the sanctioned wrapper is mandatory.
58
+ forbidden: Object.freeze([
59
+ "gh pr view",
60
+ "gh pr checks",
61
+ "gh pr edit",
62
+ "node -e",
63
+ "node --input-type=module -e",
64
+ "node -p",
65
+ "python -c",
66
+ "python3 -c",
67
+ "tailing subagent transcripts",
68
+ "sleep-poll loops",
69
+ ]),
70
+
71
+ // Orchestrator-owned: a spawned dev-loop subagent must NEVER do these. Board
72
+ // status transitions live here (not in `lifecycle`) — the orchestrator owns
73
+ // move-queue-item/sync-item-status in the current batch model.
74
+ orchestratorOwned: Object.freeze([
75
+ "gh pr merge",
76
+ "board status transitions (move-queue-item / sync-item-status.mjs; current batch model)",
77
+ ]),
78
+ });
79
+
80
+ // The wrapper-path-bearing groups are the object-valued groups (reads/edits/
81
+ // lifecycle); `forbidden`/`orchestratorOwned` are string arrays, not path maps.
82
+ const PATH_GROUP_KEYS = Object.freeze(
83
+ Object.keys(SANCTIONED_COMMANDS).filter(
84
+ (k) => !Array.isArray(SANCTIONED_COMMANDS[k]),
85
+ ),
86
+ );
87
+
88
+ /**
89
+ * EVERY value named across the path-bearing groups — with NO shape filtering,
90
+ * so a malformed/typo'd entry is RETURNED (and then fails the contract test's
91
+ * shape+existence assertions) rather than being silently skipped. Auto-includes
92
+ * any future path-bearing group. This is what keeps the map from drifting off
93
+ * disk (#1081). For a well-formed map every element is a repo-root-relative
94
+ * `scripts/*.mjs` string; the no-filtering contract means a malformed map can
95
+ * yield a non-string here, which the contract test is designed to catch.
96
+ * @returns {unknown[]} wrapper-path values (strings for a well-formed map)
97
+ */
98
+ export function listSanctionedWrapperPaths() {
99
+ const out = [];
100
+ for (const key of PATH_GROUP_KEYS) {
101
+ for (const value of Object.values(SANCTIONED_COMMANDS[key])) {
102
+ out.push(value);
103
+ }
104
+ }
105
+ return out;
106
+ }
@@ -34,6 +34,7 @@ import {
34
34
  import { formatCliError } from "../_core-helpers.mjs";
35
35
  import { requireTokenValue as readSharedTokenValue } from "../_cli-primitives.mjs";
36
36
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
37
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
37
38
  const SUBMIT_USAGE = `Usage:
38
39
  steer-loop.mjs submit --repo <owner/name> --pr <number>
39
40
  --kind stop_at_next_safe_gate --directive <text> --seq <n>
@@ -61,7 +62,9 @@ Optional:
61
62
  Output (stdout, JSON):
62
63
  { "ok": true, "acknowledgement": { ... }, "result": { ... }, "steeringState": { ... } }
63
64
  Error output (stderr, JSON):
64
- { "ok": false, "error": "...", "usage": "..." }`.trim();
65
+ { "ok": false, "error": "...", "usage": "..." }
66
+
67
+ ${JQ_OUTPUT_USAGE}`.trim();
65
68
  const STATUS_USAGE = `Usage:
66
69
  steer-loop.mjs status --run-id <id> [--state-file <path>]
67
70
  steer-loop.mjs status --repo <owner/name> --pr <number> [--state-file <path>]
@@ -75,7 +78,9 @@ Optional:
75
78
  Output (stdout, JSON):
76
79
  { "ok": true, "status": { ... } }
77
80
  Error output (stderr, JSON):
78
- { "ok": false, "error": "...", "usage": "..." }`.trim();
81
+ { "ok": false, "error": "...", "usage": "..." }
82
+
83
+ ${JQ_OUTPUT_USAGE}`.trim();
79
84
  const PROMOTE_USAGE = `Usage:
80
85
  steer-loop.mjs promote --run-id <id> --loop-state <state> [--state-file <path>]
81
86
  steer-loop.mjs promote --repo <owner/name> --pr <number>
@@ -93,7 +98,9 @@ Optional:
93
98
  Output (stdout, JSON):
94
99
  { "ok": true, "promotedCount": <n>, "promoted": [ ... ], "steeringState": { ... } }
95
100
  Error output (stderr, JSON):
96
- { "ok": false, "error": "...", "usage": "..." }`.trim();
101
+ { "ok": false, "error": "...", "usage": "..." }
102
+
103
+ ${JQ_OUTPUT_USAGE}`.trim();
97
104
  const TOP_USAGE = `Usage:
98
105
  steer-loop.mjs <subcommand> [options]
99
106
  Subcommands:
@@ -171,6 +178,7 @@ export function parseSubmitCliArgs(argv) {
171
178
  "event-id": { type: "string" },
172
179
  "copilot-input": { type: "string" },
173
180
  "reviewer-input": { type: "string" },
181
+ ...JQ_OUTPUT_PARSE_OPTIONS,
174
182
  },
175
183
  allowPositionals: true,
176
184
  strict: false,
@@ -250,6 +258,7 @@ export function parseSubmitCliArgs(argv) {
250
258
  options.reviewerInputPath = readRequiredOptionValue(token, SUBMIT_USAGE);
251
259
  continue;
252
260
  }
261
+ if (matchJqOutputToken(token, options, (t) => readRequiredOptionValue(t, SUBMIT_USAGE))) continue;
253
262
  throw usageError(`Unknown argument: ${token.rawName}`, SUBMIT_USAGE);
254
263
  }
255
264
  if (!options.help) {
@@ -290,6 +299,7 @@ export function parseStatusCliArgs(argv) {
290
299
  repo: { type: "string" },
291
300
  pr: { type: "string" },
292
301
  "state-file": { type: "string" },
302
+ ...JQ_OUTPUT_PARSE_OPTIONS,
293
303
  },
294
304
  allowPositionals: true,
295
305
  strict: false,
@@ -324,6 +334,7 @@ export function parseStatusCliArgs(argv) {
324
334
  options.stateFile = readRequiredOptionValue(token, STATUS_USAGE);
325
335
  continue;
326
336
  }
337
+ if (matchJqOutputToken(token, options, (t) => readRequiredOptionValue(t, STATUS_USAGE))) continue;
327
338
  throw usageError(`Unknown argument: ${token.rawName}`, STATUS_USAGE);
328
339
  }
329
340
  if (!options.help) {
@@ -357,6 +368,7 @@ export function parsePromoteCliArgs(argv) {
357
368
  pr: { type: "string" },
358
369
  "state-file": { type: "string" },
359
370
  "loop-state": { type: "string" },
371
+ ...JQ_OUTPUT_PARSE_OPTIONS,
360
372
  },
361
373
  allowPositionals: true,
362
374
  strict: false,
@@ -399,6 +411,7 @@ export function parsePromoteCliArgs(argv) {
399
411
  options.loopState = val;
400
412
  continue;
401
413
  }
414
+ if (matchJqOutputToken(token, options, (t) => readRequiredOptionValue(t, PROMOTE_USAGE))) continue;
402
415
  throw usageError(`Unknown argument: ${token.rawName}`, PROMOTE_USAGE);
403
416
  }
404
417
  if (!options.help) {
@@ -651,7 +664,7 @@ function rejectUnsteerableInspection(inspection, { runId, eventId, seq, directiv
651
664
  }
652
665
  export async function runSubmit(
653
666
  argv = [],
654
- { stdout = process.stdout, cwd = process.cwd(), env = process.env, ghCommand = "gh" } = {},
667
+ { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, ghCommand = "gh" } = {},
655
668
  ) {
656
669
  const options = parseSubmitCliArgs(argv);
657
670
  if (options.help) {
@@ -793,12 +806,12 @@ export async function runSubmit(
793
806
  } catch {
794
807
  steeringState = createSteeringState(runId, target);
795
808
  }
796
- stdout.write(`${JSON.stringify({
809
+ process.exitCode = emitResult({
797
810
  ok: true,
798
811
  acknowledgement: validationRejection.acknowledgement,
799
812
  result: validationRejection.result,
800
813
  steeringState,
801
- })}\n`);
814
+ }, { jq: options.jq, silent: options.silent, stdout, stderr });
802
815
  return;
803
816
  }
804
817
  const { steeringState: newState, result } = await withStateFileLock(stateFilePath, async () => {
@@ -828,9 +841,9 @@ export async function runSubmit(
828
841
  safePointCategory,
829
842
  readbackPath,
830
843
  });
831
- stdout.write(`${JSON.stringify({ ok: true, acknowledgement, result, steeringState: newState })}\n`);
844
+ process.exitCode = emitResult({ ok: true, acknowledgement, result, steeringState: newState }, { jq: options.jq, silent: options.silent, stdout, stderr });
832
845
  }
833
- export async function runStatus(argv = [], { stdout = process.stdout, cwd = process.cwd() } = {}) {
846
+ export async function runStatus(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
834
847
  const options = parseStatusCliArgs(argv);
835
848
  if (options.help) {
836
849
  stdout.write(`${STATUS_USAGE}\n`);
@@ -843,9 +856,9 @@ export async function runStatus(argv = [], { stdout = process.stdout, cwd = proc
843
856
  const stateFilePath = options.stateFile ?? (target ? defaultStateFilePathForTarget(target, cwd) : defaultStateFilePath(runId, cwd));
844
857
  const steeringState = await loadOrCreateSteeringState(stateFilePath, runId, target);
845
858
  const status = getSteeringStatus(steeringState);
846
- stdout.write(`${JSON.stringify({ ok: true, status })}\n`);
859
+ process.exitCode = emitResult({ ok: true, status }, { jq: options.jq, silent: options.silent, stdout, stderr });
847
860
  }
848
- export async function runPromote(argv = [], { stdout = process.stdout, cwd = process.cwd() } = {}) {
861
+ export async function runPromote(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
849
862
  const options = parsePromoteCliArgs(argv);
850
863
  if (options.help) {
851
864
  stdout.write(`${PROMOTE_USAGE}\n`);
@@ -864,16 +877,16 @@ export async function runPromote(argv = [], { stdout = process.stdout, cwd = pro
864
877
  }
865
878
  return nextState;
866
879
  });
867
- stdout.write(`${JSON.stringify({
880
+ process.exitCode = emitResult({
868
881
  ok: true,
869
882
  promotedCount: promotedState.promoted.length,
870
883
  promoted: promotedState.promoted,
871
884
  steeringState: promotedState.steeringState,
872
- })}\n`);
885
+ }, { jq: options.jq, silent: options.silent, stdout, stderr });
873
886
  }
874
887
  export async function runCli(
875
888
  argv = process.argv.slice(2),
876
- { stdout = process.stdout, cwd = process.cwd(), env = process.env, ghCommand = "gh" } = {},
889
+ { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, ghCommand = "gh" } = {},
877
890
  ) {
878
891
  const [subcommand, ...rest] = argv;
879
892
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
@@ -881,13 +894,13 @@ export async function runCli(
881
894
  return;
882
895
  }
883
896
  if (subcommand === "submit") {
884
- return runSubmit(rest, { stdout, cwd, env, ghCommand });
897
+ return runSubmit(rest, { stdout, stderr, cwd, env, ghCommand });
885
898
  }
886
899
  if (subcommand === "promote") {
887
- return runPromote(rest, { stdout, cwd });
900
+ return runPromote(rest, { stdout, stderr, cwd });
888
901
  }
889
902
  if (subcommand === "status") {
890
- return runStatus(rest, { stdout, cwd });
903
+ return runStatus(rest, { stdout, stderr, cwd });
891
904
  }
892
905
  const error = usageError(`Unknown subcommand: ${subcommand}`, TOP_USAGE);
893
906
  throw error;
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate that a PR body carries the invariants required to serve as the
4
+ * lightweight spec-of-record (issue #1025): Objective/why, in-scope, explicit
5
+ * non-goals, testable Acceptance criteria, Definition of done, and Open
6
+ * questions/risks.
7
+ *
8
+ * Thin CLI wrapper (parallel to detect-issue-refinement-artifact.mjs): fetches
9
+ * the PR body via `gh pr view <n> --json body` and runs the shared pure
10
+ * validator `validatePrBodySpec` (reuses issue-refinement-artifact markdown
11
+ * logic — no parallel validator). Fails closed (non-zero + per-section reason)
12
+ * when any invariant is missing.
13
+ */
14
+ import { readFile } from "node:fs/promises";
15
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
16
+ import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
17
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
18
+ import { parseArgs } from "node:util";
19
+ import { validatePrBodySpec } from "@dev-loops/core/loop/issue-refinement-artifact";
20
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
21
+
22
+ const USAGE = `Usage:
23
+ validate-pr-body-spec.mjs --repo <owner/name> --pr <number> [--expected-issue <n>]
24
+ validate-pr-body-spec.mjs --input <path> [--expected-issue <n>]
25
+ Validate that a PR body carries the lightweight spec-of-record invariants
26
+ (Objective/why, In scope, Explicit non-goals, testable Acceptance criteria,
27
+ Definition of done, Open questions/risks, and — unless --no-issue is given —
28
+ a GitHub closing-keyword issue reference such as \`Closes #123\`).
29
+ Required (exactly one):
30
+ --repo <owner/name> --pr <number> Fetch the PR body via gh and validate it
31
+ --input <path> Path to a JSON file with { "body": "..." }
32
+ (optional "repo" and "pr" fields are echoed
33
+ back in the result when present)
34
+ Optional:
35
+ --expected-issue <n> Positive integer; the referenced closing
36
+ issue(s) must include this number, else
37
+ "closes_wrong_issue". Mutually exclusive
38
+ with --no-issue.
39
+ --no-issue Explicit issue-less mode (#1210): the PR
40
+ is the sole artifact, so a closing-issue
41
+ reference is FORBIDDEN rather than
42
+ required — one present fails closed under
43
+ "unexpected_closing_issue_reference".
44
+ Mutually exclusive with --expected-issue.
45
+ Success output (stdout, JSON):
46
+ {
47
+ "ok": true | false,
48
+ "checker": "validate-pr-body-spec",
49
+ "repo": "owner/name" | null,
50
+ "pr": 123 | null,
51
+ "errors": [ { "code": "missing_...", "message": "..." } ],
52
+ "sections": [...],
53
+ "acItems": [...],
54
+ "dodItems": [...],
55
+ "closesIssues": [...]
56
+ }
57
+ Exit: 0 when every invariant is present; 1 (fail closed) when any is missing.
58
+ Error output (stderr, JSON):
59
+ { "ok": false, "error": "...", "usage": "..." }
60
+ ${JQ_OUTPUT_USAGE}`.trim();
61
+
62
+ const parseError = buildParseError(USAGE);
63
+
64
+ export function parseValidatePrBodySpecCliArgs(argv) {
65
+ const options = {
66
+ help: false,
67
+ repo: undefined,
68
+ pr: undefined,
69
+ input: undefined,
70
+ expectedIssue: undefined,
71
+ noIssue: false,
72
+ };
73
+ const { tokens } = parseArgs({
74
+ args: [...argv],
75
+ options: {
76
+ help: { type: "boolean", short: "h" },
77
+ repo: { type: "string" },
78
+ pr: { type: "string" },
79
+ input: { type: "string" },
80
+ "expected-issue": { type: "string" },
81
+ "no-issue": { type: "boolean" },
82
+ ...JQ_OUTPUT_PARSE_OPTIONS,
83
+ },
84
+ allowPositionals: true,
85
+ strict: false,
86
+ tokens: true,
87
+ });
88
+ for (const token of tokens) {
89
+ if (token.kind === "positional") {
90
+ throw parseError(`Unknown argument: ${token.value}`);
91
+ }
92
+ if (token.kind !== "option") {
93
+ continue;
94
+ }
95
+ if (token.name === "help") {
96
+ options.help = true;
97
+ return options;
98
+ }
99
+ if (token.name === "repo") {
100
+ options.repo = requireTokenValue(token, parseError).trim();
101
+ continue;
102
+ }
103
+ if (token.name === "pr") {
104
+ const value = requireTokenValue(token, parseError);
105
+ if (!/^\d+$/.test(value) || Number(value) === 0) {
106
+ throw parseError("--pr must be a positive integer");
107
+ }
108
+ options.pr = Number(value);
109
+ continue;
110
+ }
111
+ if (token.name === "input") {
112
+ options.input = requireTokenValue(token, parseError).trim();
113
+ continue;
114
+ }
115
+ if (token.name === "expected-issue") {
116
+ const value = requireTokenValue(token, parseError);
117
+ if (!/^\d+$/.test(value) || Number(value) === 0) {
118
+ throw parseError("--expected-issue must be a positive integer");
119
+ }
120
+ options.expectedIssue = Number(value);
121
+ continue;
122
+ }
123
+ if (token.name === "no-issue") {
124
+ options.noIssue = true;
125
+ continue;
126
+ }
127
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
128
+ throw parseError(`Unknown argument: ${token.rawName}`);
129
+ }
130
+ if (options.help) {
131
+ return options;
132
+ }
133
+ if (options.noIssue && Number.isInteger(options.expectedIssue)) {
134
+ throw parseError("--no-issue is mutually exclusive with --expected-issue; provide exactly one issue-linkage mode");
135
+ }
136
+ const hasInput = typeof options.input === "string" && options.input.length > 0;
137
+ const hasAnyRemote = (typeof options.repo === "string" && options.repo.length > 0) || Number.isInteger(options.pr);
138
+ const hasRemotePair = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.pr);
139
+ // --input and the remote mode are mutually exclusive input sources: a stray
140
+ // --repo/--pr alongside --input must fail closed, not be silently ignored.
141
+ if (hasInput && hasAnyRemote) {
142
+ throw parseError("--input is mutually exclusive with --repo/--pr; provide exactly one input mode");
143
+ }
144
+ if (hasInput === hasRemotePair) {
145
+ throw parseError("Provide exactly one of --input <path> or --repo <owner/name> --pr <number>");
146
+ }
147
+ return options;
148
+ }
149
+
150
+ async function fetchPrBody({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
151
+ const result = await runChild(
152
+ ghCommand,
153
+ ["pr", "view", String(pr), "--repo", repo, "--json", "body"],
154
+ env,
155
+ );
156
+ if (result.code !== 0) {
157
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
158
+ throw new Error(`gh command failed: ${detail}`);
159
+ }
160
+ const payload = parseJsonText(result.stdout, { label: "gh pr view" });
161
+ if (!payload || typeof payload !== "object") {
162
+ throw new Error("Invalid gh pr view payload: missing body");
163
+ }
164
+ return typeof payload.body === "string" ? payload.body : "";
165
+ }
166
+
167
+ async function loadInputPayload(inputPath) {
168
+ const text = await readFile(inputPath, "utf8");
169
+ const payload = parseJsonText(text, { label: `input file ${inputPath}` });
170
+ if (!payload || typeof payload !== "object") {
171
+ throw new Error(`Input file ${inputPath} must be a JSON object`);
172
+ }
173
+ return payload;
174
+ }
175
+
176
+ export async function validatePrBodySpecFromOptions(options, { env = process.env, ghCommand = "gh" } = {}) {
177
+ if (typeof options.input === "string" && options.input.length > 0) {
178
+ const payload = await loadInputPayload(options.input);
179
+ const body = typeof payload.body === "string" ? payload.body : "";
180
+ const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
181
+ const pr = Number.isInteger(payload.pr) ? payload.pr : options.pr ?? null;
182
+ return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }), repo, pr };
183
+ }
184
+ parseRepoSlug(options.repo);
185
+ const body = await fetchPrBody({ repo: options.repo, pr: options.pr }, { env, ghCommand });
186
+ return {
187
+ ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }),
188
+ repo: options.repo,
189
+ pr: options.pr,
190
+ };
191
+ }
192
+
193
+ export async function runCli(
194
+ argv = process.argv.slice(2),
195
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
196
+ ) {
197
+ let options;
198
+ try {
199
+ options = parseValidatePrBodySpecCliArgs(argv);
200
+ } catch (error) {
201
+ stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
202
+ return 1;
203
+ }
204
+ if (options.help) {
205
+ stdout.write(`${USAGE}\n`);
206
+ return 0;
207
+ }
208
+ try {
209
+ const result = await validatePrBodySpecFromOptions(options, { env, ghCommand });
210
+ // Fail closed: a body missing any invariant maps result.ok=false to exit 1.
211
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
212
+ } catch (error) {
213
+ stderr.write(`${formatCliError(error)}\n`);
214
+ return 1;
215
+ }
216
+ }
217
+
218
+ if (isDirectCliRun(import.meta.url)) {
219
+ const code = await runCli();
220
+ if (code !== 0) {
221
+ process.exitCode = code;
222
+ }
223
+ }
@@ -5,6 +5,7 @@ import { parseArgs } from "node:util";
5
5
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
6
  import { parseIssueNumber, requireTokenValue } from "../_cli-primitives.mjs";
7
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
9
  import { detectInitialCopilotPrState, LINKED_PR_STATE } from "./detect-initial-copilot-pr-state.mjs";
9
10
  import { enforcePersistentInternalWaitTimeout } from "@dev-loops/core/loop/timeout-policy";
10
11
  import {
@@ -45,9 +46,11 @@ Error output (stderr, JSON):
45
46
  { "ok": false, "error": "...", "usage": "..." }
46
47
  gh/runtime failures:
47
48
  { "ok": false, "error": "..." }
49
+ ${JQ_OUTPUT_USAGE}
48
50
  Exit codes:
49
51
  0 Success (ready_for_followup, timed_out, and prior_linked_pr_closed_unmerged are all ok:true)
50
- 1 Argument error or gh failure`.trim();
52
+ 1 Argument error or gh failure
53
+ 2 Invalid --jq filter`.trim();
51
54
  const parseError = buildParseError(USAGE);
52
55
  function rejectRemovedFlag(token) {
53
56
  throw parseError(
@@ -108,6 +111,7 @@ export function parseWatchInitialCopilotPrCliArgs(argv) {
108
111
  help: { type: "boolean", short: "h" },
109
112
  repo: { type: "string" },
110
113
  issue: { type: "string" },
114
+ ...JQ_OUTPUT_PARSE_OPTIONS,
111
115
  },
112
116
  allowPositionals: true,
113
117
  strict: false,
@@ -135,6 +139,7 @@ export function parseWatchInitialCopilotPrCliArgs(argv) {
135
139
  options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
136
140
  continue;
137
141
  }
142
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
138
143
  throw parseError(`Unknown argument: ${token.rawName}`);
139
144
  }
140
145
  if (options.repo === undefined || options.issue === undefined) {
@@ -251,7 +256,7 @@ export async function watchInitialCopilotPr(
251
256
  }
252
257
  export async function runCli(
253
258
  argv = process.argv.slice(2),
254
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
259
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
255
260
  ) {
256
261
  const options = parseWatchInitialCopilotPrCliArgs(argv);
257
262
  if (options.help) {
@@ -259,7 +264,7 @@ export async function runCli(
259
264
  return;
260
265
  }
261
266
  const result = await watchInitialCopilotPr(options, { env, ghCommand });
262
- stdout.write(`${JSON.stringify(result)}\n`);
267
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
263
268
  }
264
269
  if (isDirectCliRun(import.meta.url)) {
265
270
  runCli().catch((error) => {