arkgate 4.0.1 → 4.1.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 (52) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/html-report-depth.mjs +167 -3
  20. package/bin/lib/html-report.mjs +12 -5
  21. package/bin/lib/install-migrate.mjs +109 -6
  22. package/bin/lib/managed-upgrade.mjs +99 -0
  23. package/bin/lib/presets.mjs +314 -46
  24. package/bin/lib/project-root.mjs +268 -0
  25. package/bin/lib/remediation.mjs +12 -11
  26. package/bin/lib/rules-inventory.mjs +71 -29
  27. package/bin/lib/rules-under-contract.mjs +134 -4
  28. package/bin/lib/start-preview.mjs +48 -14
  29. package/bin/lib/suggestions.mjs +118 -3
  30. package/bin/lib/unavailable-analysis.mjs +2 -0
  31. package/bin/lib/write-path-capabilities.mjs +38 -9
  32. package/dist/eslint/index.cjs +2 -2
  33. package/dist/eslint/index.d.ts +27 -2
  34. package/dist/eslint/index.js +2 -2
  35. package/dist/index.cjs +16 -14
  36. package/dist/index.d.ts +3 -1
  37. package/dist/index.js +16 -14
  38. package/docs/README.md +3 -3
  39. package/docs/ai-gates.md +15 -11
  40. package/docs/brownfield-adoption.md +36 -0
  41. package/docs/configuration.md +36 -0
  42. package/docs/package-surface.md +3 -3
  43. package/docs/product-voice.md +7 -0
  44. package/docs/typescript-support.md +9 -5
  45. package/package.json +3 -1
  46. package/server.json +3 -3
  47. package/templates/architecture-playbook.json +3 -0
  48. package/templates/layers/shared-types.starter.json +29 -0
  49. package/templates/skills/ark-adopt.md +2 -0
  50. package/templates/skills/ark-explain.md +5 -0
  51. package/templates/skills/ark-explore.md +21 -1
  52. package/templates/skills/ark-fix.md +16 -5
@@ -107,12 +107,87 @@ export function globToRegExp(pattern) {
107
107
  regexpCache.set(pattern, re);
108
108
  return re;
109
109
  }
110
- export function patternSpecificity(pattern) {
110
+ /**
111
+ * Concrete (non-wildcard) path segments in a glob, left-to-right.
112
+ * Used for path-anchored ranking so a domain folder glob can beat a broad
113
+ * Application bag like src/lib when the file actually sits under domain/.
114
+ */
115
+ export function concreteGlobSegments(pattern) {
116
+ const glob = normalizeGlobSeparators(String(pattern));
117
+ return glob
118
+ .split('/')
119
+ .filter(Boolean)
120
+ .filter((seg) => seg !== '**' &&
121
+ seg !== '*' &&
122
+ !seg.includes('*') &&
123
+ !seg.includes('?') &&
124
+ !seg.includes('{') &&
125
+ !seg.includes('['));
126
+ }
127
+ /**
128
+ * Rank competing layer globs.
129
+ *
130
+ * Without a path: concrete-segment count + literal length (historical shape).
131
+ * With a path: last matched concrete segment depth dominates so interior
132
+ * domain/persistence folders beat Application/Presentation scatter bags
133
+ * (DL-DOMAIN-SPECIFICITY / NEW-APP-VACUUM-LIB).
134
+ */
135
+ export function patternSpecificity(pattern, relPath) {
111
136
  const glob = normalizeGlobSeparators(String(pattern));
112
- const beforeWildcard = glob.split('*')[0];
113
- const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
137
+ const concrete = concreteGlobSegments(glob);
114
138
  const literalLength = glob.replace(/\*/g, '').length;
115
- return literalSegments * 10000 + literalLength;
139
+ const base = concrete.length * 10000 + literalLength;
140
+ if (relPath === undefined || relPath === null || relPath === '')
141
+ return base;
142
+ const pathParts = String(relPath)
143
+ .split(/[/\\]/)
144
+ .filter(Boolean);
145
+ if (concrete.length === 0) {
146
+ // Pure wildcards (`**`, `*`) — weakest possible match.
147
+ return literalLength;
148
+ }
149
+ let searchFrom = 0;
150
+ let lastIdx = -1;
151
+ for (const seg of concrete) {
152
+ let found = -1;
153
+ for (let i = searchFrom; i < pathParts.length; i += 1) {
154
+ if (pathParts[i] === seg) {
155
+ found = i;
156
+ break;
157
+ }
158
+ }
159
+ if (found < 0) {
160
+ // Glob matched but segments could not be placed (braces / exotic globs) — base only.
161
+ return base;
162
+ }
163
+ lastIdx = found;
164
+ searchFrom = found + 1;
165
+ }
166
+ // Depth of last concrete segment dominates; then segment count; then length.
167
+ return (lastIdx + 1) * 1_000_000 + concrete.length * 10000 + literalLength;
168
+ }
169
+ /**
170
+ * All layers whose patterns match the path (excludes applied), with best score per layer.
171
+ * Used for dual-membership coverage signals (P0A-DUAL-MATCH).
172
+ */
173
+ export function matchingLayersForRelativePath(relPath, layers) {
174
+ const rel = String(relPath).split(/[/\\]/).join('/');
175
+ const byLayer = new Map();
176
+ for (const layer of layers ?? []) {
177
+ if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
178
+ continue;
179
+ }
180
+ for (const pattern of layer.patterns ?? []) {
181
+ if (!globToRegExp(pattern).test(rel))
182
+ continue;
183
+ const score = patternSpecificity(pattern, rel);
184
+ const prev = byLayer.get(layer.name);
185
+ if (!prev || score > prev.score) {
186
+ byLayer.set(layer.name, { layer: layer.name, pattern, score });
187
+ }
188
+ }
189
+ }
190
+ return [...byLayer.values()].sort((a, b) => b.score - a.score || a.layer.localeCompare(b.layer));
116
191
  }
