arkgate 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -224,6 +224,10 @@ export function applyFrameworkLayoutOverlays(config, root) {
224
224
  'src/services/**',
225
225
  'src/use-cases/**',
226
226
  'src/lib/**',
227
+ // Common Next "app core" bags (API clients, hooks, auth, stores) — not UI routes.
228
+ // Without these, monorepos like */src/core/** stay ungoverned and produce false greens.
229
+ 'src/core/**',
230
+ '**/core/**',
227
231
  'src/actions/**',
228
232
  'src/**/actions.ts',
229
233
  'src/**/actions.tsx',
@@ -244,6 +248,20 @@ export function applyFrameworkLayoutOverlays(config, root) {
244
248
  'src/lib/prisma/**',
245
249
  'src/server/db/**',
246
250
  ]);
251
+ // Demo assets, generated public output, and tool configs are not architecture surface.
252
+ const nextExcludes = [
253
+ '**/public/**',
254
+ '**/*.config.js',
255
+ '**/*.config.ts',
256
+ '**/*.config.mjs',
257
+ '**/playwright*.ts',
258
+ '**/eslint.config.*',
259
+ '**/postcss.config.*',
260
+ '**/prettier.config.*',
261
+ '**/rstest.config.*',
262
+ '**/scripts/**',
263
+ ];
264
+ next.exclude = [...new Set([...(next.exclude ?? []), ...nextExcludes])];
247
265
  next.frameworkOverlay = next.frameworkOverlay
248
266
  ? `${next.frameworkOverlay}+next`
249
267
  : 'next';
@@ -362,137 +380,17 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
362
380
  return uses;
363
381
  }
364
382
 
