arkgate 4.8.19 → 4.8.20

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 (55) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +4 -4
  3. package/bin/ark-check-runtime.mjs +23 -0
  4. package/bin/ark-layer-match.mjs +141 -9
  5. package/bin/ark.mjs +6 -2
  6. package/bin/lib/analysis-engine.mjs +6 -6
  7. package/bin/lib/config-contract.mjs +1 -0
  8. package/bin/lib/doctor-advisories.mjs +2 -1
  9. package/bin/lib/doctor-plan.mjs +2 -0
  10. package/bin/lib/enforcement-honesty.mjs +30 -2
  11. package/bin/lib/html-report-depth.mjs +5 -1
  12. package/bin/lib/invariant-coverage.mjs +44 -13
  13. package/bin/lib/package-manager.mjs +5 -2
  14. package/bin/lib/physical-cohesion.mjs +68 -51
  15. package/bin/lib/reshape-decisions.mjs +2 -2
  16. package/bin/lib/rules-under-contract.mjs +40 -4
  17. package/bin/lib/start-install-recovery.mjs +156 -0
  18. package/bin/lib/start-preview.mjs +0 -54
  19. package/dist/{configTypes-VD0qcubY.d.ts → configTypes-VVknqcTJ.d.ts} +2 -0
  20. package/dist/{diagnosticCatalog-D2v0ASN2.d.ts → diagnosticCatalog-BPYWNyJR.d.ts} +9 -3
  21. package/dist/eslint/index.cjs +4 -4
  22. package/dist/eslint/index.d.ts +12 -4
  23. package/dist/eslint/index.js +4 -4
  24. package/dist/index.cjs +18 -18
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +20 -20
  27. package/dist/nestjs/index.cjs +5 -5
  28. package/dist/nestjs/index.d.ts +3 -3
  29. package/dist/nestjs/index.js +5 -5
  30. package/dist/runtime/index.cjs +10 -10
  31. package/dist/runtime/index.d.ts +6 -6
  32. package/dist/runtime/index.js +10 -10
  33. package/dist/{types-BSzRy2X1.d.ts → types-Cs-46DRu.d.ts} +1 -1
  34. package/dist/{types-D5GT5ZT8.d.ts → types-p_fCp6P-.d.ts} +1 -1
  35. package/docs/README.md +1 -1
  36. package/docs/configuration.md +20 -5
  37. package/docs/develop.md +1 -1
  38. package/docs/package-surface.md +2 -2
  39. package/docs/use.md +1 -1
  40. package/package.json +3 -3
  41. package/schemas/ark.config.schema.json +6 -0
  42. package/server.json +2 -2
  43. package/templates/agent-skills/README.md +1 -1
  44. package/templates/agent-skills/ark-adopt/SKILL.md +15 -1
  45. package/templates/agent-skills/ark-autopilot/SKILL.md +2 -0
  46. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  47. package/templates/agent-skills/ark-explain/SKILL.md +1 -1
  48. package/templates/agent-skills/ark-explore/SKILL.md +13 -1
  49. package/templates/agent-skills/ark-upgrade/SKILL.md +5 -3
  50. package/templates/skills/ark-adopt.md +15 -1
  51. package/templates/skills/ark-autopilot.md +2 -0
  52. package/templates/skills/ark-coverage.md +1 -1
  53. package/templates/skills/ark-explain.md +1 -1
  54. package/templates/skills/ark-explore.md +13 -1
  55. package/templates/skills/ark-upgrade.md +5 -3
package/CHANGELOG.md CHANGED
@@ -96,6 +96,107 @@ in the immutable pre-2.0 archive linked below.
96
96
  Existing configs keep whatever URL they already have. Editor completion
97
97
  matches the 4.x line you just installed.
98
98
 
