arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
package/CHANGELOG.md CHANGED
@@ -5,7 +5,78 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 4.8.25 — 2026-10-05
9
+
10
+ **Patch** over **4.8.24**. Weekly accumulate (#342, #344, #345, #346, #347, #348, #349, #350). **Write. Check. Ship.** **No required config migration.** `childSlices.arkRulesFile` and pinned `sliceAliases` need 4.8.25. No `schemaVersion` bump. Does not close `K01` / `Z09`. This mother `ark.config.json` still does **not** turn `arkOrder` on.
11
+
12
+ **Status: released** (tag `v4.8.25`). Run `npm view arkgate@4.8.25 version` to confirm (#314).
13
+
8
14
  ### Added
15
+ - Per-slice ArkRules file `childSlices.arkRulesFile` and pinned `sliceAliases` (#342, #341).
16
+ - `arkgate-check --probe-invariants[=<id>]` checks that the tests covering an
17
+ invariant actually pin it. A test title that names the rule proves the test
18
+ exists, not that it would fail if the rule broke. For each invariant with a
19
+ `coverage.symbol`, the probe copies the project to a temporary folder and
20
+ runs only the covering tests (titles that name the id, and tests that import
21
+ the symbol's file) with your own runner — vitest, jest or `node --test`:
22
+ unchanged, then with the file throwing on load, then with the symbol
23
+ throwing when called, then with at most three small changes inside it
24
+ (negate a guard, drop a `throw`, flip or shift a comparison, shift a
25
+ constant). Each invariant reads `killed`, `survived`, `not-reached`,
26
+ `inconclusive` or `unprobeable`, with the change that went unnoticed and the
27
+ line. Your files are never changed. Report by default; `--write` saves
28
+ `.ark/invariant-probe.json` (new schema export `arkgate/schema/invariant-probe`).
29
+ Exit 0 when nothing survived, 1 when something did, 2 when it could not run.
30
+ Not a score.
31
+ - Promotion reads the committed probe result. When `.ark/invariant-probe.json`
32
+ has a fresh `survived` or `not-reached` row — the symbol file, its covering
33
+ tests and the invariant unchanged since the run — `--promote` and the policy
34
+ delta refuse to promote that invariant to `enforced` (blocker
35
+ `probe-survived`). A stale, `killed`, `inconclusive` or missing result
36
+ changes nothing, so every promotion without the file behaves as before.
37
+ Status and `--rules-inventory` show the probe state per invariant when the
38
+ file exists; new catalog id `INVARIANT_PROBE_SURVIVED` (advisory, never in
39
+ the check). The probe runs only when you start it: never from the write
40
+ hook, MCP, ESLint, the GitHub Action or `--strict-merge`, and it refuses to
41
+ combine with them. The runner gets a short environment allowlist (no
42
+ tokens); that is best effort, not a sandbox. No config key, no new skill;
43
+ `/ark-contract` and `/ark-coverage` explain the blocker and the next test to
44
+ write.
45
+ - Status lists code copied across a wall. When a slice wall denies an import,
46
+ the cheapest way around it is a copy, and a copy is not an import.
47
+ `--doctor --all`, the HTML report and `--doctor --all --json` (under
48
+ `doctor.crossWallDuplication`) now list near-identical code whose copies sit
49
+ on two sides of a slice wall, in two child slices of one universe, or in two
50
+ layers, with a place the shared code could live (the declared shared root,
51
+ the universe common folder, or the lower layer both sides may import).
52
+ Copies inside one slice are counted, never listed. It reads token
53
+ fingerprints taken while status runs, with the TypeScript ArkGate already
54
+ loaded; they never enter the check, so the verdict and `factsHash` do not
55
+ change. Tests, `.d.ts`, generated files and files over 256 KB are skipped.
56
+ Fixed thresholds (at least 50 tokens, 5 lines, most names matching), no
57
+ config key. Compact status never runs it; its JSON says `not-run` and names
58
+ the command. When a cap stops the pass the section says `partial` and how
59
+ much was left. New catalog ids `CROSS_WALL_DUPLICATE` and
60
+ `CROSS_LAYER_DUPLICATE` (advisory). Never a score, never in the write hook,
61
+ MCP, ESLint, `--changed`, or `--strict-merge`. No new skill; `/ark-place`
62
+ names the destination and `/ark-fix` moves one copy at a time.
63
+ - Status lists the files nothing imports. A governed file with no importer —
64
+ no governed file, test, project script, or known entry point — shows in
65
+ `--doctor --all`, the HTML report, and `--doctor --json` under
66
+ `doctor.orphanModules`; compact status prints one count line at most. It
67
+ reads the import edges the check already resolved, so the verdict and
68
+ `factsHash` do not change. Entry points come from `package.json` (built
69
+ `dist/` paths map back to source), CI workflow run steps, Next / Vite / Nest /
70
+ Vercel / Storybook conventions, the arkRun / arkOrder roots, config and setup
71
+ files, ambient declaration files, and an optional `.ark/entry-points.json`
72
+ (glob + reason + optional `reviewBy`; not a config key). Files only tests
73
+ import are counted, not listed. When a dynamic import, `import.meta.glob`, a
74
+ quoted path, or an unresolved import may still reach a file, the item says
75
+ so and the list is `partial`. `--doctor --all` and `--report` also list
76
+ exports nothing imports by name. New catalog ids `ORPHAN_MODULE` and
77
+ `UNUSED_EXPORT` (advisory). Never a score, never in the write hook, MCP
78
+ write tools, ESLint, or `--strict-merge`. No new skill; `/ark-explore` maps
79
+ the list and `/ark-fix` deletes one file at a time through the write gate.
9
80
  - Soft doctor residual when Domain is declared but empty and the UI holds the
10
81
  rules (`noDomainFrontend`). Projects empty Domain + presentation share, or
11
82
  the existing `domain-logic-in-ui` smell. Friendly next step: one Domain file
@@ -48,6 +119,10 @@ in the immutable pre-2.0 archive linked below.
48
119
  short phrase templates so the shape is visible. No new skill, schema, or flag.
49
120
 
50
121
  ### Changed
122
+ - `brace-expansion` is overridden to 5.0.12 (Dependabot medium, quadratic expansion) (#350).
123
+ - Eval fixtures pin Next.js 16.3.6 (#346).
124
+ - The package no longer ships `bin/lib/ark-order-invariants.mjs`, `bin/lib/ark-order-error.mjs`, or `bin/lib/stable-hash.mjs`; no CLI loaded them. ArkOrder invariants still ship in `arkgate/order`. One new generated CLI module, `bin/lib/schema-validation.mjs`, holds the JSON-Schema walker shared by the config and ArkRules contracts.
125
+ - Internal cleanup with no public export or behavior change: about 390 unused internal exports removed (knip) and hand-written copy/paste duplication cut from 1.01% to 0% (jscpd). Maintainers: `npm run check:knip` and `npm run check:duplication` run in the CI build job.
51
126
  - Compact `--doctor` names ArkRules only when the `arkRules` map is on
52
127
  (one breath + counts, not a score). Absence stays silent. Reuses
53
128
  `rulesUnderContract` — no new schema, flag, or skill. `--doctor --all`
@@ -66,6 +141,16 @@ in the immutable pre-2.0 archive linked below.
66
141
  Required CI is still the shared merge line. No new skill, schema, or host.
67
142
 
68
143
  ### Fixed
144
+ - `writes-via-aggregate` flags tagged-template SQL writes (`UPDATE`, `MERGE`, `TRUNCATE`, including `ONLY`, quoted names, and interpolated tables). Comments, `FOR UPDATE`, and `DO UPDATE SET` stay silent (#344, #343).
145
+ - `--policy-base` and MCP policy-delta now judge an advisory → enforced
146
+ invariant promotion with the declared `coverage.coverageRoots`, the same way
147
+ `--promote` does. Before, a covered promotion with roots declared came out
148
+ `judgment-required` ("Declare coverage.coverageRoots…") while `--promote`
149
+ allowed it.
150
+ - The flat-parent suggestion in status reads the resolved importer index
151
+ instead of re-reading every file for import text.
152
+ - Doctor sees CI jobs gated by `needs:`: a job that runs the fail-closed Ark check behind unconditional jobs (such as a CI-profile job) now counts as a merge gate; `ciMergeBoundary.ci.workflowPresent` / `merge-gate` no longer report it absent. Jobs a profile output can skip, jobs with `continue-on-error`, and chains with a conditional job still do not count.
153
+ - With `ARK_DOCTOR_GITHUB=1`, the Ark check counts as required only when every job that can skip it is required too (a skipped job satisfies branch protection); otherwise the doctor reports `unverified`, `arkCheckUpstreamNotRequired`, and the gap `enforcement-ark-check-upstream-not-required`. Required checks bound to the GitHub Actions app now correlate (`arkCheckSourceBound: true`).
69
154
  - Write hook and `ark-check` now agree on overlapping layer globs: the hook
70
155
  probes the same specifier extensions as `ark-check` and classifies with
71
156
  `layerForRelativePath` (explicit `money.ts` beats `src/lib/**`). Field
@@ -96,6 +181,74 @@ in the immutable pre-2.0 archive linked below.
96
181
  Existing configs keep whatever URL they already have. Editor completion
97
182
  matches the 4.x line you just installed.
98
183
 
184
+ ## 4.8.24 — 2026-09-29
185
+
186
+ **Patch** over **4.8.23**. Follow-ups to the hierarchical slices shipped in 4.8.23
187
+ (issues #335–#338), every defect a full audit of the gate and its add-ons
188
+ confirmed, and much lower memory per validation (#339). **Write. Check. Ship.**
189
+ **No required config migration:** a config that loads in 4.8.23 still loads;
190
+ ids that changed meaning warn (`CONFIG_CHILD_SLICES_INERT`,
191
+ `CONFIG_SLICE_LEGACY_STARS_ID`) and keep their 4.8.23 verdict for one release.
192
+ No `schemaVersion` bump. Does not close `K01` / `Z09`. This mother
193
+ `ark.config.json` still does **not** turn `arkOrder` on.
194
+
195
+ **Minimum version 4.8.24 for the new config fields** (`sharedImportsSlice`
196
+ object form with `stopAt`, `childSlices.message`, `childSlices.siblings.ratchet`).
197
+ arkgate 4.8.23 and older reject them at config load. Pin 4.8.24 in the write
198
+ hook and CI first.
199
+
200
+ **Status: released** (tag `v4.8.24`). This file does not assert registry
201
+ state; run `npm view arkgate@4.8.24 version` to confirm (#314).
202
+
203
+ ### Added
204
+ - `sharedImportsSlice` accepts `{ "mode": "deny-cross-parent", "stopAt": [...] }`. The whole-graph `CROSS_PARENT_VIA_SHARED` walk never starts at or passes through a stop file, so a declared composition root (`kernel/bootstrap.ts`, `kernel/registrations/**`) is no longer reported as a crossing. `stopAt` matches like a shared root or an alias glob; a stop that covers the whole tree or a whole layer root is rejected. Findings carry `via`, and doctor `sharedWalkHubs` names a likely undeclared composition root. Adding stops is a weakening policy delta. The string forms are unchanged. arkgate 4.8.23 and older reject the object (issue [#335](https://github.com/pedroknigge/arkgate/issues/335)).
205
+ - Optional `childSlices.message` sets the text for inner-wall findings (`CROSS_SIBLING_SLICE`, universe common code importing a child). Without it ArkGate uses its own text; the rule `message` stays the universe-wall text and is never reused on inner-wall findings. Same text in ark-check, CI, the write hook, MCP, and ESLint (issue [#337](https://github.com/pedroknigge/arkgate/issues/337)).
206
+ - Optional `childSlices.siblings.ratchet`: `true` always fails a new advisory crossing, `false` only measures (issue [#336](https://github.com/pedroknigge/arkgate/issues/336)).
207
+ - `arkgate mcp` / `ark mcp` start the stdio MCP server (same as `arkgate-mcp`).
208
+ - ESLint: warn-level `ark/architecture-advisory` rule (findings ark-check reports as warnings) and `ark/arkrules-structure` (ArkRules structure sensors on the same Effective Contract).
209
+ - New advisory `ARKRULE_FILE_UNREFERENCED` for an `arkrules/*.json` the config does not reference, and `doctor.rulesMigration`.
210
+ - `ark-dashboard --help`, `--version`, `--once`, `ARK_DASHBOARD_URL`.
211
+ - `npm run bench:memory` regression guard (budgets in `eval/performance/memory-budgets.v1.json`).
212
+
213
+ ### Changed
214
+ - **Memory and speed.** ark-check, `--doctor`, the write hook, ApplyPatch preflight, the MCP server and the ESLint plugin use much less memory; verdicts are byte-identical. On a 20k-file repo: ark-check peak 422 → 303 MB (3.7 → 2.3 s), ApplyPatch hook 649 → 414 MB (9.5 → 3.6 s), Write hook 163 → 146 MB (0.68 → 0.24 s), MCP server after 50 calls 725 → 442 MB. Sources are read one at a time and released, the facts hash streams, module resolution is memoized per run, the Write/Edit hook no longer walks the whole `include` tree for tsconfig aliases, and preflight reuses base parses.
215
+ - Advisory sibling crossings are advisory on day 1. The anti-growth ratchet is judged per rule and turns on only when the baseline in use already records an advisory sibling crossing of that rule (issue [#336](https://github.com/pedroknigge/arkgate/issues/336)).
216
+ - `CONFIG_CHILD_SLICES_VERSION` (and the other new-key version warnings) fire only with evidence that the repo pins an older arkgate (exact `package.json` dependency, installed copy, lockfile, hook script, or CI workflow). The finding points at that file and line and names the version to bump to (issue [#338](https://github.com/pedroknigge/arkgate/issues/338)).
217
+ - `childSlices` on a rule without `peerIsolation: true` and `allowed: false` still loads (as in 4.8.23) but is inert; ark-check now warns `CONFIG_CHILD_SLICES_INERT` and says how to fix it (add both flags to the rule, or remove `childSlices`). No config that loads in 4.8.23 fails to load.
218
+ - `sliceIdentity: "stars"` keeps every star binding (`modules/*/api/*` binds `orders/api/v1`, not `api/v1`). **Migration:** a `sliceAliases` `to` or an `allowedCrossSlice` / `childSlices.allowedCrossSlice` entry written against the 4.8.23 id (`api/v1/x`, `api/v1`) keeps loading and keeps its 4.8.23 verdict for this release: the alias joins the universe whose old id it names, and the allowance still clears the edge it cleared. ark-check warns `CONFIG_SLICE_LEGACY_STARS_ID` and names the new id to write (`orders/api/v1/x`). A legacy alias target is rejected at config load only when it maps to more than one universe shape of the rule. The legacy form is removed in the next minor release.
219
+ - `childSlices.commonFolders` counts only a folder directly under the universe; the same name inside a child belongs to that child.
220
+ - The write hook fails closed: if it cannot run inside an Ark project (invalid config, missing ArkRules file, missing `dist/`), governed writes are denied with exit 2 (`WRITE_GATE_UNAVAILABLE`) instead of exit 1, which hosts let through. The hook finds its project root like ark-check: a set `--root-env` variable, else the root that holds `ark.config.json`, else it walks up from the payload (written file, `cwd`, `workspace_roots`) to the nearest `ark.config.json`. A write outside any Ark project (no `ark.config.json` found) is allowed, as in 4.8.23.
221
+ - `ark start --apply --remove-host <host>` removes only the Ark hook entries from a merged `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` or `.agents/hooks.json` (same identity as the merge) and keeps every user key and hook; the file is deleted only when nothing else remains. "customized" is reported only when the Ark entry itself was edited.
222
+ - An enforced invariant with `coverage: { test: false }` and no `coverage.symbol` is uncovered; `coverage.symbol: "Class.member"` needs the member declared in that class.
223
+ - ArkOrder `apply()` only accepts a proposal bound to the current Release (`ARKORDER_STALE_PROPOSAL`); `restore()` never changes ξ or rolls the version back; `ReleaseStore.load()` is validated; the plane advances only after `save()`; `informationBudget` is enforced when a Release is created.
224
+ - ArkRun: the default event buffer is capped and settles after local delivery; broker handoff failures are recorded (`event.handoffFailed`); config kernels with canonical layer names enforce hard observed-layer-flow.
225
+ - `ark-check --update-baseline` always freezes the whole tree and refuses `--changed` / `--local`.
226
+
227
+ ### Fixed
228
+ - `--changed`, `--local`, `--against` and `--persona` no longer give a false green: paths resolve relative to `--root` (monorepo packages), non-ASCII names are kept, large or failed git listings exit 2, and a missing base ref exits 2 (`changed-needs-base`) instead of "nothing changed".
229
+ - `--changed` catches a new `CROSS_PARENT_VIA_SHARED` created by editing only a shared-root file, and no longer warns `CONFIG_LAYER_PATTERN_NO_MATCHES` for untouched layers.
230
+ - `ark-check --report`, `latest.json`, `--doctor` and `ark status` agree on frozen versus active findings.
231
+ - Write path parity with CI: the hook, `validate_code`, `ark_prepare_write`, `ark_prepare_change` and ApplyPatch preflight enforce ArkRules structure sensors and ArkOrder editor sensors, load ArkRules like ark-check, handle realpath aliases, and accept the full Codex `apply_patch` format.
232
+ - MCP: `validate_code` / `ark_check` output schemas pass MCP SDK validation (tools no longer disappear); a stale contract returns `CONTRACT_STALE`; `ark_status` honors `--config`; `arkgate-mcp --help` / `--version` exit.
233
+ - MCP Registry: `server.json` runs `npx arkgate@<version> mcp --root .`; the old descriptor never started the server.
234
+ - CommonJS TypeScript consumers: `.d.cts` declarations ship and are mapped for every entry.
235
+ - ESLint: warnings in ark-check are warnings in ESLint; messages carry the rule message and slice reason; ArkOrder rules show real text at 1-based columns; tsconfig JSONC / nearest tsconfig / `extends` are read correctly; an invalid config reports one diagnostic instead of crashing; `require('arkgate/eslint')` returns the plugin (it also carries a non-enumerable `__esModule`, so Babel `_interopRequireWildcard` and TypeScript `__importStar` still see every named export); `--cache` invalidates on contract change; forbidden-globals catches destructuring and bracket access.
236
+ - Slices: adapter diagnostics carry `reasonId` and the universe pair; `CROSS_PARENT_VIA_SHARED` honors the universe `allowedCrossSlice`; fail-closed reasons are named in the hook and MCP; folder-form `sliceAliases` cover their subtree; the flat-parent move card counts every resolved importer; `SHARED_IMPORTS_SLICE` is catalogued.
237
+ - Setup: `ark start --archetype/--preset` writes that shape; re-running `ark start --apply` works; `--remove-host` does not touch packages; existing `.claude/settings.json` / `.codex/hooks.json` are merged instead of skipped or wiped; `typecheck` is added only when TypeScript is present; the steward lock holds under `--strict-merge --contract-session`; `ark status --config` / `ark agents-md --config` work; starter ArkRules ids are unique.
238
+ - ArkRules: policy delta reads base ArkRules from the base ref (demoting an enforced rule is weakening); class-shape sensors see generic classes, ignore braces in strings and regexes, and skip a `static { … }` initialization block (it is not a public member); `ArkRulesValidationError` is exported from the root.
239
+ - ArkRun: undeclared emit/handle/depend count only kernel-traced receivers (`res.send`, `require.resolve`, rxjs `subscribe` are ignored); typed `send(Creator)` resolves names; `ArkModule.forRoot()` is a composition root; `arkgate/runtime` and `arkgate/nestjs` share one kernel chunk (`instanceof` works). A namespace import of a composition root (`import * as main from '../main'; main.ark.publisher(..)`) is traced as a kernel receiver, and an untraced receiver counts when its name carries one of the project's effective intent prefixes (configured `layers[].intentPrefixes` plus the canonical ones), not only `Domain.`/`Application.`….
240
+ - ArkOrder: `ARKORDER_XI_FIELD_WRITE` recognizes Prisma/ORM clients by construction, `GENERIC_UPDATE` / `INGEST_WRITES_XI` false positives removed, `xi-ttl` and `information-budget` sensors emit, NaN/Infinity rejected, documented types exported, and the shipped `bin/lib/ark-order-invariants.mjs` loads.
241
+ - Docs: the composite Action pin tracks the current release, and configuration.md names the real policy-delta entry points.
242
+ - Generated CI workflows, host hooks and MCP entries in a project that does not
243
+ have arkgate as a local dependency run `npx -y -p arkgate@<exact version> <bin>`
244
+ instead of a bare `npx ark-check` / `npx arkgate-mcp` (npm resolved those as a
245
+ nonexistent package, or ran a stale global). Without a `package.json` the workflow
246
+ skips the install step and the npm cache. `--migrate-commands` rewrites bare runners
247
+ into the pinned form, and `--doctor` leads with `PACKAGE_PIN_ABSENT` when a generated
248
+ file still uses the local runner without the pin.
249
+ - `ark-check --json` answers every fatal error with a JSON envelope (`POLICY_BASE_UNREADABLE` when a CI base ref such as `GITHUB_BASE_REF` cannot be read in the checkout, otherwise `CHECK_ERROR`), not only `CONFIG_INVALID`; exit code stays 2.
250
+ - Security: CodeQL `js/polynomial-redos` in the steward email check and two incomplete-sanitization alerts are closed.
251
+
99
252
  ## 4.8.23 — 2026-09-28
100
253
 
101
254
  **Patch** over **4.8.22**. Hierarchical slices: the issue #326 series
package/README.md CHANGED
@@ -25,11 +25,11 @@ Design the boundary before you implement. Put a small interface in front of the
25
25
 
26
26
  ## Try it
27
27
 
28
- One command. Preview only — no files change. Current release is **4.8.23**
28
+ One command. Preview only — no files change. Current release is **4.8.25**
29
29
  (check npm `latest` with `npm view arkgate version`):
30
30
 
31
31
  ```bash
32
- npx arkgate@4.8.23 start
32
+ npx arkgate@4.8.25 start
33
33
  ```
34
34
 
35
35
  ## What a red result looks like
@@ -320,9 +320,11 @@ queue monitors:
320
320
 
321
321
  Poll those facts from the dual bins **`ark-dashboard`** / **`arkgate-dashboard`**
322
322
  (`bin/ark-dashboard.mjs`). ANSI + polling only (no React/Ink/Blessed). Point
323
- `--url` / `-u` at the inspector snapshot (default
324
- `http://127.0.0.1:3000/snapshot`); the dashboard also fetches sibling `/outbox`
325
- and `/workflows`. `--interval` / `-i` is clamped to 200–60000 ms (default 2000).
323
+ `--url` / `-u` at the inspector (`handle.url` or `handle.snapshotUrl`); the dashboard
324
+ also fetches sibling `/outbox` and `/workflows`. `startInspector()` binds a random port
325
+ unless you pass `{ port }`, so the fallback `http://127.0.0.1:3000/snapshot` (or
326
+ `ARK_DASHBOARD_URL`) only matches `startInspector({ port: 3000 })`. `--interval` / `-i`
327
+ is clamped to 200–60000 ms (default 2000); `--once`, `--help`, `--version` are supported.
326
328
  Also available as `ark dashboard` / `arkgate dashboard` (passthrough to the same bin). Kernel stays
327
329
  JSON-only; presentation stays in `bin/`.
328
330
 
@@ -77,6 +77,7 @@ import {
77
77
  printViolationBreakdown,
78
78
  printHumanWarnings,
79
79
  CONCENTRATION_MIN_VIOLATIONS,
80
+ color,
80
81
  } from './lib/violations.mjs';
81
82
  import {
82
83
  detectBestFitModel,
@@ -106,7 +107,7 @@ import {
106
107
  normalize,
107
108
  walk,
108
109
  } from './lib/scan-files.mjs';
109
- import { configWarning } from './lib/config-warnings.mjs';
110
+ import { configInvalidJsonEnvelope, configWarning } from './lib/config-warnings.mjs';
110
111
  import { runArchitectureScan } from './lib/architecture-scan.mjs';
111
112
  import {
112
113
  ANALYSIS_COMPLETENESS,
@@ -124,6 +125,7 @@ import {
124
125
  applyAgainstRatchet,
125
126
  bindTeamBaseRefs,
126
127
  contractSessionFrom,
128
+ pruneScopedPatternWarnings,
127
129
  runTeamPreflight,
128
130
  ungovernedDumpMessage,
129
131
  } from './lib/team-parliament-io.mjs';
@@ -135,7 +137,9 @@ import {
135
137
  resolveEffectiveProjectRoot,
136
138
  } from './lib/project-root.mjs';
137
139
  import { demoteArkRuleTeethUnderClassificationFloor } from './lib/rules-under-contract.mjs';
138
- import { localCheckEnvelope, parseArgs, resolveDesignDeltaBaseRef } from './lib/check-args.mjs';
140
+ import {
141
+ effectiveBaselineName, localCheckEnvelope, parseArgs, resolveDesignDeltaBaseRef, UPDATE_BASELINE_SCOPE_MESSAGE,
142
+ } from './lib/check-args.mjs';
139
143
  import { detectConfig, proposeForUncovered } from './lib/check-config-detect.mjs';
140
144
  import { runWatchMode } from './lib/check-watch.mjs';
141
145
 
@@ -1030,15 +1034,6 @@ function readManifest(root, manifestPath) {
1030
1034
  return readJson(fullPath);
1031
1035
  }
1032
1036
 
1033
- const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
1034
- const color = {
1035
- red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
1036
- yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
1037
- green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
1038
- dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
1039
- bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1040
- };
1041
-
1042
1037
  /**
1043
1038
  * Monorepo honesty: when cwd/--root has no ark.config.json, walk parents (bounded).
1044
1039
  * Stamps configRoot / configWalkedUp. Mutates args.root only for read paths, or
@@ -1144,6 +1139,14 @@ async function main() {
1144
1139
  return;
1145
1140
  }
1146
1141
 
1142
+ if (args.probeInvariants) {
1143
+ // ADR 0039: owner-invoked, outside the gate path. Loaded only here; parseArgs
1144
+ // refuses every pairing with --strict-merge, --changed, --local or --doctor.
1145
+ const { runProbeInvariants } = await import('./lib/invariant-probe-cli.mjs');
1146
+ await runProbeInvariants(args, readConfig);
1147
+ return;
1148
+ }
1149
+
1147
1150
  if (args.sensors) {
1148
1151
  const { runSensors } = await import('./lib/sensor-promote-cli.mjs');
1149
1152
  await withSensorsPartialModeHonesty(args, () => runSensors(args, readConfig));
@@ -1333,6 +1336,7 @@ async function main() {
1333
1336
  strictMerge: args.strictMerge || args.contractDiff,
1334
1337
  basePath: args.policyBase,
1335
1338
  baseRef: args.policyBaseRef,
1339
+ adoptMissingBase: args.policyBaseFromTeam === true,
1336
1340
  acknowledgementPath: args.policyAck,
1337
1341
  });
1338
1342
  const preflight = runTeamPreflight({
@@ -1433,7 +1437,7 @@ async function main() {
1433
1437
 
1434
1438
  const {
1435
1439
  violations: rawViolations, warnings, safety, parseHealth, completeness, completenessReasons,
1436
- mode, policyHash, resolverIdentity, factsHash, candidateTreeHash,
1440
+ mode, policyHash, resolverIdentity, factsHash, candidateTreeHash, importGraph,
1437
1441
  } = runArchitectureScan({
1438
1442
  root,
1439
1443
  config,
@@ -1445,8 +1449,11 @@ async function main() {
1445
1449
  files: args.changed ? files : [],
1446
1450
  ts,
1447
1451
  args,
1452
+ // ADR 0037: doctor / report read a compact importer index, never the verdict path.
1453
+ graphProjection: Boolean(args.doctor || args.report) && !args.changed,
1448
1454
  });
1449
1455
 
1456
+ if (args.changed) pruneScopedPatternWarnings(warnings, root, loadGovernedFiles);
1450
1457
  // Align merge with extraMergeTeeth stamp: demote enforced ArkRules under classification floor.
1451
1458
  const preCov = computeCoverage(root, config, files, rules);
1452
1459
  const populatedLayerCount = Array.isArray(preCov.layers)
@@ -1503,8 +1510,8 @@ async function main() {
1503
1510
  configWalkedUp: args.configWalkedUp === true,
1504
1511
  safety, designDelta,
1505
1512
  ts, parseHealth, completeness,
1506
- all: args.all === true, requireGates: args.requireGates === true,
1507
- warnings,
1513
+ all: args.all === true, changed: args.changed === true, requireGates: args.requireGates === true,
1514
+ warnings, architectureFacts: importGraph ? { importGraph } : undefined,
1508
1515
  });
1509
1516
  if (designDelta) process.exitCode = !designDelta.complete ? 2 : designDelta.valid ? 0 : 1; return;
1510
1517
  }
@@ -1515,6 +1522,8 @@ async function main() {
1515
1522
  }
1516
1523
 
1517
1524
  if (args.updateBaseline) {
1525
+ // A persona can turn on --changed after parseArgs; a subset scan must never rewrite the baseline.
1526
+ if (args.changed) { console.error(UPDATE_BASELINE_SCOPE_MESSAGE); process.exitCode = 2; return; }
1518
1527
  if (!contractSessionFrom(args)) {
1519
1528
  console.error('Growing the baseline requires --contract-session. Freeze in a law-only PR.');
1520
1529
  process.exitCode = 1;
@@ -1587,14 +1596,16 @@ async function main() {
1587
1596
  changed: args.changed,
1588
1597
  changedPaths,
1589
1598
  occurrenceKeys: baselineOccurrenceKeys(violations),
1599
+ rules,
1600
+ layers: config.layers,
1590
1601
  });
1591
1602
  activeViolations = ratcheted.activeViolations;
1592
1603
  suppressed = ratcheted.suppressed;
1593
- } else if (args.baseline) {
1594
- const baseline = readBaseline(root, args.baseline);
1604
+ } else if (effectiveBaselineName(args, root)) {
1605
+ const baseline = readBaseline(root, effectiveBaselineName(args, root));
1595
1606
  if (baseline.exists) {
1596
1607
  const occurrenceKeys = baselineOccurrenceKeys(violations);
1597
- const judged = applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseline.keys);
1608
+ const judged = applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseline.keys, { rules, layers: config.layers });
1598
1609
  suppressed = judged.filter((_, index) => baseline.keys.has(occurrenceKeys[index]));
1599
1610
  activeViolations = judged.filter((_, index) => !baseline.keys.has(occurrenceKeys[index]));
1600
1611
  const currentKeys = new Set(occurrenceKeys);
@@ -1639,81 +1650,16 @@ async function main() {
1639
1650
  }
1640
1651
 
1641
1652
  if (args.rulesInventory) {
1642
- const { buildRulesInventory, inventoryToExtractionCard } = await import('./lib/rules-inventory.mjs');
1643
- const fileContents = {};
1644
- const fileLayers = {};
1645
- for (const file of files.slice(0, 400)) {
1646
- const rel = normalize(path.relative(root, file));
1647
- try {
1648
- fileContents[rel] = fs.readFileSync(file, 'utf8');
1649
- const layer = layerForFile(root, file, config.layers);
1650
- if (layer) fileLayers[rel] = layer;
1651
- } catch {
1652
- /* skip unreadable */
1653
- }
1654
- }
1655
- const contracted = [];
1656
- if (config.arkRules) {
1657
- try {
1658
- const { loadEffectiveArkRulesFromDisk } = await import('./lib/effective-contract-load.mjs');
1659
- const loaded = loadEffectiveArkRulesFromDisk(root, config);
1660
- for (const rule of loaded.arkRules.structure ?? []) contracted.push(rule.id);
1661
- for (const inv of loaded.arkRules.invariants ?? []) contracted.push(inv.id);
1662
- } catch {
1663
- /* advisory inventory still useful */
1664
- }
1665
- }
1666
- const inventory = buildRulesInventory({
1667
- fileContents,
1668
- fileLayers,
1669
- layerContexts: (config.layers ?? []).map((layer) => ({
1670
- name: layer.name,
1671
- intentPrefixes: layer.intentPrefixes ?? [],
1672
- })),
1673
- contractedRuleIds: contracted,
1674
- });
1675
- const nextPilot =
1676
- inventory.candidates[0] != null
1677
- ? inventoryToExtractionCard(inventory.candidates[0])
1678
- : null;
1679
- let evidenceLines = [];
1680
- let coverageEvidence = null;
1681
- if (config.arkRules && Object.keys(config.arkRules).length > 0) {
1682
- try {
1683
- const { summarizeRulesUnderContract, formatArkRulesEvidenceLines } = await import(
1684
- './lib/rules-under-contract.mjs'
1685
- );
1686
- const section = summarizeRulesUnderContract(root, config, {
1687
- files: files.map((file) => ({ path: normalize(path.relative(root, file)) })),
1688
- });
1689
- evidenceLines = formatArkRulesEvidenceLines(section);
1690
- if (section.active === true) {
1691
- coverageEvidence = {
1692
- symbolEvidence: section.symbolEvidence ?? [],
1693
- discarded: section.coverageStats?.discarded ?? null,
1694
- };
1695
- }
1696
- } catch {
1697
- /* inventory still useful when coverage cannot be read */
1698
- }
1699
- }
1700
- const payload = {
1701
- rulesInventory: inventory,
1702
- rulesMigration: {
1703
- inventoried: inventory.inventoried,
1704
- underContract: inventory.underContract,
1705
- frozen: inventory.frozen,
1706
- notAScore: true,
1707
- },
1708
- nextPilot: nextPilot,
1709
- ...(coverageEvidence ? { coverageEvidence } : {}),
1710
- };
1653
+ // Same helper as MCP ark_rules_inventory and doctor.rulesMigration (AR15).
1654
+ const { buildRulesInventoryPayload, formatRulesMigrationCounts } = await import(
1655
+ './lib/rules-under-contract.mjs'
1656
+ );
1657
+ const { payload, evidenceLines } = buildRulesInventoryPayload(root, config, files);
1658
+ const { rulesInventory: inventory, nextPilot } = payload;
1711
1659
  if (args.json) {
1712
1660
  console.log(JSON.stringify(payload, null, 2));
1713
1661
  } else {
1714
- console.log(
1715
- `Rules inventory: ${inventory.inventoried} inventoried, ${inventory.underContract} under contract, ${inventory.frozen} frozen (not a score).`
1716
- );
1662
+ console.log(`Rules inventory: ${formatRulesMigrationCounts(payload.rulesMigration)}.`);
1717
1663
  for (const c of inventory.candidates.slice(0, 12)) {
1718
1664
  console.log(` - [${c.confidence}] ${c.kind} @ ${c.file}:${c.line} — ${c.message}`);
1719
1665
  }
@@ -1846,8 +1792,9 @@ async function main() {
1846
1792
  files,
1847
1793
  ts,
1848
1794
  parseHealth,
1849
- undefined,
1850
- activeViolations
1795
+ importGraph ? { importGraph } : undefined,
1796
+ activeViolations,
1797
+ { details: true, changed: args.changed === true }
1851
1798
  ),
1852
1799
  // Doctor parity: always emit improvement compass when doctor would (reportParity).
1853
1800
  ...(designDepth?.improvementCompass
@@ -1865,6 +1812,7 @@ async function main() {
1865
1812
  ? renderBeginnerHtmlReport(reportPayload)
1866
1813
  : renderHtmlReport(reportPayload);
1867
1814
  const reportPath = path.isAbsolute(args.report) ? args.report : path.join(root, args.report);
1815
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
1868
1816
  fs.writeFileSync(reportPath, html);
1869
1817
 
1870
1818
  const archive = archiveReportSnapshots(root, {
@@ -2087,6 +2035,8 @@ async function main() {
2087
2035
  }
2088
2036
 
2089
2037
  main().catch((error) => {
2038
+ const envelope = configInvalidJsonEnvelope(error, process.argv);
2039
+ if (envelope) console.log(envelope);
2090
2040
  console.error(
2091
2041
  process.env.ARK_DEBUG_STACK === '1' && error instanceof Error
2092
2042
  ? error.stack
@@ -1,43 +1,55 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { parseArgs } from 'node:util';
4
+ import {
5
+ DASHBOARD_DEFAULT_INTERVAL_MS,
6
+ DASHBOARD_DEFAULT_TIMEOUT_MS,
7
+ DASHBOARD_DEFAULT_URL,
8
+ DASHBOARD_EPHEMERAL_PORT_HINT,
9
+ DASHBOARD_HELP,
10
+ clampDashboardMs,
11
+ dashboardSiblingUrl,
12
+ } from './lib/dashboard-cli.mjs';
3
13
 
4
- const DEFAULT_INTERVAL_MS = '2000';
5
- const DEFAULT_SNAPSHOT_URL = 'http://127.0.0.1:3000/snapshot';
6
- const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
7
14
  const MAX_FIELD_LEN = 120;
8
15
 
9
- const args = parseArgs({
10
- options: {
11
- interval: {
12
- type: 'string',
13
- short: 'i',
14
- default: DEFAULT_INTERVAL_MS
15
- },
16
- url: {
17
- type: 'string',
18
- short: 'u',
19
- default: DEFAULT_SNAPSHOT_URL
20
- },
21
- timeout: {
22
- type: 'string',
23
- short: 't',
24
- default: String(DEFAULT_FETCH_TIMEOUT_MS)
25
- }
26
- },
27
- allowPositionals: true
28
- });
16
+ function parseDashboardArgs() {
17
+ try {
18
+ return parseArgs({
19
+ options: {
20
+ interval: { type: 'string', short: 'i' },
21
+ url: { type: 'string', short: 'u' },
22
+ timeout: { type: 'string', short: 't' },
23
+ once: { type: 'boolean' },
24
+ help: { type: 'boolean', short: 'h' },
25
+ version: { type: 'boolean', short: 'v' },
26
+ },
27
+ allowPositionals: true,
28
+ });
29
+ } catch (err) {
30
+ const message = err instanceof Error ? err.message.split('. ')[0] : String(err);
31
+ process.stderr.write(`ark-dashboard: ${message}. Run ark-dashboard --help.\n`);
32
+ process.exit(2);
33
+ }
34
+ }
35
+
36
+ const args = parseDashboardArgs();
37
+ if (args.values.help || args.positionals[0] === 'help') {
38
+ console.log(DASHBOARD_HELP);
39
+ process.exit(0);
40
+ }
41
+ if (args.values.version) {
42
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
43
+ console.log(pkg.version);
44
+ process.exit(0);
45
+ }
29
46
 
30
- const parsedInterval = parseInt(args.values.interval, 10);
31
- const interval =
32
- Number.isFinite(parsedInterval) && parsedInterval >= 200 && parsedInterval <= 60_000
33
- ? parsedInterval
34
- : Number(DEFAULT_INTERVAL_MS);
35
- const targetUrl = args.values.url || DEFAULT_SNAPSHOT_URL;
36
- const parsedTimeout = parseInt(args.values.timeout, 10);
37
- const fetchTimeoutMs =
38
- Number.isFinite(parsedTimeout) && parsedTimeout >= 200 && parsedTimeout <= 60_000
39
- ? parsedTimeout
40
- : DEFAULT_FETCH_TIMEOUT_MS;
47
+ const envUrl = process.env.ARK_DASHBOARD_URL?.trim();
48
+ const urlExplicit = Boolean(args.values.url || envUrl);
49
+ const targetUrl = args.values.url || envUrl || DASHBOARD_DEFAULT_URL;
50
+ const interval = clampDashboardMs(args.values.interval, DASHBOARD_DEFAULT_INTERVAL_MS);
51
+ const fetchTimeoutMs = clampDashboardMs(args.values.timeout, DASHBOARD_DEFAULT_TIMEOUT_MS);
52
+ const once = Boolean(args.values.once);
41
53
 
42
54
  const RESET = '\x1b[0m';
43
55
  const RED = '\x1b[31m';
@@ -73,22 +85,6 @@ function sanitizeField(value, maxLen = MAX_FIELD_LEN) {
73
85
  return text;
74
86
  }
75
87
 
76
- function siblingUrl(snapshotUrl, suffix) {
77
- try {
78
- const u = new URL(snapshotUrl);
79
- let basePath = u.pathname;
80
- if (basePath.endsWith('/snapshot')) {
81
- basePath = basePath.slice(0, -'/snapshot'.length);
82
- } else if (basePath.endsWith('/snapshot/')) {
83
- basePath = basePath.slice(0, -'/snapshot/'.length);
84
- }
85
- u.pathname = `${basePath}${suffix}`;
86
- return u.toString();
87
- } catch {
88
- return null;
89
- }
90
- }
91
-
92
88
  /**
93
89
  * @returns {{ ok: true, data: unknown } | { ok: false, kind: 'waiting' | 'failure', error?: string }}
94
90
  */
@@ -340,6 +336,7 @@ function renderHardening(snapshotResult) {
340
336
  } else {
341
337
  const detail = snapshotResult.error ? ` (${snapshotResult.error})` : '';
342
338
  console.log(`${RED}Failure contacting kernel${detail}${RESET}`);
339
+ if (!urlExplicit) console.log(`${MUTED}${DASHBOARD_EPHEMERAL_PORT_HINT}${RESET}`);
343
340
  }
344
341
  return;
345
342
  }
@@ -381,13 +378,13 @@ function renderHardening(snapshotResult) {
381
378
  }
382
379
 
383
380
  async function render() {
384
- process.stdout.write('\x1b[2J\x1b[H');
381
+ if (!once) process.stdout.write('\x1b[2J\x1b[H');
385
382
  console.log(`ArkGate Observability Dashboard`);
386
383
  console.log(`Time: ${sanitizeField(new Date().toISOString())}`);
387
384
  console.log(`Endpoint: ${sanitizeField(targetUrl)}\n`);
388
385
 
389
- const outboxUrl = siblingUrl(targetUrl, '/outbox');
390
- const workflowsUrl = siblingUrl(targetUrl, '/workflows');
386
+ const outboxUrl = dashboardSiblingUrl(targetUrl, '/outbox');
387
+ const workflowsUrl = dashboardSiblingUrl(targetUrl, '/workflows');
391
388
  const [snapshotResult, outboxResult, workflowsResult] = await Promise.all([
392
389
  fetchJsonResult(targetUrl),
393
390
  fetchJsonResult(outboxUrl),
@@ -404,9 +401,15 @@ async function render() {
404
401
  snapshot?.outbox,
405
402
  snapshot?.workflows,
406
403
  );
404
+ return snapshotResult.ok;
407
405
  }
408
406
 
409
407
  async function startDashboard() {
408
+ if (once) {
409
+ const ok = await render();
410
+ process.exitCode = ok ? 0 : 1;
411
+ return;
412
+ }
410
413
  console.log(
411
414
  `Starting ArkGate Observability Dashboard (polling every ${interval}ms, fetch timeout ${fetchTimeoutMs}ms)`,
412
415
  );