117
192
  export function layerForRelativePath(relPath, layers) {
118
193
  // File paths (not globs): any OS separator → posix relative.
@@ -125,7 +200,7 @@ export function layerForRelativePath(relPath, layers) {
125
200
  }
126
201
  for (const pattern of layer.patterns ?? []) {
127
202
  if (globToRegExp(pattern).test(rel)) {
128
- const score = patternSpecificity(pattern);
203
+ const score = patternSpecificity(pattern, rel);
129
204
  if (score > bestScore) {
130
205
  bestScore = score;
131
206
  bestName = layer.name;
@@ -129,16 +129,19 @@ export function applyFrameworkLayoutOverlays(config, root) {
129
129
  // claiming a framework that package.json does not declare.
130
130
  if (signals.apiSurface && !signals.nestFramework && !signals.expressLike) {
131
131
  ensureInclude('src');
132
+ // Controllers/routes/http stay Presentation. Do NOT add bare `src/**/api/**` —
133
+ // that swallows Application bags like `src/core/api/**` and Next `app/api/**`
134
+ // (P0-A API shell). API shells live on Application via NEXT_API / core patterns.
132
135
  mergeLayerPatterns(next, 'PresentationAdapters', [
133
136
  'src/**/routes/**',
134
137
  'src/**/controllers/**',
135
138
  'src/**/http/**',
136
- 'src/**/api/**',
137
139
  ]);
138
140
  mergeLayerPatterns(next, 'ApplicationOrchestration', [
139
141
  'src/**/services/**',
140
142
  'src/**/use-cases/**',
141
143
  'src/**/usecases/**',
144
+ 'src/**/api/**',
142
145
  ]);
143
146
  }
144
147
 
@@ -218,8 +221,9 @@ export function applyFrameworkLayoutOverlays(config, root) {
218
221
  'src/**/layout.ts',
219
222
  'src/**/loading.tsx',
220
223
  'src/**/error.tsx',
221
- 'src/**/route.ts',
222
- 'src/**/route.tsx',
224
+ // Do NOT add bare src/**/route.ts here — that outranks app/api Application shells
225
+ // under path-anchored specificity (P0-A / DL-P0A-RETROFIT). Non-API route handlers
226
+ // still match via src/app/** Presentation; API route.ts is Application via app/api/**.
223
227
  // Next middleware edge entry (classic + Next 16 proxy rename)
224
228
  'src/middleware.ts',
225
229
  'src/middleware.js',
@@ -235,18 +239,43 @@ export function applyFrameworkLayoutOverlays(config, root) {
235
239
  'src/server/**',
236
240
  'src/services/**',
237
241
  'src/use-cases/**',
238
- 'src/lib/**',
239
- // Common Next "app core" bags (API clients, hooks, auth, stores) not UI routes.
242
+ // NEVER bare src/lib/** — that Application vacuum mis-layers Domain/Persistence
243
+ // (NEW-APP-VACUUM-LIB / DL-DOMAIN-SPECIFICITY). Prefer specific orchestration bags.
244
+ 'src/lib/actions/**',
245
+ 'src/lib/services/**',
246
+ 'src/lib/server/**',
247
+ 'src/lib/use-cases/**',
248
+ 'src/lib/api-handlers/**',
249
+ 'src/lib/handlers/**',
250
+ // Common Next "app core" bags — not the whole lib tree, not UI routes.
240
251
  // Without these, monorepos like */src/core/** stay ungoverned and produce false greens.
241
252
  'src/core/**',
242
253
  '**/core/**',
243
254
  'src/actions/**',
244
255
  'src/**/actions.ts',
245
256
  'src/**/actions.tsx',
257
+ // Next API routes are use-case / orchestration shells (not Presentation UI).
258
+ // Higher-specificity than src/app/** / app/** so they win over PresentationAdapters.
259
+ // Include route-group transparent shells: app/(marketing)/api/**
260
+ 'src/app/api/**',
261
+ 'app/api/**',
262
+ 'src/pages/api/**',
263
+ 'pages/api/**',
264
+ '**/app/api/**',
265
+ '**/pages/api/**',
266
+ '**/app/**/api/**',
267
+ '**/pages/**/api/**',
268
+ 'src/app/**/api/**',
269
+ 'app/**/api/**',
246
270
  ]);
247
271
  mergeLayerPatterns(next, 'DomainModel', [
248
272
  'src/domain/**',
249
273
  'src/entities/**',
274
+ '**/domain/**',
275
+ '**/entities/**',
276
+ '**/kernel/domain/**',
277
+ 'src/**/domain/**',
278
+ 'src/**/entities/**',
250
279
  'src/**/model/**',
251
280
  'src/**/models/**',
252
281
  ]);
@@ -259,9 +288,10 @@ export function applyFrameworkLayoutOverlays(config, root) {
259
288
  'src/lib/db/**',
260
289
  'src/lib/prisma/**',
261
290
  'src/server/db/**',
262
- // Conventional client data bags under lib/ (higher specificity than bare src/lib/**).
291
+ // Conventional client data bags under lib/ (path-anchored specificity beats vacuum bags).
263
292
  'src/lib/supabase/**',
264
293
  'src/lib/airtable/**',
294
+ 'src/lib/turso/**',
265
295
  'src/lib/firebase/**',
266
296
  'src/lib/firestore/**',
267
297
  'src/lib/mongodb/**',
@@ -270,10 +300,17 @@ export function applyFrameworkLayoutOverlays(config, root) {
270
300
  'src/lib/kysely/**',
271
301
  'src/lib/planetscale/**',
272
302
  'src/lib/neon/**',
303
+ 'src/lib/auth/**',
273
304
  '**/lib/supabase/**',
274
305
  '**/lib/airtable/**',
275
306
  '**/lib/prisma/**',
307
+ '**/lib/turso/**',
276
308
  '**/lib/db/**',
309
+ '**/lib/auth/**',
310
+ '**/repositories/**',
311
+ '**/db/**',
312
+ '**/supabase/**',
313
+ '**/airtable/**',
277
314
  ]);
278
315
  // Demo assets, generated public output, and tool configs are not architecture surface.
279
316
  const nextExcludes = [
@@ -428,6 +465,8 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
428
465
  export {
429
466
  globToRegExp,
430
467
  patternSpecificity,
468
+ concreteGlobSegments,
469
+ matchingLayersForRelativePath,
431
470
  layerForFile,
432
471
  layerForRelativePath,
433
472
  isEdgeDenied,
@@ -1272,6 +1311,25 @@ export function collectRepoShapeSignals(root) {
1272
1311
  fs.existsSync(path.join(root, 'src', 'contexts')) ||
1273
1312
  fs.existsSync(path.join(root, 'src', 'bounded-contexts'));
1274
1313
 
1314
+ // Vite SPA + root Vercel api/ + lib/ (NEW-SPA-DEFAULT-LAYOUT / superinsights-class).
1315
+ const viteDependency = Object.keys(deps).some(
1316
+ (name) => name === 'vite' || name.startsWith('vite/') || name === '@vitejs/plugin-react'
1317
+ );
1318
+ const viteConfigPresent = [
1319
+ 'vite.config.ts',
1320
+ 'vite.config.js',
1321
+ 'vite.config.mjs',
1322
+ 'vite.config.mts',
1323
+ ].some((name) => fs.existsSync(path.join(root, name)));
1324
+ const rootApiDir = fs.existsSync(path.join(root, 'api'));
1325
+ const rootLibDir = fs.existsSync(path.join(root, 'lib'));
1326
+ const viteVercelSpaLayout =
1327
+ !nextFramework &&
1328
+ !nestFramework &&
1329
+ (viteDependency || viteConfigPresent) &&
1330
+ (rootApiDir || rootLibDir) &&
1331
+ (ui || hasUiFramework || rootApiDir);
1332
+
1275
1333
  const hasBin = Boolean(pkg?.bin);
1276
1334
  const hasExports = Boolean(pkg?.exports);
1277
1335
  const hasMain = Boolean(pkg?.main || pkg?.module);
@@ -1308,6 +1366,7 @@ export function collectRepoShapeSignals(root) {
1308
1366
  toolHints.push('@nestjs/*');
1309
1367
  }
1310
1368
  if (nextFramework && !toolHints.includes('next')) toolHints.push('next');
1369
+ if (viteVercelSpaLayout && !toolHints.includes('vite')) toolHints.push('vite');
1311
1370
 
1312
1371
  // Turborepo / Nx markers (monorepo tooling — maps to monorepo preset, not separate engines).
1313
1372
  const monorepoTooling = [];
@@ -1353,6 +1412,7 @@ export function collectRepoShapeSignals(root) {
1353
1412
  featureSlicedLayout,
1354
1413
  verticalSliceLayout,
1355
1414
  dddBoundedContextsLayout,
1415
+ viteVercelSpaLayout,
1356
1416
  // null when absent so scoreArchetypes `if (signals.x)` is false for empty tooling
1357
1417
  monorepoTooling: monorepoTooling.length > 0 ? monorepoTooling : null,
1358
1418
  fullStackProduct,
@@ -1469,11 +1529,95 @@ export function loadArchitecturePlaybook(playbookPath = defaultPlaybookPath()) {
1469
1529
  }
1470
1530
 
1471
1531
  function resolvePreset(archetypeDef, signals) {
1472
- const alt = archetypeDef.presetAlternatives?.['feature-sliced'];
1473
- if (alt?.whenSignal && signals[alt.whenSignal]) return 'feature-sliced';
1532
+ const alts = archetypeDef.presetAlternatives ?? {};
1533
+ // SPA layout first more specific than feature-sliced / default layered.
1534
+ const spaAlt = alts['vite-vercel-spa'];
1535
+ if (spaAlt?.whenSignal && signals[spaAlt.whenSignal]) return 'vite-vercel-spa';
1536
+ // Also honor signal even when playbook omits the alternative key (older playbooks).
1537
+ if (signals.viteVercelSpaLayout) return 'vite-vercel-spa';
1538
+ const fsdAlt = alts['feature-sliced'];
1539
+ if (fsdAlt?.whenSignal && signals[fsdAlt.whenSignal]) return 'feature-sliced';
1474
1540
  return archetypeDef.preset;
1475
1541
  }
1476
1542
 
1543
+ /**
1544
+ * Refuse start --apply when shape is too uncertain (NEW-START-LOW-CONFIDENCE-SHAPE).
1545
+ * Applies to **all** apply paths (not only --yes). Explicit --archetype / --preset / --force bypasses.
1546
+ * Fail-closed when confidence or projected coverage metrics are null/unknown.
1547
+ *
1548
+ * Rules:
1549
+ * - empty greenfield (totalFiles === 0) → allow (scaffold only; nothing to mis-classify yet)
1550
+ * - null confidence or null projected coverage → refuse (fail-closed)
1551
+ * - projected coverage < 50% → refuse (wrong include / vacuum)
1552
+ * - confidence < 0.6 AND projected coverage < 80% → refuse (weak shape + incomplete cover)
1553
+ * - confidence < 0.6 with coverage ≥ 80% → allow (thin-library / capped confidence but contract covers)
1554
+ *
1555
+ * @returns {{ ok: true } | { ok: false, reasons: string[], confidence: number|null, projectedCoverage: number|null }}
1556
+ */
1557
+ export function evaluateStartShapeConfidenceGate({
1558
+ confidence,
1559
+ projectedCoveragePercent,
1560
+ totalFiles,
1561
+ explicitShape = false,
1562
+ force = false,
1563
+ minConfidence = 0.6,
1564
+ minProjectedCoverage = 50,
1565
+ strongCoverageFloor = 80,
1566
+ } = {}) {
1567
+ if (force || explicitShape) return { ok: true, bypassed: true };
1568
+ // Day-zero empty tree: contract + gates scaffold; coverage is vacuously fine.
1569
+ if (typeof totalFiles === 'number' && totalFiles === 0) {
1570
+ return {
1571
+ ok: true,
1572
+ emptyGreenfield: true,
1573
+ confidence: typeof confidence === 'number' ? confidence : null,
1574
+ projectedCoverage: 100,
1575
+ totalFiles: 0,
1576
+ };
1577
+ }
1578
+ const reasons = [];
1579
+ const conf =
1580
+ typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : null;
1581
+ const cov =
1582
+ typeof projectedCoveragePercent === 'number' && Number.isFinite(projectedCoveragePercent)
1583
+ ? projectedCoveragePercent
1584
+ : null;
1585
+ // Fail-closed on unknown metrics (null analysis must not silently apply).
1586
+ if (cov === null) {
1587
+ reasons.push(
1588
+ 'projected governed coverage is unknown — refuse apply without measured coverage (fail-closed)'
1589
+ );
1590
+ } else if (cov < minProjectedCoverage) {
1591
+ reasons.push(
1592
+ `projected governed coverage ${cov}% is below ${minProjectedCoverage}% — refuse apply`
1593
+ );
1594
+ }
1595
+ // Thin packages often cap confidence (e.g. 0.28) while covering 100% — do not block those.
1596
+ const coverageStrong = cov !== null && cov >= strongCoverageFloor;
1597
+ if (conf === null) {
1598
+ reasons.push(
1599
+ 'archetype confidence is unknown — refuse apply without shape analysis (fail-closed); pass --archetype/--preset or --force'
1600
+ );
1601
+ } else if (conf < minConfidence && !coverageStrong) {
1602
+ reasons.push(
1603
+ `archetype confidence ${conf} is below ${minConfidence} with projected coverage ${cov ?? 'unknown'}% (< ${strongCoverageFloor}%) — refuse apply without an explicit shape`
1604
+ );
1605
+ }
1606
+ if (reasons.length === 0) return { ok: true, confidence: conf, projectedCoverage: cov };
1607
+ return {
1608
+ ok: false,
1609
+ reasons,
1610
+ confidence: conf,
1611
+ projectedCoverage: cov,
1612
+ choices: [
1613
+ 'Pass --archetype <id> or --preset <name> to lock the shape deliberately',
1614
+ 'Pass --force to apply anyway after reviewing the preview',
1615
+ 'Run without --apply for a read-only preview',
1616
+ 'Run ark-check --recommend to inspect ranked shapes',
1617
+ ],
1618
+ };
1619
+ }
1620
+
1477
1621
  /**
1478
1622
  * Score playbook archetypes against collected repo shape signals.
1479
1623
  * Returns sorted matches (highest score first) with confidence in [0, 1].
@@ -1656,6 +1800,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
1656
1800
  expressLike: signals.expressLike,
1657
1801
  verticalSliceLayout: signals.verticalSliceLayout,
1658
1802
  dddBoundedContextsLayout: signals.dddBoundedContextsLayout,
1803
+ viteVercelSpaLayout: signals.viteVercelSpaLayout,
1659
1804
  monorepoTooling: signals.monorepoTooling,
1660
1805
  },
1661
1806
  // A repo past this size is not greenfield: `ark init` would scaffold a starter that governs
@@ -1711,9 +1856,25 @@ export function mapWizardChoiceToArchetype(choiceKey) {
1711
1856
 
1712
1857
  const NEW_HERE_GOVERNED_THRESHOLD = 50;
1713
1858
 
1714
- /** Show onboarding nudge when coverage is low or config is missing. */
1859
+ /**
1860
+ * Show onboarding nudge when config is missing or start has not finished.
1861
+ *
1862
+ * After `ark start --apply` (config + AGENTS.md present), low coverage is an
1863
+ * adopt/coverage problem — not “finish ark start”. Field: NEW-DOCTOR-STALE-FINISH-START.
1864
+ */
1715
1865
  export function shouldShowNewHereNudge(root, configPath, governedPercent, configMissing) {
1716
1866
  if (configMissing) return true;
1867
+ // Start already applied: never primary-action “finish start”.
1868
+ try {
1869
+ if (
1870
+ fs.existsSync(configPath) &&
1871
+ fs.existsSync(path.join(root, 'AGENTS.md'))
1872
+ ) {
1873
+ return false;
1874
+ }
1875
+ } catch {
1876
+ /* fall through */
1877
+ }
1717
1878
  if (typeof governedPercent === 'number' && governedPercent < NEW_HERE_GOVERNED_THRESHOLD) {
1718
1879
  return true;
1719
1880
  }
package/bin/ark.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  buildArchitectureRecommendation,
10
10
  detectPackageManager,
11
11
  detectWorkspaces,
12
+ evaluateStartShapeConfidenceGate,
12
13
  resolveIncludeRoots,
13
14
  detectTsPackageRoots,
14
15
  INIT_WIZARD_CHOICES,
@@ -422,11 +423,52 @@ async function start(args) {
422
423
  cliVersion,
423
424
  packageInstallArgv,
424
425
  });
426
+ // NEW-START-LOW-CONFIDENCE-SHAPE: refuse apply on all apply paths when shape is weak.
427
+ if (args.apply) {
428
+ const gate = evaluateStartShapeConfidenceGate({
429
+ confidence: preview.analysis?.confidence,
430
+ projectedCoveragePercent: preview.projectedCoverage?.percent,
431
+ totalFiles: preview.projectedCoverage?.totalFiles,
432
+ explicitShape: Boolean(args.archetype || args.preset),
433
+ force: Boolean(args.force),
434
+ });
435
+ if (!gate.ok) {
436
+ if (args.json) {
437
+ console.log(
438
+ JSON.stringify(
439
+ {
440
+ ok: false,
441
+ error: 'start-shape-confidence-gate',
442
+ ...gate,
443
+ preview,
444
+ },
445
+ null,
446
+ 2
447
+ )
448
+ );
449
+ } else {
450
+ console.error('Refusing ark start --apply: shape confidence / coverage gate failed.');
451
+ for (const reason of gate.reasons ?? []) console.error(` • ${reason}`);
452
+ console.error('Choices:');
453
+ for (const choice of gate.choices ?? []) console.error(` • ${choice}`);
454
+ renderStartPreview(preview);
455
+ }
456
+ return 2;
457
+ }
458
+ }
425
459
  if (args.json) console.log(JSON.stringify(preview, null, 2));
426
- else renderStartPreview(preview);
460
+ else if (!args.apply) renderStartPreview(preview);
461
+ else renderStartPreview(preview, { applying: true });
427
462
  if (!args.apply) return 0;
428
463
  applyStartPreview(args.root, preview);
429
- if (!args.json) console.log(`Applied ${preview.changes.length} previewed mutation(s).`);
464
+ // DL-START-APPLY-MESSAGE: single honest summary (do not claim preview-no-write after apply).
465
+ if (!args.json) {
466
+ if (preview.changes.length === 0) {
467
+ console.log('Start apply complete — nothing to change (already set up).');
468
+ } else {
469
+ console.log(`Applied ${preview.changes.length} start mutation(s).`);
470
+ }
471
+ }
430
472
  // After applying exact preview bytes, install the pinned package when requested
431
473
  // (preview itself never runs the package manager — field: start left pin without node_modules).
432
474
  if (
@@ -548,8 +590,12 @@ async function start(args) {
548
590
  fs.existsSync(path.join(root, 'lerna.json')) ||
549
591
  fs.existsSync(path.join(root, 'apps')) ||
550
592
  fs.existsSync(path.join(root, 'packages'));
551
- // Mature multi-package / nested-TS trees must NOT get a thin src/** starter (0 files).
552
- if (looksLikeMonorepo && (rec?.mature || includeRoots.length > 0 || tsPackages.length > 0)) {
593
+ // SPA (Vite + root api/lib) wins over monorepo heuristics (NEW-SPA-DEFAULT-LAYOUT).
594
+ if (rec?.preset === 'vite-vercel-spa' || preset === 'vite-vercel-spa') {
595
+ initArgs.push('--preset', 'vite-vercel-spa');
596
+ console.log(' Vite/Vercel SPA layout detected — include src,api,lib; api→Application; db clients→Persistence.');
597
+ } else if (looksLikeMonorepo && (rec?.mature || includeRoots.length > 0 || tsPackages.length > 0)) {
598
+ // Mature multi-package / nested-TS trees must NOT get a thin src/** starter (0 files).
553
599
  // UI-heavy TS packages (Remotion/Vite) prefer ui-surface patterns when recommend says so.
554
600
  const useUi =
555
601
  rec?.preset === 'feature-sliced' ||
@@ -562,7 +608,8 @@ async function start(args) {
562
608
  ? ` Multi-package / TS package layout detected — profile include: ${shown.join(', ')}.`
563
609
  : ' Multi-package layout detected — using monorepo profile.'
564
610
  );
565
- } else if (!rec?.mature && preset) {
611
+ } else if (preset) {
612
+ // Prefer recommended preset even on mature single-package trees (avoid vacuum hexagonal).
566
613
  initArgs.push('--preset', preset);
567
614
  }
568
615
  const status = runArkCheck(initArgs, { cwd: root });
@@ -53,7 +53,12 @@ function nextActionForDiagnostic(ruleId, evidence, violation) {
53
53
  }
54
54
  export function toAdapterDiagnostic(violation, fallbackSeverity = 'error') {
55
55
  const ruleId = text(violation.ruleId) ?? text(violation.code) ?? 'ARK_UNKNOWN';
56
- const severity = violation.severity === 'warning' ? 'warning' : fallbackSeverity;
56
+ // Type-only placement debt (failsStrict:false / typeOnly non-peer) is warning severity.
57
+ const severity = violation.severity === 'warning' ||
58
+ violation.failsStrict === false ||
59
+ (violation.typeOnly === true && violation.peerIsolation !== true)
60
+ ? 'warning'
61
+ : fallbackSeverity;
57
62
  const evidence = {
58
63
  ...(text(violation.target) ? { target: text(violation.target) } : {}),
59
64
  ...(text(violation.fromLayer) ? { fromLayer: text(violation.fromLayer) } : {}),
@@ -129,6 +134,7 @@ export function createAdapterResult(input) {
129
134
  }
130
135
  }
131
136
  const diagnostics = [
137
+ // toAdapterDiagnostic maps failsStrict:false / typeOnly non-peer → warning severity.
132
138
  ...(input.violations ?? []).map((item) => toAdapterDiagnostic(item, 'error')),
133
139
  ...(input.warnings ?? []).map((item) => toAdapterDiagnostic(item, 'warning')),
134
140
  ];
@@ -69,7 +69,9 @@ export {
69
69
  packageManager,
70
70
  arkCheckCommand,
71
71
  checkArchitectureScriptSnippet,
72
+ ensureCheckArchitectureScript,
72
73
  layerPlacementTable,
74
+ loadConfigLayersForAgents,
73
75
  agentInstructions,
74
76
  mcpJson,
75
77
  codexTomlSnippet,