365
- const _regexpCache = new Map();
366
-
367
- function escapeLiteral(ch) {
368
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
369
- }
370
-
371
- /** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
372
- function bracesBalanced(glob) {
373
- let depth = 0;
374
- for (let i = 0; i < glob.length; i += 1) {
375
- const c = glob[i];
376
- if (c === '\\') {
377
- i += 1; // skip the escaped character
378
- continue;
379
- }
380
- if (c === '{') depth += 1;
381
- else if (c === '}') {
382
- depth -= 1;
383
- if (depth < 0) return false;
384
- }
385
- }
386
- return depth === 0;
387
- }
388
-
389
- /**
390
- * Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
391
- * pattern, then cached).
392
- *
393
- * IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
394
- * (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
395
- * because the second step re-matches the star inside the substitution the first step just
396
- * inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
397
- * every file in a subdirectory. Scanning one character at a time also lets us support
398
- * brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
399
- *
400
- * Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
401
- * typo) is treated as a literal so the gate never crashes on `new RegExp`.
402
- */
403
- export function globToRegExp(pattern) {
404
- const cached = _regexpCache.get(pattern);
405
- if (cached) return cached;
406
-
407
- const glob = pattern.split(path.sep).join('/');
408
- const useBraces = bracesBalanced(glob);
409
- let out = '';
410
- let braceDepth = 0;
411
- for (let i = 0; i < glob.length; i += 1) {
412
- const c = glob[i];
413
- if (c === '\\' && i + 1 < glob.length) {
414
- out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
415
- i += 1;
416
- } else if (c === '*') {
417
- if (glob[i + 1] === '*') {
418
- if (glob[i + 2] === '/') {
419
- out += '(?:.*/)?'; // `**/` matches zero or more path segments
420
- i += 2;
421
- } else {
422
- out += '.*'; // `**` matches across `/`
423
- i += 1;
424
- }
425
- } else {
426
- out += '[^/]*'; // `*` matches within a single segment
427
- }
428
- } else if (c === '?') {
429
- out += '[^/]';
430
- } else if (c === '{' && useBraces) {
431
- out += '(?:';
432
- braceDepth += 1;
433
- } else if (c === '}' && useBraces && braceDepth > 0) {
434
- out += ')';
435
- braceDepth -= 1;
436
- } else if (c === ',' && useBraces && braceDepth > 0) {
437
- out += '|';
438
- } else {
439
- out += escapeLiteral(c);
440
- }
441
- }
442
- const re = new RegExp(`^${out}$`);
443
- _regexpCache.set(pattern, re);
444
- return re;
445
- }
446
-
447
- // Specificity score for a layer glob: more literal path segments before the first wildcard
448
- // wins, then longer literal text. So `src/kernel/app/**` (3 literal segments) beats
449
- // `src/kernel/**` (2), and an exact file like `src/kernel/events.ts` beats both. This is what
450
- // makes a facade split (a KernelApi surface layer overlapping a KernelInternal catch-all)
451
- // resolve to the surface REGARDLESS of layer declaration order — the intuitive result.
452
- export function patternSpecificity(pattern) {
453
- const glob = String(pattern).split(path.sep).join('/');
454
- const beforeWildcard = glob.split('*')[0];
455
- const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
456
- const literalLength = glob.replace(/\*/g, '').length;
457
- return literalSegments * 10000 + literalLength;
458
- }
459
-
460
- /**
461
- * Resolve a file's architecture layer from ark.config.json layer glob patterns. When more
462
- * than one layer matches (overlapping globs, e.g. a facade split), the MOST SPECIFIC pattern
463
- * wins; ties break by declaration order (first wins). Order-independent for non-ambiguous
464
- * overlaps, so a config author can't silently break a facade by listing the catch-all first.
465
- *
466
- * A layer may also declare `exclude` globs. A file matching ANY exclude glob is NOT a
467
- * candidate for that layer even if a `patterns` glob matches — this lets a broad pattern
468
- * (e.g. `src/**​/domain/**`) carve out subtrees it should not govern (framework internals
469
- * like `**​/kernel/**`) without enumerating every include. Excluding a file from its layer
470
- * also removes it from that layer's rule and `forbiddenGlobals` enforcement, since both key
471
- * off this classification — which is exactly how a broad domain glob stops mis-flagging
472
- * `src/kernel/domain` as impure domain code. This is the single file→layer matcher shared by
473
- * the ark-check CI gate and the ark-mcp write gate, so `exclude` behaves identically in both.
474
- */
475
- export function layerForFile(root, file, layers) {
476
- const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
477
- const rel = path.relative(root, abs).split(path.sep).join('/');
478
- let bestName;
479
- let bestScore = -1;
480
- for (const layer of layers ?? []) {
481
- if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
482
- continue;
483
- }
484
- for (const pattern of layer.patterns ?? []) {
485
- if (globToRegExp(pattern).test(rel)) {
486
- const score = patternSpecificity(pattern);
487
- if (score > bestScore) {
488
- bestScore = score;
489
- bestName = layer.name;
490
- }
491
- }
492
- }
493
- }
494
- return bestName;
495
- }
383
+ // Layer glob matching — single source of truth in ark-layer-match.mjs (also used by ESLint).
384
+ export {
385
+ globToRegExp,
386
+ patternSpecificity,
387
+ layerForFile,
388
+ layerForRelativePath,
389
+ isEdgeDenied,
390
+ DEFAULT_GENERATED_FILE_GLOBS,
391
+ scanExcludePatterns,
392
+ isScanExcludedRelative,
393
+ } from './ark-layer-match.mjs';
496
394
 
497
395
  function normalizePrefix(prefix) {
498
396
  return prefix.endsWith('.') ? prefix : `${prefix}.`;
@@ -842,7 +740,15 @@ function readPackageJson(root) {
842
740
  }
843
741
  }
844
742
 
