arkgate 2.8.3 → 2.9.0

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 (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +2 -1
  3. package/bin/ark-check.mjs +2 -2
  4. package/bin/ark-layer-match.mjs +88 -5
  5. package/bin/ark-mcp.mjs +7 -70
  6. package/bin/ark-shared.mjs +88 -8
  7. package/bin/ark.mjs +3 -2
  8. package/bin/lib/architecture-scan.mjs +16 -4
  9. package/bin/lib/config-warnings.mjs +11 -2
  10. package/bin/lib/doctor-plan.mjs +20 -0
  11. package/bin/lib/import-resolve.mjs +133 -0
  12. package/bin/lib/presets.mjs +207 -11
  13. package/bin/lib/remediation.mjs +15 -0
  14. package/bin/lib/suggestions.mjs +8 -3
  15. package/dist/eslint/index.cjs +63 -5
  16. package/dist/eslint/index.cjs.map +1 -1
  17. package/dist/eslint/index.d.cts +33 -1
  18. package/dist/eslint/index.d.ts +33 -1
  19. package/dist/eslint/index.js +63 -5
  20. package/dist/eslint/index.js.map +1 -1
  21. package/dist/index.cjs +103 -14
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +21 -9
  24. package/dist/index.d.ts +21 -9
  25. package/dist/index.js +103 -14
  26. package/dist/index.js.map +1 -1
  27. package/dist/nestjs/index.cjs +78 -4
  28. package/dist/nestjs/index.cjs.map +1 -1
  29. package/dist/nestjs/index.d.cts +1 -1
  30. package/dist/nestjs/index.d.ts +1 -1
  31. package/dist/nestjs/index.js +78 -4
  32. package/dist/nestjs/index.js.map +1 -1
  33. package/dist/runtime/index.cjs +103 -14
  34. package/dist/runtime/index.cjs.map +1 -1
  35. package/dist/runtime/index.d.cts +1 -1
  36. package/dist/runtime/index.d.ts +1 -1
  37. package/dist/runtime/index.js +103 -14
  38. package/dist/runtime/index.js.map +1 -1
  39. package/dist/{types-CSJhEOk2.d.cts → types-D6Q8WHes.d.cts} +7 -0
  40. package/dist/{types-CSJhEOk2.d.ts → types-D6Q8WHes.d.ts} +7 -0
  41. package/docs/agent-guide.md +55 -4
  42. package/package.json +3 -1
  43. package/server.json +2 -2
  44. package/templates/architecture-playbook.json +65 -1
  45. package/templates/policy-packs/enthusiast-ddd-bounded-contexts.json +19 -0
  46. package/templates/policy-packs/enthusiast-ui-surface.json +18 -0
  47. package/templates/policy-packs/enthusiast-vertical-slice.json +18 -0
  48. package/templates/skills/ark-adopt.md +4 -0
  49. package/templates/skills/ark-architect.md +5 -1
  50. package/templates/skills/ark-autopilot.md +3 -1
  51. package/templates/skills/ark-fix.md +3 -0
  52. package/templates/skills/ark-place.md +7 -0
  53. package/templates/skills/ark-think.md +43 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.9.0 — 2026-07-09
8
+
9
+ Track P: slice isolation, vertical-slice + DDD presets, skill surface, and adoption depth.
10
+ **No intentional CLI flag or JSON shape breaks** for existing presets; new rules/presets are opt-in.
11
+
12
+ ### Added
13
+
14
+ - **`peerIsolation` edge rules (P0):** opt-in cross-slice bans
15
+ (e.g. `features/auth` ↛ `features/payments`). Optional `sliceFolders`.
16
+ Wired in `ark-check`, ESLint, write-gate, remediation (`cross-slice-boundary`, judgment).
17
+ - **`vertical-slice` preset (P2):** Features / Shared / Lib / App with peerIsolation on
18
+ Features. `ark init --preset vertical-slice`. CLI help and fit scoring include all
19
+ public presets (`ui-surface` documented).
20
+ - **P3 vertical-slice adoption surface:** playbook archetype `vertical-slice-product`,
21
+ signal `verticalSliceLayout`, policy pack `enthusiast-vertical-slice`, gallery
22
+ `examples/vertical-slice-starter/` (strict-config green).
23
+ - **P4 `ddd-bounded-contexts` preset:** contexts/*/domain|application|presentation|infra +
24
+ SharedKernel; peerIsolation matrix blocks **any** cross-context import (same or
25
+ cross technical layer). Archetype, pack, gallery starter.
26
+ - **Skills (S1/S3):** architect/place/fix/adopt/autopilot know vertical-slice + DDD;
27
+ new host-only `/ark-think` skill (no package LLM). Refresh installs with
28
+ `ark-check --install-agent-gates --skills-only --force`.
29
+ - **Eval corpus (S5):** `eval/cases/vertical-slice-cross-feature` labeled peerIsolation case
30
+ (`cross-slice-boundary` fixClass, judgment).
31
+ - **S2 recommend/doctor:** JSON/human output includes `galleryStarter` + `policyPack`; wizard
32
+ choices for vertical-slice and DDD; doctor new-here lines for Nest modular and monorepo tooling.
33
+ - **P5 monorepo depth:** default include falls back to `packages`/`apps`/`libs`; detect
34
+ `turbo.json` / `nx.json`; playbook boosts multi-app-workspace on monorepo tooling.
35
+ - **P6 FSD patterns:** feature-sliced accepts `src/<layer>/**` and root `<layer>/**` (app/pages).
36
+ - **P7 aliases:** `clean-architecture` and `onion-architecture` → hexagonal factory.
37
+ - **P8 Nest guidance:** agent-guide + doctor tip (hexagonal vs ddd-bounded-contexts).
38
+
39
+ ### Changed
40
+
41
+ - **Same-layer deny semantics (locked):** classic `{ allowed: false }` without
42
+ `peerIsolation` never blocks same-layer edges (historical short-circuit restored /
43
+ confirmed). Only `peerIsolation: true` may deny, and only when slice ids differ.
44
+ - **`peerIsolation` applies cross-layer too:** when set, deny only if slices differ
45
+ (enables honest DDD inter-context isolation for e.g. application→domain across contexts).
46
+ - **`FRAMEWORK_INTERNAL_EXCLUDE`:** `src/kernel/**` + `**/src/kernel/**` only — no longer
47
+ `**/kernel/**` (which carved out `src/shared/kernel/**`).
48
+ - **Write-gate import resolve:** single `resolveImportTarget` primitive in
49
+ `bin/lib/import-resolve.mjs`; `ark-mcp` entry stays under 1000 LOC.
50
+ - **Gallery starters:** `npm run check:gallery-starters` fails on factory drift;
51
+ `generate:gallery-starters` rewrites configs from presets.
52
+
7
53
  ## 2.8.3 — 2026-07-09
8
54
 
9
55
  Field residuals + official site: core ratchet to honest ENFORCE, typecheck bootstrap,
package/README.md CHANGED
@@ -153,6 +153,7 @@ Install once: `npx arkgate-check --install-agent-gates`
153
153
  | Deep brownfield / manifest mining alone | `/ark-adopt` |
154
154
  | New file placement | `/ark-place` |
155
155
  | Gate violation on a change | `/ark-fix` |
156
+ | Design trade-offs within the contract (no package LLM) | `/ark-think` |
156
157
  | Edit `ark.config.json` safely | `/ark-contract` |
157
158
  | Plain-language tour of the report | `/ark-explain` |
158
159
  | Deep “what am I not using?” audit | `/ark-coverage` |
@@ -173,7 +174,7 @@ ark.config.json
173
174
  └─► Runtime kernel (opt-in) — only if you call it
174
175
  ```
175
176
 
176
- - **Presets:** hexagonal, layered, feature-sliced, monorepo (layers start optional; doctor suggests tightening populated cores).
177
+ - **Presets:** hexagonal, layered, feature-sliced, monorepo, ui-surface, vertical-slice, ddd-bounded-contexts (+ aliases clean-architecture / onion-architecture). Layers start optional; doctor suggests tightening populated cores. Cross-slice / cross-context bans use optional `peerIsolation` rules.
177
178
  - **Frameworks:** Nest / Next / express / library layouts get sensible globs on init so day-one coverage is real.
178
179
  - **Brownfield:** baseline ratchet, refuse to freeze a wrong contract, `/ark-adopt` for mature trees.
179
180
  - **Agents:** skills install into Claude / Cursor / Codex / Grok; `ark start` freezes an origin report under `.ark/reports/`.
package/bin/ark-check.mjs CHANGED
@@ -201,7 +201,7 @@ function usage() {
201
201
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
202
202
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
203
203
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
204
- ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo, ui-surface)',
204
+ ' ark-check --list-policy-packs enthusiast packs (hexagonal, layered, feature-sliced, monorepo, ui-surface, vertical-slice, ddd-bounded-contexts)',
205
205
  ' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
206
206
  ' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
207
207
  ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
@@ -209,7 +209,7 @@ function usage() {
209
209
  ' ark-check --watch re-run the check when governed files change (debounced)',
210
210
  ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
211
211
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
212
- ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo] [--force]',
212
+ ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture] [--force]',
213
213
  ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--skills-only] [--codex-home] [--force]',
214
214
  ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
215
215
  ' ark-check --print-config eleven-layer',
@@ -135,11 +135,94 @@ export function layerForRelativePath(relPath, layers) {
135
135
  }
136
136
  return bestName;
137
137
  }
138
- export function isEdgeDenied(rules, from, to) {
139
- if (from === to)
140
- return false;
141
- const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
142
- return hit?.allowed === false;
138
+ /**
139
+ * Extract the slice id under a known folder name.
140
+ * Includes the parent folder so `features/auth` ≠ `modules/auth`.
141
+ * `src/features/auth/api.ts` + folders `["features"]` `"features/auth"`.
142
+ */
143
+ export function sliceIdForPath(relPath, sliceFolders) {
144
+ if (!sliceFolders?.length)
145
+ return undefined;
146
+ const parts = String(relPath)
147
+ .split(/[/\\]/)
148
+ .filter(Boolean);
149
+ const folders = new Set(sliceFolders.map((s) => String(s).toLowerCase()));
150
+ for (let i = 0; i < parts.length - 1; i += 1) {
151
+ if (folders.has(parts[i].toLowerCase())) {
152
+ return `${parts[i]}/${parts[i + 1]}`;
153
+ }
154
+ }
155
+ return undefined;
156
+ }
157
+ /**
158
+ * Infer slice parent folders from layer globs: the path segment immediately
159
+ * before a `*` or `**` wildcard (e.g. `src/features/**` → `features`).
160
+ */
161
+ export function inferSliceFoldersFromPatterns(patterns) {
162
+ const out = new Set();
163
+ for (const pattern of patterns ?? []) {
164
+ const glob = normalizeGlobSeparators(String(pattern));
165
+ const parts = glob.split('/').filter(Boolean);
166
+ for (let i = 0; i < parts.length; i += 1) {
167
+ const part = parts[i];
168
+ if ((part === '**' || part === '*') && i > 0) {
169
+ const prev = parts[i - 1];
170
+ if (prev && !prev.includes('*') && !prev.includes('{') && !prev.includes('}')) {
171
+ out.add(prev);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ return [...out];
177
+ }
178
+ function resolveSliceFolders(rule, layerName, layers) {
179
+ if (Array.isArray(rule.sliceFolders) && rule.sliceFolders.length > 0) {
180
+ return rule.sliceFolders.filter((s) => typeof s === 'string' && s.length > 0);
181
+ }
182
+ const layer = (layers ?? []).find((l) => l.name === layerName);
183
+ return inferSliceFoldersFromPatterns(layer?.patterns);
184
+ }
185
+ /**
186
+ * Find the first denying rule for a layer edge.
187
+ *
188
+ * Semantics (locked):
189
+ * - Classic (`allowed: false`, no peerIsolation): deny cross-layer edges only.
190
+ * Same-layer is always allowed (historical short-circuit).
191
+ * - `peerIsolation: true` + `allowed: false`: deny only when importer and importee
192
+ * resolve to **different** slice ids (same or cross layer). Same-slice → allow.
193
+ * Missing paths or unclassifiable slices → fail-open (do not deny).
194
+ */
195
+ export function findDeniedEdgeRule(rules, from, to, options) {
196
+ for (const rule of rules ?? []) {
197
+ if (rule.from !== from || rule.to !== to)
198
+ continue;
199
+ if (rule.allowed !== false)
200
+ continue;
201
+ if (rule.peerIsolation) {
202
+ const fromPath = options?.fromPath;
203
+ const toPath = options?.toPath;
204
+ if (!fromPath || !toPath)
205
+ continue;
206
+ const folders = resolveSliceFolders(rule, from, options?.layers);
207
+ if (folders.length === 0)
208
+ continue;
209
+ const fromSlice = sliceIdForPath(fromPath, folders);
210
+ const toSlice = sliceIdForPath(toPath, folders);
211
+ if (!fromSlice || !toSlice)
212
+ continue;
213
+ if (fromSlice !== toSlice)
214
+ return rule;
215
+ continue; // same slice: this peerIsolation rule does not deny
216
+ }
217
+ // Classic deny — same-layer always allowed without peerIsolation
218
+ if (from === to)
219
+ continue;
220
+ return rule;
221
+ }
222
+ return undefined;
223
+ }
224
+ export function isEdgeDenied(rules, from, to, options) {
225
+ return findDeniedEdgeRule(rules, from, to, options) !== undefined;
143
226
  }
144
227
  /** Codegen globs skipped by default scan (emitted into the CLI derived matcher). */
145
228
  export const DEFAULT_GENERATED_FILE_GLOBS = [
package/bin/ark-mcp.mjs CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  detectTsPackageRoots,
46
46
  resolveIncludeRoots,
47
47
  } from './ark-shared.mjs';
48
+ import { createImportTargetResolver } from './lib/import-resolve.mjs';
48
49
 
49
50
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
50
51
 
@@ -97,73 +98,6 @@ function inferLayer(filePath, config, root) {
97
98
  return layerForFile(root, filePath, config.layers);
98
99
  }
99
100
 
100
- // Read tsconfig path aliases via the TypeScript config parser (so JSONC + `extends` work).
101
- // Returns { baseUrl (absolute), aliases: [{ from, to }] } — `from` a prefix like "@/", `to`
102
- // its base like "./src/". Empty when typescript/tsconfig is unavailable; callers then resolve
103
- // only relative imports (aliased ones fall through to the infra heuristic — no worse than before).
104
- function readTsconfigAliases(ts, root) {
105
- if (!ts) return { baseUrl: root, aliases: [] };
106
- try {
107
- const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
108
- if (!configPath) return { baseUrl: root, aliases: [] };
109
- const read = ts.readConfigFile(configPath, ts.sys.readFile);
110
- if (read.error) return { baseUrl: root, aliases: [] };
111
- const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
112
- const opts = parsed.options || {};
113
- const baseUrl = opts.baseUrl || path.dirname(configPath);
114
- const aliases = [];
115
- for (const [pattern, targets] of Object.entries(opts.paths || {})) {
116
- if (!Array.isArray(targets) || targets.length === 0) continue;
117
- aliases.push({ from: pattern.replace(/\*$/, ''), to: String(targets[0]).replace(/\*$/, '') });
118
- }
119
- aliases.sort((a, b) => b.from.length - a.from.length); // longest prefix wins
120
- return { baseUrl, aliases };
121
- } catch {
122
- return { baseUrl: root, aliases: [] };
123
- }
124
- }
125
-
126
- // Resolve an import specifier to a repo-relative target path (directory-level — enough to
127
- // classify the layer). Relative and tsconfig-aliased imports resolve; bare packages return
128
- // undefined (ungoverned → the gate's infra heuristic decides those).
129
- function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
130
- let abs;
131
- if (specifier.startsWith('./') || specifier.startsWith('../')) {
132
- if (!fromFilePath) return undefined;
133
- abs = path.resolve(path.dirname(path.resolve(root, fromFilePath)), specifier);
134
- } else {
135
- const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
136
- if (!alias) return undefined; // bare package specifier
137
- abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
138
- }
139
- const rel = path.relative(root, abs).split(path.sep).join('/');
140
- return rel.startsWith('..') ? undefined : rel; // outside the project root
141
- }
142
-
143
- // Build the resolveImportLayer callback for the AI write gate: specifier → target layer, so
144
- // the gate lets ark.config.json layer RULES govern a resolvable edge instead of the infra
145
- // path-heuristic. A barrel/dir import (`@/lib/db`) is classified like a file under it.
146
- function createImportLayerResolver(ts, root, config) {
147
- const layers = config.layers ?? [];
148
- if (layers.length === 0) return undefined;
149
- const tsAliases = readTsconfigAliases(ts, root);
150
- return (specifier, fromFilePath) => {
151
- const rel = resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases);
152
- if (!rel) return undefined;
153
- // When the target is a real directory (a barrel import like `@/lib/db`), classify a file
154
- // INSIDE it so it resolves to the `src/lib/db/**` layer, not a broader `src/lib/**` that
155
- // the bare path would also match. Falls back to the same probe when the target isn't on
156
- // disk (e.g. a file referenced without its extension).
157
- let probe = rel;
158
- try {
159
- if (fs.statSync(path.join(root, rel)).isDirectory()) probe = `${rel}/index.ts`;
160
- } catch {
161
- /* not on disk — classify the path as-is, with the dir-style fallback below */
162
- }
163
- return layerForFile(root, probe, layers) || layerForFile(root, `${rel}/index.ts`, layers);
164
- };
165
- }
166
-
167
101
  async function loadArk() {
168
102
  const url = new URL('../dist/index.js', import.meta.url);
169
103
  if (!fs.existsSync(url)) {
@@ -501,9 +435,12 @@ async function main() {
501
435
  typescript: ts,
502
436
  forbiddenGlobals,
503
437
  infrastructureLayers,
504
- // Contract-first: a resolvable import edge is judged by the config's layer rules, not the
505
- // infra path-heuristic — so the write gate agrees with ark-check (ark.config.json wins).
506
- resolveImportLayer: createImportLayerResolver(ts, args.root, config),
438
+ // Contract-first: one resolve step yields layer + relPath for rules + peerIsolation.
439
+ resolveImportTarget: createImportTargetResolver(ts, args.root, config),
440
+ architectureLayers: configLayers.map((layer) => ({
441
+ name: layer.name,
442
+ patterns: layer.patterns,
443
+ })),
507
444
  });
508
445
 
509
446
  if (args.hook) {
@@ -446,6 +446,9 @@ export {
446
446
  layerForFile,
447
447
  layerForRelativePath,
448
448
  isEdgeDenied,
449
+ findDeniedEdgeRule,
450
+ sliceIdForPath,
451
+ inferSliceFoldersFromPatterns,
449
452
  DEFAULT_GENERATED_FILE_GLOBS,
450
453
  scanExcludePatterns,
451
454
  isScanExcludedRelative,
@@ -666,6 +669,8 @@ export const ARCHETYPE_IDS = [
666
669
  'integration-bridge',
667
670
  'multi-app-workspace',
668
671
  'prototype-spike',
672
+ 'vertical-slice-product',
673
+ 'ddd-bounded-contexts',
669
674
  ];
670
675
 
671
676
  const UI_DIR_NAMES = new Set([
@@ -1142,6 +1147,18 @@ export function collectRepoShapeSignals(root) {
1142
1147
  ['app', 'pages', 'features', 'entities', 'shared'].some((name) =>
1143
1148
  fs.existsSync(path.join(root, 'src', name))
1144
1149
  );
1150
+ // Vertical slice: features/* co-located slices + shared/lib escape, without full FSD ladder
1151
+ // (entities/widgets). Distinct from featureSlicedLayout which matches any FSD folder.
1152
+ const verticalSliceLayout =
1153
+ fs.existsSync(path.join(root, 'src', 'features')) &&
1154
+ (fs.existsSync(path.join(root, 'src', 'shared')) ||
1155
+ fs.existsSync(path.join(root, 'src', 'lib'))) &&
1156
+ !fs.existsSync(path.join(root, 'src', 'entities')) &&
1157
+ !fs.existsSync(path.join(root, 'src', 'widgets'));
1158
+ // DDD multi-context tree (contexts/*/domain|application|infrastructure).
1159
+ const dddBoundedContextsLayout =
1160
+ fs.existsSync(path.join(root, 'src', 'contexts')) ||
1161
+ fs.existsSync(path.join(root, 'src', 'bounded-contexts'));
1145
1162
 
1146
1163
  const hasBin = Boolean(pkg?.bin);
1147
1164
  const hasExports = Boolean(pkg?.exports);
@@ -1180,6 +1197,22 @@ export function collectRepoShapeSignals(root) {
1180
1197
  }
1181
1198
  if (nextFramework && !toolHints.includes('next')) toolHints.push('next');
1182
1199
 
1200
+ // Turborepo / Nx markers (monorepo tooling — maps to monorepo preset, not separate engines).
1201
+ const monorepoTooling = [];
1202
+ if (fs.existsSync(path.join(root, 'turbo.json'))) monorepoTooling.push('turborepo');
1203
+ if (
1204
+ fs.existsSync(path.join(root, 'nx.json')) ||
1205
+ fs.existsSync(path.join(root, 'workspace.json'))
1206
+ ) {
1207
+ monorepoTooling.push('nx');
1208
+ }
1209
+ if (monorepoTooling.includes('turborepo') && !toolHints.includes('turborepo')) {
1210
+ toolHints.push('turborepo');
1211
+ }
1212
+ if (monorepoTooling.includes('nx') && !toolHints.includes('nx')) {
1213
+ toolHints.push('nx');
1214
+ }
1215
+
1183
1216
  return {
1184
1217
  workspaces,
1185
1218
  workspaceDirs,
@@ -1203,6 +1236,10 @@ export function collectRepoShapeSignals(root) {
1203
1236
  domainHeavy,
1204
1237
  uiOnly,
1205
1238
  featureSlicedLayout,
1239
+ verticalSliceLayout,
1240
+ dddBoundedContextsLayout,
1241
+ // null when absent so scoreArchetypes `if (signals.x)` is false for empty tooling
1242
+ monorepoTooling: monorepoTooling.length > 0 ? monorepoTooling : null,
1206
1243
  fullStackProduct,
1207
1244
  persistenceFromDeps,
1208
1245
  nestFramework,
@@ -1229,6 +1266,14 @@ const SIGNAL_WHY = {
1229
1266
  library: () => 'publishable package shape (exports/main, no CLI bin)',
1230
1267
  libraryOnly: () => 'library package without a CLI entry',
1231
1268
  featureSlicedLayout: () => 'feature-sliced directory layout under src/',
1269
+ verticalSliceLayout: () =>
1270
+ 'vertical-slice layout (src/features + shared/lib, without FSD entities/widgets)',
1271
+ dddBoundedContextsLayout: () =>
1272
+ 'DDD bounded contexts under src/contexts or src/bounded-contexts',
1273
+ monorepoTooling: (signals) =>
1274
+ signals.monorepoTooling?.length
1275
+ ? `monorepo tooling detected (${signals.monorepoTooling.join(', ')})`
1276
+ : 'monorepo tooling markers present',
1232
1277
  domain: () => 'domain directory present',
1233
1278
  application: () => 'application or services directory present',
1234
1279
  domainHeavy: () => 'both domain and application directories present',
@@ -1418,6 +1463,9 @@ export function buildArchitectureRecommendation(root, options = {}) {
1418
1463
  phase3: result.phases?.['3'] ?? [],
1419
1464
  };
1420
1465
 
1466
+ const galleryStarter = GALLERY_STARTER_BY_ARCHETYPE[result.archetype] ?? null;
1467
+ const policyPackId = policyPackIdForPreset(result.preset);
1468
+
1421
1469
  return {
1422
1470
  ok: true,
1423
1471
  playbookVersion: playbook.version,
@@ -1435,6 +1483,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
1435
1483
  matchedSignals: result.matched,
1436
1484
  runnerUp: result.runnerUp,
1437
1485
  toolHints: signals.toolHints,
1486
+ galleryStarter,
1487
+ policyPack: policyPackId,
1438
1488
  signals: {
1439
1489
  sourceFileCount: signals.sourceFileCount,
1440
1490
  workspaces: signals.workspaces,
@@ -1452,6 +1502,9 @@ export function buildArchitectureRecommendation(root, options = {}) {
1452
1502
  nestFramework: signals.nestFramework,
1453
1503
  nextFramework: signals.nextFramework,
1454
1504
  expressLike: signals.expressLike,
1505
+ verticalSliceLayout: signals.verticalSliceLayout,
1506
+ dddBoundedContextsLayout: signals.dddBoundedContextsLayout,
1507
+ monorepoTooling: signals.monorepoTooling,
1455
1508
  },
1456
1509
  // A repo past this size is not greenfield: `ark init` would scaffold a starter that governs
1457
1510
  // a thin slice and can mis-flag framework internals, so steer these to the adoption flow.
@@ -1473,7 +1526,9 @@ export const INIT_WIZARD_CHOICES = [
1473
1526
  { key: '5', archetype: 'worker-pipeline', label: 'Background jobs or workers' },
1474
1527
  { key: '6', archetype: 'multi-app-workspace', label: 'Several apps in one repository' },
1475
1528
  { key: '7', archetype: 'prototype-spike', label: 'A quick experiment or learning project' },
1476
- { key: '8', archetype: 'auto', label: 'Analyze my repo and suggest (recommended if unsure)' },
1529
+ { key: '8', archetype: 'vertical-slice-product', label: 'Feature-first slices (vertical slice)' },
1530
+ { key: '9', archetype: 'ddd-bounded-contexts', label: 'Multiple business domains (bounded contexts)' },
1531
+ { key: 'a', archetype: 'auto', label: 'Analyze my repo and suggest (recommended if unsure)' },
1477
1532
  ];
1478
1533
 
1479
1534
  export function isValidArchetypeId(id) {
@@ -1528,6 +1583,12 @@ export function formatArchitectureRecommendationHuman(recommendation) {
1528
1583
  lines.push('');
1529
1584
  lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1530
1585
  lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
1586
+ if (recommendation.galleryStarter) {
1587
+ lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
1588
+ }
1589
+ if (recommendation.policyPack) {
1590
+ lines.push(`Policy pack: ark-check --apply-policy-pack ${recommendation.policyPack}`);
1591
+ }
1531
1592
  if (recommendation.thinTsSurface) {
1532
1593
  lines.push('');
1533
1594
  lines.push(
@@ -1591,18 +1652,37 @@ const GALLERY_STARTER_BY_ARCHETYPE = {
1591
1652
  'api-backend': 'examples/api-backend-starter/',
1592
1653
  'worker-pipeline': 'examples/worker-pipeline-starter/',
1593
1654
  'multi-app-workspace': 'examples/multi-app-workspace-starter/',
1655
+ 'vertical-slice-product': 'examples/vertical-slice-starter/',
1656
+ 'ddd-bounded-contexts': 'examples/ddd-context-starter/',
1594
1657
  };
1595
1658
 
1596
- /** Machine-readable adoption record for optional commit (Phase E). */
1597
- export function buildAdoptionPlanDocument(recommendation) {
1598
- const preset = recommendation.preset;
1599
- const policyPackId =
1659
+ /** Enthusiast pack id for a named preset, or null when none ships. */
1660
+ export function policyPackIdForPreset(preset) {
1661
+ if (
1600
1662
  preset === 'hexagonal' ||
1601
1663
  preset === 'layered' ||
1602
1664
  preset === 'feature-sliced' ||
1603
- preset === 'monorepo'
1604
- ? `enthusiast-${preset}`
1605
- : null;
1665
+ preset === 'monorepo' ||
1666
+ preset === 'vertical-slice' ||
1667
+ preset === 'ddd-bounded-contexts' ||
1668
+ preset === 'ui-surface' ||
1669
+ preset === 'clean-architecture' ||
1670
+ preset === 'onion-architecture'
1671
+ ) {
1672
+ // clean/onion alias packs → hexagonal enthusiast pack
1673
+ const packPreset =
1674
+ preset === 'clean-architecture' || preset === 'onion-architecture'
1675
+ ? 'hexagonal'
1676
+ : preset;
1677
+ return `enthusiast-${packPreset}`;
1678
+ }
1679
+ return null;
1680
+ }
1681
+
1682
+ /** Machine-readable adoption record for optional commit (Phase E). */
1683
+ export function buildAdoptionPlanDocument(recommendation) {
1684
+ const preset = recommendation.preset;
1685
+ const policyPackId = recommendation.policyPack ?? policyPackIdForPreset(preset);
1606
1686
 
1607
1687
  return {
1608
1688
  version: '1',
package/bin/ark.mjs CHANGED
@@ -55,7 +55,7 @@ function parseArgs(argv) {
55
55
  function usage() {
56
56
  return `Usage:
57
57
  ark start [--root <project>] [--yes]
58
- ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo]
58
+ ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture]
59
59
  [--archetype <playbook-id>] [--tools <list>] [--yes] [--force] [--no-strict]
60
60
  ark upgrade [--root <project>] [--no-install] [--no-strict]
61
61
 
@@ -76,7 +76,8 @@ Options:
76
76
  --preset Start from a named architecture preset instead of detection.
77
77
  --archetype Application shape from templates/architecture-playbook.json (maps to the matching preset).
78
78
  Valid ids: crud-product, api-backend, frontend-surface, library-sdk, cli-utility,
79
- worker-pipeline, event-coordinator, integration-bridge, multi-app-workspace, prototype-spike.
79
+ worker-pipeline, event-coordinator, integration-bridge, multi-app-workspace, prototype-spike,
80
+ vertical-slice-product, ddd-bounded-contexts.
80
81
  --tools Comma-separated agents to gate (claude,cursor,codex,windsurf,cline,copilot,kiro,roo,continue,gemini).
81
82
  Omit to auto-detect from each tool's config dir, falling back to claude+cursor+codex.
82
83
 
@@ -237,10 +237,16 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
237
237
  importGraph.get(relFile).add(relTarget);
238
238
  }
239
239
  }
240
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
240
+ const relTarget = target ? normalize(path.relative(root, target)) : undefined;
241
+ const rule = targetLayer
242
+ ? isBlocked(rules, sourceLayer, targetLayer, {
243
+ fromPath: relFile,
244
+ toPath: relTarget,
245
+ layers: config.layers,
246
+ })
247
+ : undefined;
241
248
  if (rule) {
242
- const relTarget = normalize(path.relative(root, target));
243
- const targetCached = nextCacheFiles[relTarget];
249
+ const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
244
250
  const staticEdge = edge.kind === 'import' || edge.kind === 'export';
245
251
  const targetTypeOnlyExports =
246
252
  staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
@@ -257,6 +263,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
257
263
  targetTypeNames.size > 0 &&
258
264
  !targetCached?.hasTopLevelSideEffects &&
259
265
  named.every((n) => targetTypeNames.has(n));
266
+ const peerIsolation = Boolean(rule.peerIsolation);
260
267
  violations.push({
261
268
  ruleId: 'LAYER_IMPORT_VIOLATION',
262
269
  file: relFile,
@@ -269,7 +276,12 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
269
276
  ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
270
277
  ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
271
278
  ...(edge.kind ? { edgeKind: edge.kind } : {}),
272
- message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
279
+ ...(peerIsolation ? { peerIsolation: true } : {}),
280
+ message:
281
+ rule.message ??
282
+ (peerIsolation
283
+ ? `${sourceLayer} must not ${edge.kind} another slice of ${targetLayer} (${relFile} → ${relTarget}). Extract shared code or use events/ports across slices.`
284
+ : `${sourceLayer} must not ${edge.kind} ${targetLayer}.`),
273
285
  });
274
286
  }
275
287
  }
@@ -10,6 +10,7 @@ import {
10
10
  patternSpecificity,
11
11
  resolveIntentLayer,
12
12
  } from '../ark-shared.mjs';
13
+ import { findDeniedEdgeRule } from '../ark-layer-match.mjs';
13
14
  import { normalize } from './scan-files.mjs';
14
15
 
15
16
  export function intentLayersFromManifest(manifest) {
@@ -36,8 +37,16 @@ export function layerForIntent(intent, layers, manifestIntentLayers) {
36
37
  return resolveIntentLayer(intent, source);
37
38
  }
38
39
 
39
- export function isBlocked(rules, from, to) {
40
- return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
40
+ /**
41
+ * First denying edge rule for from→to, or undefined.
42
+ * Path-aware when options.fromPath / options.toPath are set (peerIsolation).
43
+ * @param {object[]} rules
44
+ * @param {string} from
45
+ * @param {string} to
46
+ * @param {{ fromPath?: string, toPath?: string, layers?: object[] }} [options]
47
+ */
48
+ export function isBlocked(rules, from, to, options) {
49
+ return findDeniedEdgeRule(rules, from, to, options);
41
50
  }
42
51
 
43
52
  export function configWarning(ruleId, message, extra = {}) {
@@ -330,6 +330,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
330
330
  archetype: recommendation?.archetype,
331
331
  label: recommendation?.label,
332
332
  preset: recommendation?.preset,
333
+ galleryStarter: recommendation?.galleryStarter,
334
+ policyPack: recommendation?.policyPack,
333
335
  recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
334
336
  initCommand: recommendation?.archetype
335
337
  ? arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)
@@ -409,6 +411,24 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
409
411
  console.log(color.bold('New here?'));
410
412
  if (recommendation) {
411
413
  line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
414
+ if (recommendation.galleryStarter) {
415
+ line(ok, `Gallery starter: ${recommendation.galleryStarter}`);
416
+ }
417
+ if (recommendation.policyPack) {
418
+ line(ok, `Policy pack: ${arkCommand(root, 'ark-check', `--apply-policy-pack ${recommendation.policyPack}`)}`);
419
+ }
420
+ if (recommendation.signals?.nestFramework) {
421
+ line(
422
+ ok,
423
+ 'Nest modular monolith → prefer hexagonal (or ddd-bounded-contexts if you have src/contexts/*)'
424
+ );
425
+ }
426
+ if (recommendation.signals?.monorepoTooling?.length) {
427
+ line(
428
+ ok,
429
+ `Monorepo tooling (${recommendation.signals.monorepoTooling.join(', ')}) → preset monorepo (apps/packages/libs)`
430
+ );
431
+ }
412
432
  } else {
413
433
  line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
414
434
  }