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
@@ -0,0 +1,104 @@
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-logs": "scripts/github/fetch-ci-logs.mjs",
30
+ "issue-list": "scripts/github/list-issues.mjs",
31
+ "copilot-review-state": "scripts/github/probe-copilot-review.mjs",
32
+ "gate-coordination": "scripts/loop/detect-pr-gate-coordination-state.mjs",
33
+ }),
34
+
35
+ // Metadata edits.
36
+ edits: Object.freeze({
37
+ "pr-body-title-assignee-milestone": "scripts/github/edit-pr.mjs",
38
+ "issue-comment": "scripts/github/comment-issue.mjs",
39
+ }),
40
+
41
+ // Lifecycle mutations a subagent MAY perform (state transitions on the PR /
42
+ // review). Board status transitions are deliberately NOT here — they are
43
+ // orchestratorOwned (see below): in the current batch model the orchestrator
44
+ // owns board moves, and a subagent's In-Progress move rides ready-for-review.mjs
45
+ // (#1069), so there is no standalone subagent-sanctioned board-sync op.
46
+ lifecycle: Object.freeze({
47
+ "ready-for-review": "scripts/github/ready-for-review.mjs",
48
+ "pr-create": "scripts/github/create-pr.mjs",
49
+ "copilot-request": "scripts/github/request-copilot-review.mjs",
50
+ "reply-resolve-thread": "scripts/github/reply-resolve-review-thread.mjs",
51
+ "reply-resolve-threads": "scripts/github/reply-resolve-review-threads.mjs",
52
+ "gate-verdict-comment": "scripts/github/upsert-checkpoint-verdict.mjs",
53
+ }),
54
+
55
+ // Never allowed for any operation above — the sanctioned wrapper is mandatory.
56
+ forbidden: Object.freeze([
57
+ "gh pr view",
58
+ "gh pr checks",
59
+ "gh pr edit",
60
+ "node -e",
61
+ "node --input-type=module -e",
62
+ "node -p",
63
+ "python -c",
64
+ "python3 -c",
65
+ "tailing subagent transcripts",
66
+ "sleep-poll loops",
67
+ ]),
68
+
69
+ // Orchestrator-owned: a spawned dev-loop subagent must NEVER do these. Board
70
+ // status transitions live here (not in `lifecycle`) — the orchestrator owns
71
+ // move-queue-item/sync-item-status in the current batch model.
72
+ orchestratorOwned: Object.freeze([
73
+ "gh pr merge",
74
+ "board status transitions (move-queue-item / sync-item-status.mjs; current batch model)",
75
+ ]),
76
+ });
77
+
78
+ // The wrapper-path-bearing groups are the object-valued groups (reads/edits/
79
+ // lifecycle); `forbidden`/`orchestratorOwned` are string arrays, not path maps.
80
+ const PATH_GROUP_KEYS = Object.freeze(
81
+ Object.keys(SANCTIONED_COMMANDS).filter(
82
+ (k) => !Array.isArray(SANCTIONED_COMMANDS[k]),
83
+ ),
84
+ );
85
+
86
+ /**
87
+ * EVERY value named across the path-bearing groups — with NO shape filtering,
88
+ * so a malformed/typo'd entry is RETURNED (and then fails the contract test's
89
+ * shape+existence assertions) rather than being silently skipped. Auto-includes
90
+ * any future path-bearing group. This is what keeps the map from drifting off
91
+ * disk (#1081). For a well-formed map every element is a repo-root-relative
92
+ * `scripts/*.mjs` string; the no-filtering contract means a malformed map can
93
+ * yield a non-string here, which the contract test is designed to catch.
94
+ * @returns {unknown[]} wrapper-path values (strings for a well-formed map)
95
+ */
96
+ export function listSanctionedWrapperPaths() {
97
+ const out = [];
98
+ for (const key of PATH_GROUP_KEYS) {
99
+ for (const value of Object.values(SANCTIONED_COMMANDS[key])) {
100
+ out.push(value);
101
+ }
102
+ }
103
+ return out;
104
+ }
@@ -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,207 @@
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 a GitHub closing-keyword issue
28
+ 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".
38
+ Success output (stdout, JSON):
39
+ {
40
+ "ok": true | false,
41
+ "checker": "validate-pr-body-spec",
42
+ "repo": "owner/name" | null,
43
+ "pr": 123 | null,
44
+ "errors": [ { "code": "missing_...", "message": "..." } ],
45
+ "sections": [...],
46
+ "acItems": [...],
47
+ "dodItems": [...],
48
+ "closesIssues": [...]
49
+ }
50
+ Exit: 0 when every invariant is present; 1 (fail closed) when any is missing.
51
+ Error output (stderr, JSON):
52
+ { "ok": false, "error": "...", "usage": "..." }
53
+ ${JQ_OUTPUT_USAGE}`.trim();
54
+
55
+ const parseError = buildParseError(USAGE);
56
+
57
+ export function parseValidatePrBodySpecCliArgs(argv) {
58
+ const options = {
59
+ help: false,
60
+ repo: undefined,
61
+ pr: undefined,
62
+ input: undefined,
63
+ expectedIssue: undefined,
64
+ };
65
+ const { tokens } = parseArgs({
66
+ args: [...argv],
67
+ options: {
68
+ help: { type: "boolean", short: "h" },
69
+ repo: { type: "string" },
70
+ pr: { type: "string" },
71
+ input: { type: "string" },
72
+ "expected-issue": { type: "string" },
73
+ ...JQ_OUTPUT_PARSE_OPTIONS,
74
+ },
75
+ allowPositionals: true,
76
+ strict: false,
77
+ tokens: true,
78
+ });
79
+ for (const token of tokens) {
80
+ if (token.kind === "positional") {
81
+ throw parseError(`Unknown argument: ${token.value}`);
82
+ }
83
+ if (token.kind !== "option") {
84
+ continue;
85
+ }
86
+ if (token.name === "help") {
87
+ options.help = true;
88
+ return options;
89
+ }
90
+ if (token.name === "repo") {
91
+ options.repo = requireTokenValue(token, parseError).trim();
92
+ continue;
93
+ }
94
+ if (token.name === "pr") {
95
+ const value = requireTokenValue(token, parseError);
96
+ if (!/^\d+$/.test(value) || Number(value) === 0) {
97
+ throw parseError("--pr must be a positive integer");
98
+ }
99
+ options.pr = Number(value);
100
+ continue;
101
+ }
102
+ if (token.name === "input") {
103
+ options.input = requireTokenValue(token, parseError).trim();
104
+ continue;
105
+ }
106
+ if (token.name === "expected-issue") {
107
+ const value = requireTokenValue(token, parseError);
108
+ if (!/^\d+$/.test(value) || Number(value) === 0) {
109
+ throw parseError("--expected-issue must be a positive integer");
110
+ }
111
+ options.expectedIssue = Number(value);
112
+ continue;
113
+ }
114
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
115
+ throw parseError(`Unknown argument: ${token.rawName}`);
116
+ }
117
+ if (options.help) {
118
+ return options;
119
+ }
120
+ const hasInput = typeof options.input === "string" && options.input.length > 0;
121
+ const hasAnyRemote = (typeof options.repo === "string" && options.repo.length > 0) || Number.isInteger(options.pr);
122
+ const hasRemotePair = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.pr);
123
+ // --input and the remote mode are mutually exclusive input sources: a stray
124
+ // --repo/--pr alongside --input must fail closed, not be silently ignored.
125
+ if (hasInput && hasAnyRemote) {
126
+ throw parseError("--input is mutually exclusive with --repo/--pr; provide exactly one input mode");
127
+ }
128
+ if (hasInput === hasRemotePair) {
129
+ throw parseError("Provide exactly one of --input <path> or --repo <owner/name> --pr <number>");
130
+ }
131
+ return options;
132
+ }
133
+
134
+ async function fetchPrBody({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
135
+ const result = await runChild(
136
+ ghCommand,
137
+ ["pr", "view", String(pr), "--repo", repo, "--json", "body"],
138
+ env,
139
+ );
140
+ if (result.code !== 0) {
141
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
142
+ throw new Error(`gh command failed: ${detail}`);
143
+ }
144
+ const payload = parseJsonText(result.stdout, { label: "gh pr view" });
145
+ if (!payload || typeof payload !== "object") {
146
+ throw new Error("Invalid gh pr view payload: missing body");
147
+ }
148
+ return typeof payload.body === "string" ? payload.body : "";
149
+ }
150
+
151
+ async function loadInputPayload(inputPath) {
152
+ const text = await readFile(inputPath, "utf8");
153
+ const payload = parseJsonText(text, { label: `input file ${inputPath}` });
154
+ if (!payload || typeof payload !== "object") {
155
+ throw new Error(`Input file ${inputPath} must be a JSON object`);
156
+ }
157
+ return payload;
158
+ }
159
+
160
+ export async function validatePrBodySpecFromOptions(options, { env = process.env, ghCommand = "gh" } = {}) {
161
+ if (typeof options.input === "string" && options.input.length > 0) {
162
+ const payload = await loadInputPayload(options.input);
163
+ const body = typeof payload.body === "string" ? payload.body : "";
164
+ const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
165
+ const pr = Number.isInteger(payload.pr) ? payload.pr : options.pr ?? null;
166
+ return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }), repo, pr };
167
+ }
168
+ parseRepoSlug(options.repo);
169
+ const body = await fetchPrBody({ repo: options.repo, pr: options.pr }, { env, ghCommand });
170
+ return {
171
+ ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }),
172
+ repo: options.repo,
173
+ pr: options.pr,
174
+ };
175
+ }
176
+
177
+ export async function runCli(
178
+ argv = process.argv.slice(2),
179
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
180
+ ) {
181
+ let options;
182
+ try {
183
+ options = parseValidatePrBodySpecCliArgs(argv);
184
+ } catch (error) {
185
+ stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
186
+ return 1;
187
+ }
188
+ if (options.help) {
189
+ stdout.write(`${USAGE}\n`);
190
+ return 0;
191
+ }
192
+ try {
193
+ const result = await validatePrBodySpecFromOptions(options, { env, ghCommand });
194
+ // Fail closed: a body missing any invariant maps result.ok=false to exit 1.
195
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
196
+ } catch (error) {
197
+ stderr.write(`${formatCliError(error)}\n`);
198
+ return 1;
199
+ }
200
+ }
201
+
202
+ if (isDirectCliRun(import.meta.url)) {
203
+ const code = await runCli();
204
+ if (code !== 0) {
205
+ process.exitCode = code;
206
+ }
207
+ }
@@ -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) => {
@@ -8,6 +8,7 @@
8
8
  import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
9
9
  import { dirname, join, resolve, parse as parsePath } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { buildStateAtlasHtml } from './build-state-atlas.mjs';
11
12
 
12
13
  const REPO_ROOT_DEFAULT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
14
 
@@ -46,27 +47,59 @@ export const DECKS = [
46
47
  // Resolve a deck's published filename: distinct outFile when set, else file.
47
48
  const deckOut = (deck) => deck.outFile ?? deck.file;
48
49
 
50
+ // The State atlas: a generated page (site/state-atlas.html) rendering every
51
+ // dev-loops state machine as mermaid diagrams straight from the code's tables.
52
+ export const STATE_ATLAS = { file: 'state-atlas.html', label: 'State atlas' };
53
+
49
54
  // The other resources linked from the navigation, in order.
50
55
  export const NAV_LINKS = [
51
56
  ...ARTICLES.map((a) => ({ file: a.file, label: a.navLabel })),
52
57
  ...DECKS.map((d) => ({ file: deckOut(d), label: d.navLabel })),
58
+ { file: STATE_ATLAS.file, label: STATE_ATLAS.label },
53
59
  ];
54
60
 
55
61
  // Nav styling, appended to each article page's own <style> block so it reuses
56
62
  // the article design-system variables (--heading/--kicker/--accent-soft).
57
63
  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); }
64
+ .site-nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 1.1rem; max-width: 48rem; margin: 0 auto; padding: 0.9rem clamp(1.1rem, 5vw, 2rem); border-bottom: 1px solid rgba(148, 163, 184, 0.16); }
65
+ @media (min-width: 900px) { .site-nav { max-width: 56rem; } }
59
66
  .site-nav-brand { font-weight: 700; letter-spacing: -0.01em; color: var(--heading); text-decoration: none; border: 0; margin-right: auto; }
60
67
  .site-nav-links { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
61
68
  .site-nav a { color: var(--kicker); text-decoration: none; font-size: 0.9rem; border: 0; }
62
- .site-nav a:hover { color: var(--accent-soft); }`;
69
+ .site-nav a:hover { color: var(--accent-soft); }
70
+ .site-nav-gh { display: inline-flex; align-items: center; }
71
+ .site-nav-gh svg { width: 1.125rem; height: 1.125rem; fill: currentColor; display: block; }`;
72
+
73
+ // The repository the site links to from its nav. Derived from the effective
74
+ // repoRoot's package.json repository.url (single source of truth) so a repo
75
+ // rename/move updates the nav link too, rather than drifting from a hardcoded
76
+ // copy. Resolved inside buildSite from its repoRoot param (not at module load)
77
+ // so --repo-root / buildSite({ repoRoot }) reads the right package.json and no
78
+ // I/O runs merely on import. The '.git' suffix is stripped for the web URL.
79
+ // npm's `repository` field may be a string ("github:owner/repo" or a full URL)
80
+ // or an object { type, url }. Accept both; fail with an explicit, actionable
81
+ // message when neither yields a URL, rather than an implicit TypeError.
82
+ export async function resolveRepoUrl(repoRoot) {
83
+ const { repository } = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
84
+ const raw = typeof repository === 'string' ? repository : repository?.url;
85
+ if (!raw) {
86
+ throw new Error(`cannot resolve repo URL: package.json at ${repoRoot} has no repository.url`);
87
+ }
88
+ return raw
89
+ .replace(/^github:/, 'https://github.com/')
90
+ .replace(/\.git$/, '');
91
+ }
92
+ // Inline SVG (GitHub octicon mark) so it renders under the page's strict CSP
93
+ // (img-src is data:-only; no external icon).
94
+ const GITHUB_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>';
63
95
 
64
- function navMarkup() {
96
+ function navMarkup(repoUrl) {
65
97
  const links = NAV_LINKS.map((l) => ` <a href="${l.file}">${l.label}</a>`).join('\n');
66
98
  return `<nav class="site-nav" aria-label="dev-loops resources">