845
- /** Workspace roots from package.json workspaces and pnpm-workspace.yaml (no YAML dependency). */
743
+ /**
744
+ * Workspace / multi-package roots for monorepo include.
745
+ * Sources (universal — no project-specific names):
746
+ * 1. package.json workspaces + pnpm-workspace.yaml
747
+ * 2. rush.json projectFolder top segments (JSONC-tolerant)
748
+ * 3. lerna.json packages globs
749
+ * 4. If still empty: conventional multi-package top-level dirs that exist and
750
+ * contain at least one package.json (packages, apps, plugins, services, …)
751
+ */
846
752
  export function detectWorkspaces(root) {
847
753
  const dirs = new Set();
848
754
  const addGlob = (glob) => {
@@ -850,9 +756,16 @@ export function detectWorkspaces(root) {
850
756
  const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
851
757
  if (beforeStar && beforeStar !== '.') dirs.add(normalizeRel(beforeStar));
852
758
  };
759
+ const addTop = (rel) => {
760
+ if (typeof rel !== 'string') return;
761
+ const top = rel.split(/[/\\]/).filter(Boolean)[0];
762
+ if (top && top !== '.') dirs.add(normalizeRel(top));
763
+ };
764
+
853
765
  const pkg = readPackageJson(root);
854
766
  const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
855
767
  if (Array.isArray(ws)) ws.forEach(addGlob);
768
+
856
769
  const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
857
770
  if (fs.existsSync(pnpmFile)) {
858
771
  let inPackages = false;
@@ -867,9 +780,183 @@ export function detectWorkspaces(root) {
867
780
  if (item) addGlob(item[1].trim());
868
781
  }
869
782
  }
783
+
784
+ // Rush monorepos often have no root package.json workspaces — only rush.json.
785
+ const rushFile = path.join(root, 'rush.json');
786
+ if (fs.existsSync(rushFile)) {
787
+ try {
788
+ let text = fs.readFileSync(rushFile, 'utf8');
789
+ text = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
790
+ const rush = JSON.parse(text);
791
+ for (const project of rush.projects || []) {
792
+ if (typeof project?.projectFolder === 'string') addTop(project.projectFolder);
793
+ }
794
+ } catch {
795
+ /* ignore malformed rush.json */
796
+ }
797
+ }
798
+
799
+ // Lerna packages globs.
800
+ const lernaFile = path.join(root, 'lerna.json');
801
+ if (fs.existsSync(lernaFile)) {
802
+ try {
803
+ const lerna = JSON.parse(fs.readFileSync(lernaFile, 'utf8'));
804
+ if (Array.isArray(lerna.packages)) lerna.packages.forEach(addGlob);
805
+ } catch {
806
+ /* ignore */
807
+ }
808
+ }
809
+
810
+ // Conventional multi-package roots when no explicit workspace manifest listed roots.
811
+ if (dirs.size === 0) {
812
+ const conventional = [
813
+ 'packages',
814
+ 'apps',
815
+ 'plugins',
816
+ 'services',
817
+ 'server',
818
+ 'servers',
819
+ 'server-plugins',
820
+ 'libs',
821
+ 'lib',
822
+ 'modules',
823
+ 'foundations',
824
+ 'pods',
825
+ 'models',
826
+ 'clients',
827
+ 'sdks',
828
+ 'tools',
829
+ 'tooling',
830
+ 'desktop',
831
+ 'common',
832
+ ];
833
+ for (const name of conventional) {
834
+ const abs = path.join(root, name);
835
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) continue;
836
+ if (dirContainsPackageJson(abs, 2)) dirs.add(name);
837
+ }
838
+ }
839
+
870
840
  return [...dirs];
871
841
  }
872
842
 