99
+ ## 4.8.20 — 2026-09-25
100
+
101
+ **Patch** over **4.8.19**. Daily accumulate (2026-09-25 ART) of the ships
102
+ that landed after 4.8.19 published: `coverage.symbol` is a non-test
103
+ declaration
104
+ ([#301](https://github.com/pedroknigge/arkgate/pull/301) /
105
+ [#291](https://github.com/pedroknigge/arkgate/issues/291)), reshape pilot
106
+ stays in the source layer and slice
107
+ ([#299](https://github.com/pedroknigge/arkgate/pull/299) /
108
+ [#295](https://github.com/pedroknigge/arkgate/issues/295)),
109
+ physical-cohesion residual clears finished and elegant
110
+ ([#302](https://github.com/pedroknigge/arkgate/pull/302) /
111
+ [#296](https://github.com/pedroknigge/arkgate/issues/296)), anchored
112
+ starred `sliceFolders`
113
+ ([#300](https://github.com/pedroknigge/arkgate/pull/300) /
114
+ [#294](https://github.com/pedroknigge/arkgate/issues/294)), shared roots
115
+ cannot import a slice when opted in
116
+ ([#303](https://github.com/pedroknigge/arkgate/pull/303) /
117
+ [#297](https://github.com/pedroknigge/arkgate/issues/297)), pnpm Age
118
+ install-fail recovery
119
+ ([#293](https://github.com/pedroknigge/arkgate/pull/293) /
120
+ [#292](https://github.com/pedroknigge/arkgate/issues/292)), vitest 4.1.11
121
+ ([#298](https://github.com/pedroknigge/arkgate/pull/298)), Dependabot
122
+ vitest 5 ignore
123
+ ([#304](https://github.com/pedroknigge/arkgate/pull/304)), and docs
124
+ ([#244](https://github.com/pedroknigge/arkgate/pull/244),
125
+ [#234](https://github.com/pedroknigge/arkgate/pull/234)).
126
+ **Write. Check. Ship.** **No required config migration.** No
127
+ `schemaVersion` bump. Does not close `K01` / `Z09`. This mother
128
+ `ark.config.json` still does **not** turn `arkOrder` on.
129
+
130
+ **Status: published** (npm `latest` is **4.8.20**).
131
+
132
+ ### Changed
133
+ - This is a behavior change. Some repos that are green today will turn red
134
+ when `coverage.symbol` only appeared in tests or imports.
135
+ `symbolEvidenceFile` is the non-test file that declares the symbol.
136
+ Discard counters (`depthLimited`, the per-file byte cap, and the other
137
+ existing reasons) are printed on green doctor and `--rules-inventory`
138
+ output too; the depth and byte caps themselves stay internal
139
+ ([#301](https://github.com/pedroknigge/arkgate/pull/301) /
140
+ [#291](https://github.com/pedroknigge/arkgate/issues/291)).
141
+ - `sliceFolders` accepts a starred prefix (`lib/features/*/*`), anchored
142
+ like `sharedRoots`. The slice id is the directories the stars bind
143
+ (`lib/features/projects/rfi`). A star never binds a filename; a file
144
+ directly under that prefix stays in the last directory
145
+ (`lib/features/projects`). Bare `sliceFolders` names no longer treat a
146
+ filename as the slice child segment.
147
+ `src/components/features/projects/rfi-status-pill.tsx` with `["projects"]`
148
+ is not a slice. Bare names under `src/lib/features` behave exactly as
149
+ before: `src/lib/features/projects/rfi/x.ts` with `["features"]` stays
150
+ `features/projects` (a regression test covers it)
151
+ ([#300](https://github.com/pedroknigge/arkgate/pull/300) /
152
+ [#294](https://github.com/pedroknigge/arkgate/issues/294)).
153
+
154
+ ### Fixed
155
+ - Reshape pilot destinations stay in the source file's layer and slice. A
156
+ proposal that would leave both is withheld instead of pointing at
157
+ `src/features/`
158
+ ([#299](https://github.com/pedroknigge/arkgate/pull/299) /
159
+ [#295](https://github.com/pedroknigge/arkgate/issues/295)).
160
+ - Doctor no longer calls the tree finished or elegant while a
161
+ physical-cohesion finding is still unsuppressed. The gate verdict is
162
+ unchanged, and the reshape card stays one judgment
163
+ ([#302](https://github.com/pedroknigge/arkgate/pull/302) /
164
+ [#296](https://github.com/pedroknigge/arkgate/issues/296)).
165
+ - A `peerIsolation` rule may set `sharedImportsSlice` to `"deny"`. A
166
+ declared shared root then may not import a slice
167
+ (`shared root … → slice …`). The default stays off, so current walls
168
+ stay green. A slice importing a shared root stays allowed.
169
+ `allowedCrossSlice` does not excuse this hop. Doctor lists shared-root
170
+ → slice bridges even when the flag is off. A green run still says the
171
+ wall is direct-only
172
+ ([#303](https://github.com/pedroknigge/arkgate/pull/303) /
173
+ [#297](https://github.com/pedroknigge/arkgate/issues/297)).
174
+ - Age-gated `start --apply` install failure no longer reprints the same
175
+ `pnpm add` as the primary next step. Recovery names pnpm Age, leads
176
+ with `npx --package=arkgate@<pin> arkgate-check --doctor`, and says
177
+ `minimumReleaseAgeExclude` must live in pnpm config or
178
+ `pnpm-workspace.yaml` (a CLI flag is not enough)
179
+ ([#293](https://github.com/pedroknigge/arkgate/pull/293) /
180
+ [#292](https://github.com/pedroknigge/arkgate/issues/292)).
181
+
182
+ ### Dependencies
183
+ - Dev dependency `vitest` and `@vitest/coverage-v8` are on 4.1.11, which
184
+ pulls `@vitest/mocker@4.1.11` and closes
185
+ [GHSA-82fw-gwwq-j7x9](https://github.com/vitest-dev/vitest/security/advisories/GHSA-82fw-gwwq-j7x9)
186
+ (moderate path traversal via redirect mocks). Vitest 4's V8 coverage
187
+ provider remaps with AST analysis, so the same suite reports a larger
188
+ branch denominator; coverage floors moved to just under those measured
189
+ numbers. No tests were removed
190
+ ([#298](https://github.com/pedroknigge/arkgate/pull/298)).
191
+ - Dependabot ignores vitest 5 (`vitest`, `@vitest/coverage-v8`, and
192
+ `@vitest/mocker` at `>= 5.0.0`) until Node 22.12+ is the floor.
193
+ Version-update pull requests stay off; the cooldown is 7 days
194
+ ([#304](https://github.com/pedroknigge/arkgate/pull/304)).
195
+ - Docs: anonymized live dogfood sales stats
196
+ ([#244](https://github.com/pedroknigge/arkgate/pull/244)); `UC01` marked
197
+ done after [#233](https://github.com/pedroknigge/arkgate/pull/233)
198
+ ([#234](https://github.com/pedroknigge/arkgate/pull/234)).
199
+
99
200
  ## 4.8.19 — 2026-09-20
100
201
 
101
202
  **Patch** over **4.8.18**. Daily accumulate (2026-09-20 ART) of the five
package/README.md CHANGED
@@ -39,7 +39,7 @@ Works with Cursor, Claude, Codex, and Grok.
39
39
 
40
40
  </div>
41
41
 
42
- > **ArkGate 4.8.19** is on npm `latest`.
42
+ > **ArkGate 4.8.20** is on npm `latest`.
43
43
  > Write. Check. Ship. Adopted = required GitHub
44
44
  > status running `arkgate-check --strict-merge`, or an explicit `advisory-only` stance.
45
45
  > Status is compact (`arkgate-check --doctor`; `--all` for Details). Optional **ArkRun**
@@ -47,7 +47,7 @@ Works with Cursor, Claude, Codex, and Grok.
47
47
  > (`arkgate/order`) stops rewriting a big product choice — like the billing plan —
48
48
  > as if it were a seat count. Change those choices through a valve, not a generic update.
49
49
  > `@arkgate/runtime` is deprecated.
50
- > [4.8.19](CHANGELOG.md) · [4.8.11](docs/releases/4.8.11.md) · [4.8.10](docs/releases/4.8.10.md) · [4.8.9](docs/releases/4.8.9.md) · [4.8.8](docs/releases/4.8.8.md) · [4.8.7](docs/releases/4.8.7.md) · [4.8.6](docs/releases/4.8.6.md) · [4.8.5](docs/releases/4.8.5.md) · [4.8.4](docs/releases/4.8.4.md) · [4.8.3](docs/releases/4.8.3.md) · [4.8.2](docs/releases/4.8.2.md) · [4.8.1](docs/releases/4.8.1.md) · [4.8.0](docs/releases/4.8.0.md) · [Docs hub](docs/README.md) · [Voice](docs/product-voice.md)
50
+ > [4.8.20](CHANGELOG.md) · [4.8.11](docs/releases/4.8.11.md) · [4.8.10](docs/releases/4.8.10.md) · [4.8.9](docs/releases/4.8.9.md) · [4.8.8](docs/releases/4.8.8.md) · [4.8.7](docs/releases/4.8.7.md) · [4.8.6](docs/releases/4.8.6.md) · [4.8.5](docs/releases/4.8.5.md) · [4.8.4](docs/releases/4.8.4.md) · [4.8.3](docs/releases/4.8.3.md) · [4.8.2](docs/releases/4.8.2.md) · [4.8.1](docs/releases/4.8.1.md) · [4.8.0](docs/releases/4.8.0.md) · [Docs hub](docs/README.md) · [Voice](docs/product-voice.md)
51
51
 
52
52
  ---
53
53
 
@@ -72,7 +72,7 @@ npx --package=arkgate arkgate-check --doctor # status — one next step
72
72
  npx --package=arkgate arkgate-check --doctor --all # full details
73
73
  ```
74
74
 
75
- `start --apply` pins arkgate with this repo's package manager (`pnpm add -w` / `yarn add -W` at a workspace root). Do not run `npm install -D arkgate` at a `workspace:*` root — npm rejects that protocol. After a local install, `npx arkgate-check --doctor` also works. `arkgate-check` is a command in the `arkgate` package, not its own npm package.
75
+ `start --apply` pins arkgate with this repo's package manager (`pnpm add -w` / `yarn add -W` at a workspace root). Do not run `npm install -D arkgate` at a `workspace:*` root — npm rejects that protocol. After a local install, `npx arkgate-check --doctor` also works. `arkgate-check` is a command in the `arkgate` package, not its own npm package. If pnpm Age (`minimumReleaseAge`) blocks a brand-new release, keep using `npx --package=arkgate@<version>` — do not re-run the same add. Put arkgate on `minimumReleaseAgeExclude` in pnpm config or `pnpm-workspace.yaml` (a CLI flag is not enough), or wait until the package is mature.
76
76
 
77
77
  `start --apply` refuses when projected governed coverage is below 50% or
78
78
  shape confidence is weak (below 0.6 with coverage under 80%), or when the
@@ -346,7 +346,7 @@ Compact starters leave the extra off. Details: [ArkOrder](docs/arkorder.md).
346
346
  | Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
347
347
  | Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
348
348
  | Security | [SECURITY.md](SECURITY.md) |
349
- | Current published (4.8.19 on npm `latest`) | [CHANGELOG](CHANGELOG.md) |
349
+ | Current published (4.8.20 on npm `latest`) | [CHANGELOG](CHANGELOG.md) |
350
350
  | Prior published (4.8.11) | [docs/releases/4.8.11.md](docs/releases/4.8.11.md) · [CHANGELOG](CHANGELOG.md) |
351
351
  | Prior published (4.8.10) | [docs/releases/4.8.10.md](docs/releases/4.8.10.md) · [CHANGELOG](CHANGELOG.md) |
352
352
  | Prior published (4.8.9) | [docs/releases/4.8.9.md](docs/releases/4.8.9.md) · [CHANGELOG](CHANGELOG.md) |
@@ -1673,6 +1673,27 @@ async function main() {
1673
1673
  inventory.candidates[0] != null
1674
1674
  ? inventoryToExtractionCard(inventory.candidates[0])
1675
1675
  : null;
1676
+ let evidenceLines = [];
1677
+ let coverageEvidence = null;
1678
+ if (config.arkRules && Object.keys(config.arkRules).length > 0) {
1679
+ try {
1680
+ const { summarizeRulesUnderContract, formatArkRulesEvidenceLines } = await import(
1681
+ './lib/rules-under-contract.mjs'
1682
+ );
1683
+ const section = summarizeRulesUnderContract(root, config, {
1684
+ files: files.map((file) => ({ path: normalize(path.relative(root, file)) })),
1685
+ });
1686
+ evidenceLines = formatArkRulesEvidenceLines(section);
1687
+ if (section.active === true) {
1688
+ coverageEvidence = {
1689
+ symbolEvidence: section.symbolEvidence ?? [],
1690
+ discarded: section.coverageStats?.discarded ?? null,
1691
+ };
1692
+ }
1693
+ } catch {
1694
+ /* inventory still useful when coverage cannot be read */
1695
+ }
1696
+ }
1676
1697
  const payload = {
1677
1698
  rulesInventory: inventory,
1678
1699
  rulesMigration: {
@@ -1682,6 +1703,7 @@ async function main() {
1682
1703
  notAScore: true,
1683
1704
  },
1684
1705
  nextPilot: nextPilot,
1706
+ ...(coverageEvidence ? { coverageEvidence } : {}),
1685
1707
  };
1686
1708
  if (args.json) {
1687
1709
  console.log(JSON.stringify(payload, null, 2));
@@ -1695,6 +1717,7 @@ async function main() {
1695
1717
  if (nextPilot) {
1696
1718
  console.log(`Next extraction pilot: ${nextPilot.pilot} → ${nextPilot.pilotTarget}`);
1697
1719
  }
1720
+ for (const line of evidenceLines) console.log(line);
1698
1721
  }
1699
1722
  process.exitCode = 0;
1700
1723
  return;
@@ -215,6 +215,14 @@ export function layerForRelativePath(relPath, layers) {
215
215
  * Includes the parent folder so `features/auth` ≠ `modules/auth`.
216
216
  * Identity is case-normalized for portable results across filesystems.
217
217
  * `src/features/auth/api.ts` + folders `["features"]` → `"features/auth"`.
218
+ *
219
+ * A bare name stays that unanchored one-segment match. The child segment is
220
+ * never the filename, so a flat file under the parent is not its own slice.
221
+ * A starred prefix (`lib/features` plus one star per extra directory) reuses
222
+ * the shared-root anchor: it must sit at offset 0, or at offset 1 after `src/`
223
+ * or `app/`. The slice id is the concrete directories the stars bind. A star
224
+ * never binds the filename; a file directly under the last bound directory
225
+ * keeps that directory as its slice.
218
226
  */
219
227
  export function sliceIdForPath(relPath, sliceFolders) {
220
228
  if (!sliceFolders?.length)
@@ -222,11 +230,87 @@ export function sliceIdForPath(relPath, sliceFolders) {
222
230
  const parts = String(relPath)
223
231
  .split(/[/\\]/)
224
232
  .filter(Boolean);
225
- const folders = new Set(sliceFolders.map((s) => String(s).toLowerCase()));
226
- for (let i = 0; i < parts.length - 1; i += 1) {
227
- if (folders.has(parts[i].toLowerCase())) {
228
- return `${parts[i].toLowerCase()}/${parts[i + 1].toLowerCase()}`;
233
+ if (parts.length === 0)
234
+ return undefined;
235
+ const bare = new Set();
236
+ for (const raw of sliceFolders) {
237
+ if (typeof raw !== 'string' || raw.length === 0)
238
+ continue;
239
+ if (isAnchoredSliceEntry(raw)) {
240
+ const anchored = anchoredSliceId(parts, raw);
241
+ if (anchored)
242
+ return anchored;
243
+ continue;
229
244
  }
245
+ if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('*')) {
246
+ bare.add(raw.toLowerCase());
247
+ }
248
+ }
249
+ return bareSliceId(parts, bare);
250
+ }
251
+ /** A starred prefix with a real leading directory, not a bare name and not `*`. */
252
+ function isAnchoredSliceEntry(entry) {
253
+ const segments = entry.split(/[/\\]/).filter((part) => part.length > 0);
254
+ if (segments.length < 2)
255
+ return false;
256
+ if (segments[0] === '*' || segments[0] === '**')
257
+ return false;
258
+ return segments.some((part) => part === '*');
259
+ }
260
+ function anchoredSliceId(parts, raw) {
261
+ const pattern = raw
262
+ .split(/[/\\]/)
263
+ .filter((part) => part.length > 0)
264
+ .map((part) => part.toLowerCase());
265
+ const offsets = anchorOffsets(parts, pattern[0] ?? '');
266
+ for (const offset of offsets) {
267
+ const id = bindAnchoredSlice(parts, pattern, offset);
268
+ if (id)
269
+ return id;
270
+ }
271
+ return undefined;
272
+ }
273
+ /**
274
+ * Walk the prefix. Literals must match. Each star binds one directory and
275
+ * stops before the filename; leftover stars then end the id at the last
276
+ * directory that did bind.
277
+ */
278
+ function bindAnchoredSlice(parts, pattern, offset) {
279
+ const bound = [];
280
+ let index = offset;
281
+ for (let pi = 0; pi < pattern.length; pi += 1) {
282
+ const segment = pattern[pi];
283
+ if (segment === '*') {
284
+ if (index >= parts.length - 1) {
285
+ for (let rest = pi; rest < pattern.length; rest += 1) {
286
+ if (pattern[rest] !== '*')
287
+ return undefined;
288
+ }
289
+ break;
290
+ }
291
+ bound.push(parts[index].toLowerCase());
292
+ index += 1;
293
+ continue;
294
+ }
295
+ if (segment === '**' || index >= parts.length)
296
+ return undefined;
297
+ if (parts[index].toLowerCase() !== segment)
298
+ return undefined;
299
+ bound.push(parts[index].toLowerCase());
300
+ index += 1;
301
+ }
302
+ return bound.length > 0 ? bound.join('/') : undefined;
303
+ }
304
+ /** Bare names: leftmost folder, plus the next directory. Never the filename. */
305
+ function bareSliceId(parts, names) {
306
+ if (names.size === 0)
307
+ return undefined;
308
+ for (let i = 0; i < parts.length - 1; i += 1) {
309
+ if (!names.has(parts[i].toLowerCase()))
310
+ continue;
311
+ if (i + 1 >= parts.length - 1)
312
+ continue;
313
+ return `${parts[i].toLowerCase()}/${parts[i + 1].toLowerCase()}`;
230
314
  }
231
315
  return undefined;
232
316
  }
@@ -279,6 +363,16 @@ function trimTrailingSlashes(value) {
279
363
  }
280
364
  /** Source folders a declared shared root may sit under without being named. */
281
365
  const SHARED_ROOT_SOURCE_PREFIXES = ['src', 'app'];
366
+ /**
367
+ * Anchor at segment 0, or also at segment 1 when the path opens with `src/` or
368
+ * `app/` and the declaration does not itself start with that folder.
369
+ * Shared roots and starred slice prefixes share this offset.
370
+ */
371
+ function anchorOffsets(parts, rootHead) {
372
+ const head = (parts[0] ?? '').toLowerCase();
373
+ const root = rootHead.toLowerCase();
374
+ return SHARED_ROOT_SOURCE_PREFIXES.includes(head) && root !== head ? [0, 1] : [0];
375
+ }
282
376
  /** A root that would disable the wall wholesale is not a root. */
283
377
  function isBlanketRoot(raw) {
284
378
  const trimmed = trimTrailingSlashes(raw.replace(/^[./]+/, ''));
@@ -287,7 +381,7 @@ function isBlanketRoot(raw) {
287
381
  /**
288
382
  * Is `relPath` under one of the roots the rule declares shared on purpose?
289
383
  *
290
- * **Anchored**, unlike `sliceIdForPath`: the root must start the repo-relative
384
+ * **Anchored**: the root must start the repo-relative
291
385
  * path, optionally after a single conventional source folder, so `ui` covers
292
386
  * `ui/button.tsx` and `src/ui/button.tsx` but NOT `modules/a/ui/x.tsx` — an
293
387
  * unanchored root would exempt a whole tree the author never declared. Deeper
@@ -317,9 +411,7 @@ export function pathUnderSharedRoot(relPath, sharedRoots) {
317
411
  const root = normalizeSegments(raw);
318
412
  if (root.length === 0)
319
413
  continue;
320
- // Anchor at segment 0, or at segment 1 when the path opens with a source
321
- // folder the root does not itself name.
322
- const offsets = SHARED_ROOT_SOURCE_PREFIXES.includes(parts[0]) && root[0] !== parts[0] ? [0, 1] : [0];
414
+ const offsets = anchorOffsets(parts, root[0] ?? '');
323
415
  for (const offset of offsets) {
324
416
  if (offset + root.length > parts.length)
325
417
  continue;
@@ -364,7 +456,8 @@ export function crossSliceEdgeAllowed(allowedCrossSlice, fromSlice, toSlice) {
364
456
  * cross-slice edge, is the repo telling us its design, so it is no longer
365
457
  * "unclassifiable". Order: no fromPath → no slice folders → intent (no toPath):
366
458
  * shared-root allow / slice fail-closed → a side that is neither in a slice nor
367
- * declared shared → same slice → declared cross edge → deny.
459
+ * declared shared → shared root importing a slice (only when the rule denies
460
+ * that hop) → same slice → declared cross edge → deny.
368
461
  *
369
462
  * Intent/event names are not files. Callers pass fromPath only and must not
370
463
  * invent a toPath (that would mis-slice). A declared shared root is classified
@@ -386,6 +479,14 @@ export function peerIsolationDecision(input) {
386
479
  const toClassified = Boolean(input.toSlice) || input.toShared === true;
387
480
  if (!fromClassified || !toClassified)
388
481
  return { denied: true, reason: 'unclassifiable-path' };
482
+ // Shared roots are a sink. The hop is allowed unless the rule opts in.
483
+ // allowedCrossSlice does not excuse it: this is not a slice-to-slice edge.
484
+ if (input.sharedImportsSlice === 'deny' &&
485
+ input.fromShared === true &&
486
+ Boolean(input.toSlice) &&
487
+ !input.fromSlice) {
488
+ return { denied: true, reason: 'shared-imports-slice' };
489
+ }
389
490
  // At least one side is declared shared (and carries no slice id): the repo
390
491
  // said this code belongs to no slice, so there is no cross-slice edge here.
391
492
  if (!input.fromSlice || !input.toSlice)
@@ -416,6 +517,8 @@ export function peerIsolationDenyExplanation(reason, context) {
416
517
  switch (reason) {
417
518
  case 'cross-slice':
418
519
  return `cross-slice edge ${context.fromSlice ?? '?'} → ${context.toSlice ?? '?'}. Extract the shared code, use events/ports across slices, or declare the edge in the rule's allowedCrossSlice.`;
520
+ case 'shared-imports-slice':
521
+ return `shared root imports slice ${context.toSlice ?? '?'} (${context.fromPath ?? '?'} → ${context.toPath ?? '?'}). The wall is direct-only.`;
419
522
  case 'unclassifiable-path': {
420
523
  const unplaced = [
421
524
  context.fromSlice ? undefined : context.fromPath,
@@ -474,6 +577,7 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
474
577
  fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
475
578
  toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
476
579
  crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
580
+ sharedImportsSlice: rule.sharedImportsSlice,
477
581
  });
478
582
  if (decision.denied) {
479
583
  return { rule, peerIsolationReason: decision.reason, fromSlice, toSlice };
@@ -487,6 +591,34 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
487
591
  }
488
592
  return undefined;
489
593
  }
594
+ /**
595
+ * Shared-root → slice edges the check still allows. Listed so a green run
596
+ * says the wall is direct-only. When the rule sets `sharedImportsSlice: "deny"`
597
+ * the same hop is a violation instead, so it is not listed twice.
598
+ */
599
+ export function findSharedImportsSliceBridge(rules, from, to, options) {
600
+ const fromPath = options?.fromPath;
601
+ const toPath = options?.toPath;
602
+ if (!fromPath || !toPath)
603
+ return undefined;
604
+ for (const rule of rules ?? []) {
605
+ if (rule.from !== from || rule.to !== to)
606
+ continue;
607
+ if (rule.allowed !== false || !rule.peerIsolation)
608
+ continue;
609
+ if (rule.sharedImportsSlice === 'deny')
610
+ continue;
611
+ const folders = resolveSliceFolders(rule, from, options?.layers);
612
+ const fromSlice = sliceIdForPath(fromPath, folders);
613
+ const toSlice = sliceIdForPath(toPath, folders);
614
+ if (fromSlice || !toSlice)
615
+ continue;
616
+ if (!pathUnderSharedRoot(fromPath, rule.sharedRoots))
617
+ continue;
618
+ return { fromPath, toPath, toSlice };
619
+ }
620
+ return undefined;
621
+ }
490
622
  export function isEdgeDenied(rules, from, to, options) {
491
623
  return findDeniedEdgeRule(rules, from, to, options) !== undefined;
492
624
  }
package/bin/ark.mjs CHANGED
@@ -26,11 +26,13 @@ import {
26
26
  applyStartPreview,
27
27
  emitStartSetupBudgetRefuse,
28
28
  evaluateStartSetupBudgetGate,
29
- formatStartPackageInstallFailure,
30
29
  planStart,
31
30
  renderStartPreview,
32
- runStartPackageInstall,
33
31
  } from './lib/start-preview.mjs';
32
+ import {
33
+ formatStartPackageInstallFailure,
34
+ runStartPackageInstall,
35
+ } from './lib/start-install-recovery.mjs';
34
36
  import { runUpgradeCommand } from './lib/upgrade-command.mjs';
35
37
  import { detectActiveAgentHost } from './lib/skill-install.mjs';
36
38
  import { loadArkConfigContract } from './lib/config-contract.mjs';
@@ -509,6 +511,7 @@ async function start(args) {
509
511
  exitStatus: install.status,
510
512
  installCommand,
511
513
  hostOutput: `${install.stdout}\n${install.stderr}`,
514
+ packageVersion: cliVersion(),
512
515
  })
513
516
  );
514
517
  return install.status;
@@ -584,6 +587,7 @@ async function start(args) {
584
587
  exitStatus: installStatus,
585
588
  installCommand: `${command} ${commandArgs.join(' ')}`,
586
589
  hostOutput,
590
+ packageVersion: cliVersion(),
587
591
  })
588
592
  );
589
593
  }