67
99
  <a class="site-nav-brand" href="index.html">dev-loops</a>
68
100
  <div class="site-nav-links">
69
101
  ${links}
102
+ <a class="site-nav-gh" href="${repoUrl}" aria-label="dev-loops on GitHub">${GITHUB_ICON}</a>
70
103
  </div>
71
104
  </nav>`;
72
105
  }
@@ -75,13 +108,13 @@ ${links}
75
108
  // markup right after <body>. Idempotent enough for assembly (each source file
76
109
  // is read once). Throws if the page lacks the expected anchors so a structural
77
110
  // drift fails the build rather than publishing an un-navigable page.
78
- export function injectNav(html) {
111
+ export function injectNav(html, repoUrl) {
79
112
  if (!html.includes('</style>') || !/<body[^>]*>/.test(html)) {
80
113
  throw new Error('cannot inject nav: page is missing a <style> block or <body> tag');
81
114
  }
82
115
  return html
83
116
  .replace('</style>', `${NAV_CSS}\n</style>`)
84
- .replace(/<body([^>]*)>/, `<body$1>\n ${navMarkup()}`);
117
+ .replace(/<body([^>]*)>/, `<body$1>\n ${navMarkup(repoUrl)}`);
85
118
  }
86
119
 
87
120
  export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
@@ -100,14 +133,16 @@ export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
100
133
  await rm(out, { recursive: true, force: true });
101
134
  await mkdir(out, { recursive: true });
102
135
 
136
+ const repoUrl = await resolveRepoUrl(repoRoot);
137
+
103
138
  // Landing page: the intro article, navigable, published as index.html.
104
139
  const landingHtml = await readFile(join(articlesDir, LANDING.file), 'utf8');
105
- await writeFile(join(out, 'index.html'), injectNav(landingHtml), 'utf8');
140
+ await writeFile(join(out, 'index.html'), injectNav(landingHtml, repoUrl), 'utf8');
106
141
 
107
142
  // Deep-dive article: published with the same nav so the set is navigable.
108
143
  for (const article of ARTICLES) {
109
144
  const html = await readFile(join(articlesDir, article.file), 'utf8');
110
- await writeFile(join(out, article.file), injectNav(html), 'utf8');
145
+ await writeFile(join(out, article.file), injectNav(html, repoUrl), 'utf8');
111
146
  }
112
147
 
113
148
  // Decks: self-contained slide renders, copied as-is (no nav injection).
@@ -115,9 +150,25 @@ export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
115
150
  await cp(join(decksDir, deck.file), join(out, deckOut(deck)));
116
151
  }
117
152
 
153
+ // State atlas: generated from the core state tables at build time, then given
154
+ // the same shared nav as the article pages so it can never drift from the code.
155
+ await writeFile(join(out, STATE_ATLAS.file), injectNav(buildStateAtlasHtml(), repoUrl), 'utf8');
156
+
157
+ // Vendored mermaid runtime the atlas page references (Pages has no CSP, so we
158
+ // load it as an external asset rather than inlining ~3MB into the page).
159
+ const mermaidSrc = join(repoRoot, 'scripts', 'loop', 'inspect-run-viewer', 'vendor', 'mermaid.min.js');
160
+ await mkdir(join(out, 'assets'), { recursive: true });
161
+ await cp(mermaidSrc, join(out, 'assets', 'mermaid.min.js'));
162
+
118
163
  return {
119
164
  out,
120
- files: ['index.html', ...ARTICLES.map((a) => a.file), ...DECKS.map((d) => deckOut(d))],
165
+ files: [
166
+ 'index.html',
167
+ ...ARTICLES.map((a) => a.file),
168
+ ...DECKS.map((d) => deckOut(d)),
169
+ STATE_ATLAS.file,
170
+ 'assets/mermaid.min.js',
171
+ ],
121
172
  };
122
173
  }
123
174