843
+ /** True if dir (or a child within maxDepth) has a package.json — multi-package root signal. */
844
+ function dirContainsPackageJson(dir, maxDepth) {
845
+ try {
846
+ if (fs.existsSync(path.join(dir, 'package.json'))) return true;
847
+ if (maxDepth <= 0) return false;
848
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
849
+ if (!entry.isDirectory()) continue;
850
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
851
+ if (dirContainsPackageJson(path.join(dir, entry.name), maxDepth - 1)) return true;
852
+ }
853
+ } catch {
854
+ return false;
855
+ }
856
+ return false;
857
+ }
858
+
859
+ const SKIP_DIR_NAMES = new Set([
860
+ 'node_modules',
861
+ 'dist',
862
+ 'build',
863
+ 'coverage',
864
+ '.git',
865
+ '.next',
866
+ '.turbo',
867
+ '.cache',
868
+ 'vendor',
869
+ '__pycache__',
870
+ ]);
871
+
872
+ function dirHasTsSources(dir, maxDepth) {
873
+ try {
874
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
875
+ if (entry.isFile() && /\.(tsx?|jsx?|mts|cts)$/i.test(entry.name)) return true;
876
+ if (!entry.isDirectory()) continue;
877
+ if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
878
+ if (maxDepth > 0 && dirHasTsSources(path.join(dir, entry.name), maxDepth - 1)) return true;
879
+ }
880
+ } catch {
881
+ return false;
882
+ }
883
+ return false;
884
+ }
885
+
886
+ /**
887
+ * Discover package roots that contain TypeScript/JS sources (polyglot-safe).
888
+ * Returns relative paths (e.g. remotion-composer, packages/ui). Caps depth/count
889
+ * to avoid scanning huge trees. Universal — no project-specific names.
890
+ */
891
+ export function detectTsPackageRoots(root, options = {}) {
892
+ const maxDepth = options.maxDepth ?? 3;
893
+ const maxRoots = options.maxRoots ?? 40;
894
+ const found = [];
895
+
896
+ const visit = (abs, rel, depth) => {
897
+ if (found.length >= maxRoots || depth > maxDepth) return;
898
+ let entries;
899
+ try {
900
+ entries = fs.readdirSync(abs, { withFileTypes: true });
901
+ } catch {
902
+ return;
903
+ }
904
+ const hasPkg = entries.some((e) => e.isFile() && e.name === 'package.json');
905
+ if (hasPkg && rel && dirHasTsSources(abs, 3)) {
906
+ found.push(rel.split(path.sep).join('/'));
907
+ // Do not descend into nested packages under an already-selected package root
908
+ // unless maxDepth allows and we want monorepo packages/* children — still scan children
909
+ // for nested packages (packages/foo).
910
+ }
911
+ if (depth >= maxDepth) return;
912
+ for (const entry of entries) {
913
+ if (!entry.isDirectory()) continue;
914
+ if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
915
+ // Skip agent skill asset trees (not app code).
916
+ if (entry.name === 'skills' || entry.name === 'templates' || entry.name === 'fixtures') continue;
917
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
918
+ visit(path.join(abs, entry.name), childRel, depth + 1);
919
+ }
920
+ };
921
+
922
+ visit(root, '', 0);
923
+ // Also treat root itself if it has package.json + TS
924
+ if (fs.existsSync(path.join(root, 'package.json')) && dirHasTsSources(root, 2)) {
925
+ if (!found.includes('.')) {
926
+ // Prefer explicit '.' only when no nested packages found
927
+ if (found.length === 0) found.push('.');
928
+ }
929
+ }
930
+ return [...new Set(found)].sort();
931
+ }
932
+
933
+ /**
934
+ * Merge workspace globs with TS package roots. If workspaces alone would miss
935
+ * all TS (or are empty), fill from detectTsPackageRoots.
936
+ */
937
+ export function resolveIncludeRoots(root) {
938
+ const workspaces = detectWorkspaces(root);
939
+ const tsRoots = detectTsPackageRoots(root);
940
+ if (workspaces.length === 0) {
941
+ if (tsRoots.length > 0) return tsRoots.filter((r) => r !== '.');
942
+ return [];
943
+ }
944
+ // Workspaces present: keep them, add TS package roots not covered by a workspace prefix
945
+ const merged = new Set(workspaces);
946
+ for (const tr of tsRoots) {
947
+ if (tr === '.') continue;
948
+ const covered = workspaces.some(
949
+ (w) => tr === w || tr.startsWith(`${w}/`) || w.startsWith(`${tr}/`)
950
+ );
951
+ if (!covered) merged.add(tr.split('/')[0]); // top segment if nested
952
+ // Prefer full package path when it's a direct child
953
+ if (!tr.includes('/') || workspaces.includes(tr.split('/')[0])) {
954
+ if (fs.existsSync(path.join(root, tr, 'package.json'))) merged.add(tr);
955
+ }
956
+ }
957
+ return [...merged];
958
+ }
959
+
873
960
  function isSourceFile(name) {
874
961
  return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
875
962
  }
@@ -945,6 +1032,47 @@ function countTsxFiles(files) {
945
1032
  return files.filter((f) => /\.(tsx|jsx)$/i.test(f)).length;
946
1033
  }
947
1034
 
1035
+ /**
1036
+ * Merge root + nested package.json dependencies (frontend/, packages/*, apps/*, …).
1037
+ * Hosts that only put `arkgate` at the monorepo root and Next under frontend/ must
1038
+ * still detect nextFramework so layout overlays (src/core/**) apply on day one.
1039
+ */
1040
+ export function collectAggregatedDeps(root) {
1041
+ const deps = {};
1042
+ const mergePkg = (pkg) => {
1043
+ if (!pkg || typeof pkg !== 'object') return;
1044
+ for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
1045
+ const block = pkg[key];
1046
+ if (block && typeof block === 'object') Object.assign(deps, block);
1047
+ }
1048
+ };
1049
+ mergePkg(readPackageJson(root));
1050
+ let entries = [];
1051
+ try {
1052
+ entries = fs.readdirSync(root, { withFileTypes: true });
1053
+ } catch {
1054
+ return deps;
1055
+ }
1056
+ for (const entry of entries) {
1057
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1058
+ const childRoot = path.join(root, entry.name);
1059
+ mergePkg(readPackageJson(childRoot));
1060
+ // One level under conventional multi-package roots
1061
+ if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
1062
+ try {
1063
+ for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
1064
+ if (sub.isDirectory() && !sub.name.startsWith('.')) {
1065
+ mergePkg(readPackageJson(path.join(childRoot, sub.name)));
1066
+ }
1067
+ }
1068
+ } catch {
1069
+ /* ignore */
1070
+ }
1071
+ }
1072
+ }
1073
+ return deps;
1074
+ }
1075
+
948
1076
  /**
949
1077
  * Collect deterministic repo shape signals for architecture archetype scoring.
950
1078
  * Vendor packages may appear in toolHints only — never as the primary label.
@@ -953,7 +1081,20 @@ export function collectRepoShapeSignals(root) {
953
1081
  const pkg = readPackageJson(root);
954
1082
  const workspaceDirs = detectWorkspaces(root);
955
1083
  const workspaces = workspaceDirs.length > 0;
956
- const srcDirs = ['src', 'lib', 'api', 'packages', 'apps'].filter((d) =>
1084
+ // Include frontend/web/client common Next monorepo app folders (deer-flow-style).
1085
+ const candidateScanDirs = [
1086
+ 'src',
1087
+ 'lib',
1088
+ 'api',
1089
+ 'packages',
1090
+ 'apps',
1091
+ 'frontend',
1092
+ 'web',
1093
+ 'client',
1094
+ 'app',
1095
+ ...workspaceDirs,
1096
+ ];
1097
+ const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
957
1098
  fs.existsSync(path.join(root, d))
958
1099
  );
959
1100
  const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
@@ -961,13 +1102,14 @@ export function collectRepoShapeSignals(root) {
961
1102
  const sourceFileCount = sourceFiles.length;
962
1103
  const tinyTree = sourceFileCount < 3;
963
1104
 
964
- const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
1105
+ // Nested package.json deps (not root-only) critical for monorepo Next under frontend/
1106
+ const deps = collectAggregatedDeps(root);
965
1107
  const hasUiFramework = Object.keys(deps).some((name) =>
966
1108
  /^(react|react-dom|vue|svelte|preact|solid-js)$/i.test(name.split('/')[0])
967
1109
  );
968
1110
  const srcUiFiles = sourceFiles.filter((file) => {
969
1111
  const rel = path.relative(root, file).split(path.sep).join('/');
970
- return rel.startsWith('src/') && /\.(tsx|jsx)$/i.test(file);
1112
+ return (rel.includes('/src/') || rel.startsWith('src/')) && /\.(tsx|jsx)$/i.test(file);
971
1113
  });
972
1114
 
973
1115
  const topNames = new Set(srcDirs.flatMap((d) => listTopLevelDirNames(root, d)));
@@ -983,7 +1125,8 @@ export function collectRepoShapeSignals(root) {
983
1125
  const rel = path.relative(root, file).split(path.sep).join('/');
984
1126
  return (
985
1127
  /(^|\/)next\.config\./.test(rel) ||
986
- /(^|\/)app\/.*\/page\.(t|j)sx?$/.test(rel) ||
1128
+ // app/page.tsx OR app/dashboard/page.tsx (middle segment optional)
1129
+ /(^|\/)app\/(?:.*\/)?page\.(t|j)sx?$/.test(rel) ||
987
1130
  /(^|\/)pages\/.+\.(t|j)sx?$/.test(rel)
988
1131
  );
989
1132
  });
@@ -1250,7 +1393,15 @@ export function scoreArchetypes(signals, playbook) {
1250
1393
  const rawConfidence = top.score / top.maxPositive;
1251
1394
  const margin =
1252
1395
  second && second.score > 0 ? (top.score - second.score) / Math.max(top.score, 1) : 0.25;
1253
- const confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
1396
+ let confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
1397
+ // Thin / zero TS surface: never present a high-confidence archetype as the sole answer.
1398
+ const thinTs =
1399
+ !signals.sourceFileCount ||
1400
+ signals.sourceFileCount < 8 ||
1401
+ signals.tinyTree;
1402
+ if (thinTs) {
1403
+ confidence = Math.min(confidence, 0.28);
1404
+ }
1254
1405
 
1255
1406
  return {
1256
1407
  ranked: scored,
@@ -1258,6 +1409,13 @@ export function scoreArchetypes(signals, playbook) {
1258
1409
  label: top.label,
1259
1410
  preset: top.preset,
1260
1411
  confidence: Math.round(confidence * 1000) / 1000,
1412
+ ...(thinTs
1413
+ ? {
1414
+ thinTsSurface: true,
1415
+ caution:
1416
+ 'TypeScript/JS surface is thin or missing — treat the archetype as a weak hint. Prefer ark-check --suggest-include / --adopt-contract on the real package roots before scaffolding.',
1417
+ }
1418
+ : {}),
1261
1419
  phases: top.phases,
1262
1420
  analogy: top.analogy,
1263
1421
  antiPatterns: top.antiPatterns,
@@ -1292,6 +1450,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
1292
1450
  label: result.label,
1293
1451
  preset: result.preset,
1294
1452
  confidence: result.confidence,
1453
+ ...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
1295
1454
  phases: result.phases,
1296
1455
  adoptInOrder,
1297
1456
  analogy: result.analogy,
@@ -1457,6 +1616,13 @@ export function formatArchitectureRecommendationHuman(recommendation) {
1457
1616
  lines.push('');
1458
1617
  lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1459
1618
  lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
1619
+ if (recommendation.thinTsSurface) {
1620
+ lines.push('');
1621
+ lines.push(
1622
+ `⚠ Thin TypeScript surface (${recommendation.signals?.sourceFileCount ?? 0} files) — confidence is capped. Do not treat this as a firm shape.`
1623
+ );
1624
+ if (recommendation.caution) lines.push(recommendation.caution);
1625
+ }
1460
1626
  if (recommendation.runnerUp?.id) {
1461
1627
  lines.push(
1462
1628
  `Runner-up: ${recommendation.runnerUp.id}${recommendation.runnerUp.label ? ` (${recommendation.runnerUp.label})` : ''}`
package/bin/ark.mjs CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  buildArchitectureRecommendation,
11
11
  detectPackageManager,
12
12
  detectWorkspaces,
13
+ resolveIncludeRoots,
14
+ detectTsPackageRoots,
13
15
  INIT_WIZARD_CHOICES,
14
16
  isValidArchetypeId,
15
17
  mapWizardChoiceToArchetype,
@@ -345,16 +347,32 @@ async function start(args) {
345
347
  if (!fs.existsSync(configPath)) {
346
348
  const initArgs = ['--root', root, '--init'];
347
349
  const preset = archetype ? resolveArchetypePreset(archetype).preset : undefined;
350
+ const includeRoots = resolveIncludeRoots(root);
351
+ const tsPackages = detectTsPackageRoots(root);
348
352
  const workspaces = detectWorkspaces(root);
349
353
  const looksLikeMonorepo =
354
+ includeRoots.length > 0 ||
355
+ tsPackages.length > 0 ||
350
356
  workspaces.length > 0 ||
357
+ fs.existsSync(path.join(root, 'rush.json')) ||
358
+ fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) ||
359
+ fs.existsSync(path.join(root, 'lerna.json')) ||
351
360
  fs.existsSync(path.join(root, 'apps')) ||
352
361
  fs.existsSync(path.join(root, 'packages'));
353
- // Mature multi-package trees must NOT get a thin src/** starter (0 files → false green).
354
- // Prefer the monorepo preset so include roots match apps/packages/tooling.
355
- if (looksLikeMonorepo && (rec?.mature || workspaces.length > 0)) {
356
- initArgs.push('--preset', 'monorepo');
357
- console.log(' Multi-package layout detected — using monorepo profile.');
362
+ // Mature multi-package / nested-TS trees must NOT get a thin src/** starter (0 files).
363
+ if (looksLikeMonorepo && (rec?.mature || includeRoots.length > 0 || tsPackages.length > 0)) {
364
+ // UI-heavy TS packages (Remotion/Vite) prefer ui-surface patterns when recommend says so.
365
+ const useUi =
366
+ rec?.preset === 'feature-sliced' ||
367
+ rec?.archetype === 'frontend-surface' ||
368
+ (tsPackages.length > 0 && includeRoots.length === 0 && !rec?.mature);
369
+ initArgs.push('--preset', useUi && tsPackages.length <= 3 ? 'ui-surface' : 'monorepo');
370
+ const shown = includeRoots.length > 0 ? includeRoots : tsPackages;
371
+ console.log(
372
+ shown.length > 0
373
+ ? ` Multi-package / TS package layout detected — profile include: ${shown.join(', ')}.`
374
+ : ' Multi-package layout detected — using monorepo profile.'
375
+ );
358
376
  } else if (!rec?.mature && preset) {
359
377
  initArgs.push('--preset', preset);
360
378
  }
@@ -363,7 +381,12 @@ async function start(args) {
363
381
  } else {
364
382
  console.log(' Found an existing ark.config.json — keeping it.');
365
383
  }
366
- runArkCheck(['--root', root, '--install-agent-gates'], { cwd: root });
384
+ {
385
+ const gateArgs = ['--root', root, '--install-agent-gates'];
386
+ if (args.tools) gateArgs.push('--tools', args.tools);
387
+ if (args.force) gateArgs.push('--force');
388
+ runArkCheck(gateArgs, { cwd: root });
389
+ }
367
390
 
368
391
  // 4) Show the plan: what's safe to auto-fix vs what needs a decision.
369
392
  console.log('');
@@ -406,49 +429,36 @@ async function start(args) {
406
429
  planOk = false;
407
430
  }
408
431
 
409
- // 5) Plain-language wrap-up — three operating modes, one contract.
410
- // suggest = greenfield shape proposal; adapt = match real layout / raise coverage;
411
- // enforce = contract actually governs code and gates stay on.
432
+ // 5) Plain-language wrap-up — one next step, status light only.
433
+ // Modes are detected (Suggest/Adapt/Enforce), not user-picked settings.
412
434
  console.log('');
413
435
  if (mode === 'enforce' && planOk) {
414
- console.log('Done — Ark is in ENFORCE mode: your contract governs the code and the gates stay on.');
436
+ console.log('Done — status: ENFORCE (gates can honestly protect you).');
415
437
  console.log('What happens now:');
416
438
  console.log(' • Every edit is checked (in CI and, if wired, at write time).');
417
- console.log(' • Carry out remaining plan steps with /ark-autopilot (safe auto + your approvals).');
418
439
  } else if (mode === 'suggest') {
419
- console.log('Done — Ark is in SUGGEST mode: a starting shape is installed; enforcement grows as you add layers.');
440
+ console.log('Done — status: SUGGEST (starting shape installed; expand as you grow).');
420
441
  console.log('What happens now:');
421
- console.log(' • Gates are on for whatever the contract already matches.');
422
- console.log(' • Expand coverage as you create real layer folders (see the plan above).');
423
442
  if (governedPercent != null) {
424
- console.log(` • Right now Ark governs ~${governedPercent}% of in-scope files — low is normal on a fresh scaffold.`);
443
+ console.log(` • Ark governs ~${governedPercent}% of in-scope files — low is normal on a fresh scaffold.`);
425
444
  }
426
- console.log(' • When you want the agent to drive the plan: /ark-autopilot');
427
445
  } else {
428
- console.log('Done — Ark is in ADAPT mode: config is in place, but the contract still needs to match your real layout.');
446
+ console.log('Done — status: ADAPT (contract still aligning with your real layout).');
429
447
  console.log('What happens now:');
430
448
  if (governedPercent != null) {
431
449
  console.log(
432
- ` • Governed coverage is ~${governedPercent}% — a "clean" plan with low coverage checks almost nothing.`
450
+ ` • Governed ~${governedPercent}% — a "clean" plan with low coverage checks almost nothing.`
433
451
  );
434
452
  }
435
- console.log(` • See what is unmatched: ${arkCommand(root, 'ark-check', '--coverage')}`);
436
- console.log(' • On a mature repo, prefer /ark-adopt over forcing a starter preset.');
437
- console.log(' • Drive fixes with /ark-autopilot once the contract matches; until then ENFORCE is not honest.');
438
- }
439
- console.log(` • Re-run the plan anytime: ${arkCommand(root, 'ark-check', '--plan')}`);
440
- console.log(` • Full project check: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
441
- console.log(` • Adoption health: ${arkCommand(root, 'ark-check', '--doctor')}`);
442
- console.log(` • Update Ark later: ${arkCommand(root, 'ark', 'upgrade')}`);
443
- if (fs.existsSync(path.join(root, '.ark-baseline.json'))) {
444
- console.log(
445
- ' • Baseline file present — keep empty for ratchet-from-clean, or freeze debt with --update-baseline.'
446
- );
447
- } else {
448
- console.log(
449
- ' • No baseline yet (fine on clean trees). Adopting dirty code? freeze with --update-baseline.'
450
- );
451
453
  }
454
+ console.log('');
455
+ console.log('Next (the only flow you need):');
456
+ console.log(' 1. In your agent: /ark-autopilot');
457
+ console.log(' → origin report, adoption, plan, safe fixes, leave gates on.');
458
+ console.log(` 2. Status anytime: ${arkCommand(root, 'ark-check', '--doctor')}`);
459
+ console.log(` 3. After edits: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
460
+ console.log('');
461
+ console.log('Optional later: --plan · --coverage · /ark-fix · /ark-place · ark upgrade');
452
462
 
453
463
  // 6) First architecture report — freezes an origin snapshot under .ark/reports/
454
464
  // so later --report runs can show evolution. Idempotent: origin is written only once.