arkgate 2.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 (64) hide show
  1. package/CHANGELOG.md +1249 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -0
  4. package/SECURITY.md +39 -0
  5. package/bin/ark-check.mjs +5204 -0
  6. package/bin/ark-mcp.mjs +898 -0
  7. package/bin/ark-shared.mjs +1520 -0
  8. package/bin/ark.mjs +491 -0
  9. package/dist/eslint/index.cjs +222 -0
  10. package/dist/eslint/index.cjs.map +1 -0
  11. package/dist/eslint/index.d.cts +42 -0
  12. package/dist/eslint/index.d.ts +40 -0
  13. package/dist/eslint/index.js +193 -0
  14. package/dist/eslint/index.js.map +1 -0
  15. package/dist/index.cjs +3080 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +577 -0
  18. package/dist/index.d.ts +577 -0
  19. package/dist/index.js +2998 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/nestjs/index.cjs +2332 -0
  22. package/dist/nestjs/index.cjs.map +1 -0
  23. package/dist/nestjs/index.d.cts +22 -0
  24. package/dist/nestjs/index.d.ts +22 -0
  25. package/dist/nestjs/index.js +2308 -0
  26. package/dist/nestjs/index.js.map +1 -0
  27. package/dist/types-DpdVN7Lm.d.cts +1023 -0
  28. package/dist/types-DpdVN7Lm.d.ts +1023 -0
  29. package/docs/agent-guide.md +490 -0
  30. package/docs/ai-gates.md +337 -0
  31. package/docs/ark-check-example.json +87 -0
  32. package/docs/assets/ark-write-gate.svg +28 -0
  33. package/docs/brownfield-adoption.md +87 -0
  34. package/docs/demos/01-write-gate-self-correction.md +74 -0
  35. package/docs/demos/02-brownfield-baseline-adoption.md +71 -0
  36. package/docs/demos/03-copilot-autopilot.md +83 -0
  37. package/docs/enthusiast/README.md +62 -0
  38. package/docs/enthusiast/explanation-application-shape.md +29 -0
  39. package/docs/enthusiast/how-to-agent-gates.md +36 -0
  40. package/docs/enthusiast/how-to-gallery-starter.md +27 -0
  41. package/docs/enthusiast/how-to-pick-shape.md +45 -0
  42. package/docs/enthusiast/how-to-policy-pack.md +37 -0
  43. package/docs/enthusiast/reference-archetypes.md +36 -0
  44. package/docs/enthusiast/reference-commands.md +50 -0
  45. package/docs/enthusiast/tutorial-first-project.md +86 -0
  46. package/docs/production-hardening.md +59 -0
  47. package/package.json +125 -0
  48. package/server.json +39 -0
  49. package/templates/architecture-playbook.json +339 -0
  50. package/templates/policy-packs/enthusiast-feature-sliced.json +20 -0
  51. package/templates/policy-packs/enthusiast-hexagonal.json +18 -0
  52. package/templates/policy-packs/enthusiast-layered.json +18 -0
  53. package/templates/policy-packs/enthusiast-monorepo.json +18 -0
  54. package/templates/skills/ark-adopt.md +103 -0
  55. package/templates/skills/ark-architect.md +90 -0
  56. package/templates/skills/ark-autopilot.md +95 -0
  57. package/templates/skills/ark-contract.md +98 -0
  58. package/templates/skills/ark-coverage.md +96 -0
  59. package/templates/skills/ark-explain.md +78 -0
  60. package/templates/skills/ark-fix.md +96 -0
  61. package/templates/skills/ark-loop.md +69 -0
  62. package/templates/skills/ark-place.md +68 -0
  63. package/templates/skills/ark-runtime.md +62 -0
  64. package/templates/skills/ark-upgrade.md +109 -0
@@ -0,0 +1,1520 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * Default layer rule matrix + intent-prefix map, shared by both CLIs and by the ark-mcp
7
+ * write-path gate so they enforce identically. These mirror the elevenLayerProfile in
8
+ * src/kernel/layers/ArchitectureProfile.ts; kept here (not imported from dist) because the
9
+ * CLIs run standalone with only `typescript` present, no build step.
10
+ */
11
+ export const DEFAULT_INTENT_PREFIXES = [
12
+ { layer: 'DomainModel', prefixes: ['Domain.'] },
13
+ { layer: 'ApplicationOrchestration', prefixes: ['Application.'] },
14
+ { layer: 'PersistenceAdapters', prefixes: ['Adapter.Persistence.', 'Adapter.Repository.'] },
15
+ { layer: 'IntegrationAdapters', prefixes: ['Adapter.Integration.', 'Adapter.External.'] },
16
+ { layer: 'WorkflowSagaEngine', prefixes: ['Workflow.'] },
17
+ { layer: 'BackgroundJobsScheduling', prefixes: ['Job.'] },
18
+ { layer: 'PresentationAdapters', prefixes: ['Presentation.', 'Adapter.Presentation.', 'Adapter.Api.'] },
19
+ { layer: 'ReportingReadModels', prefixes: ['Reporting.'] },
20
+ { layer: 'ExtensibilityMetadata', prefixes: ['Metadata.'] },
21
+ { layer: 'SecurityAuditObservability', prefixes: ['Security.', 'Audit.', 'Observability.'] },
22
+ { layer: 'Kernel', prefixes: ['Kernel.'] },
23
+ ];
24
+
25
+ export const DEFAULT_LAYER_DIRECTORIES = {
26
+ DomainModel: ['domain'],
27
+ ApplicationOrchestration: ['application', 'app'],
28
+ PersistenceAdapters: [
29
+ 'adapters/persistence',
30
+ 'adapters/repository',
31
+ 'repositories',
32
+ 'infra/persistence',
33
+ ],
34
+ IntegrationAdapters: ['adapters/integration', 'adapters/external', 'integrations'],
35
+ WorkflowSagaEngine: ['workflows', 'sagas'],
36
+ BackgroundJobsScheduling: ['jobs', 'schedules'],
37
+ PresentationAdapters: ['presentation', 'adapters/presentation', 'adapters/api'],
38
+ ReportingReadModels: ['reporting', 'read-models', 'projections'],
39
+ ExtensibilityMetadata: ['metadata', 'extensions'],
40
+ SecurityAuditObservability: ['security', 'audit', 'observability'],
41
+ Kernel: ['kernel'],
42
+ };
43
+
44
+ const DEFAULT_ALLOWED_FLOWS = [
45
+ { from: 'PresentationAdapters', to: 'ApplicationOrchestration' },
46
+ { from: 'ApplicationOrchestration', to: 'DomainModel' },
47
+ { from: 'WorkflowSagaEngine', to: 'ApplicationOrchestration' },
48
+ { from: 'WorkflowSagaEngine', to: 'DomainModel' },
49
+ { from: 'BackgroundJobsScheduling', to: 'ApplicationOrchestration' },
50
+ ];
51
+
52
+ function flowKey(from, to) {
53
+ return `${from}->${to}`;
54
+ }
55
+
56
+ function createStrictDenyRules(layers, allowedFlows) {
57
+ const allowed = new Set(allowedFlows.map((flow) => flowKey(flow.from, flow.to)));
58
+ const rules = [];
59
+ for (const from of layers) {
60
+ for (const to of layers) {
61
+ if (from.layer === to.layer) continue;
62
+ if (allowed.has(flowKey(from.layer, to.layer))) continue;
63
+ rules.push({ from: from.layer, to: to.layer, allowed: false });
64
+ }
65
+ }
66
+ return rules;
67
+ }
68
+
69
+ export const DEFAULT_RULES = createStrictDenyRules(
70
+ DEFAULT_INTENT_PREFIXES,
71
+ DEFAULT_ALLOWED_FLOWS
72
+ );
73
+
74
+ /**
75
+ * Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
76
+ * deterministic. `console` is deliberately omitted (too common during adoption); add it per
77
+ * project via the layer's `forbiddenGlobals` in ark.config.json.
78
+ */
79
+ export const DEFAULT_DOMAIN_FORBIDDEN_GLOBALS = ['fetch', 'process', 'Date.now', 'Math.random'];
80
+
81
+ export function createElevenLayerConfig(options = {}) {
82
+ const rootDir = options.rootDir ?? 'src';
83
+ const optional = options.optionalLayers ?? true;
84
+ const prefix = rootDir === '.' ? '' : `${rootDir}/`;
85
+ const config = {
86
+ include: options.include ?? [rootDir],
87
+ layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
88
+ name: entry.layer,
89
+ patterns: (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [entry.layer]).map(
90
+ (directory) => `${prefix}${directory}/**`
91
+ ),
92
+ intentPrefixes: entry.prefixes,
93
+ optional,
94
+ ...(entry.layer === 'DomainModel'
95
+ ? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
96
+ : {}),
97
+ })),
98
+ rules: DEFAULT_RULES,
99
+ };
100
+ // When a project root is known, overlay Nest/Next/express filename conventions so a
101
+ // flat framework starter is governed on day one (not "0% governed / false green").
102
+ if (options.root) return applyFrameworkLayoutOverlays(config, options.root);
103
+ return config;
104
+ }
105
+
106
+ /**
107
+ * Merge unique glob patterns onto a named layer (create the layer if missing).
108
+ * More-specific file globs (*.controller.ts) win over broad dirs via layerForFile scoring.
109
+ */
110
+ function mergeLayerPatterns(config, layerName, patterns, extras = {}) {
111
+ if (!patterns?.length) return;
112
+ const layers = config.layers ?? (config.layers = []);
113
+ let layer = layers.find((entry) => entry.name === layerName);
114
+ if (!layer) {
115
+ layer = { name: layerName, patterns: [], optional: true, ...extras };
116
+ layers.push(layer);
117
+ }
118
+ const set = new Set(layer.patterns ?? []);
119
+ for (const pattern of patterns) set.add(pattern);
120
+ layer.patterns = [...set];
121
+ for (const [key, value] of Object.entries(extras)) {
122
+ if (layer[key] === undefined) layer[key] = value;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Framework-aware layout overlays. Detection uses collectRepoShapeSignals (deps + filenames).
128
+ * Pure additive: never removes existing preset patterns. Goal: Nest/Next/express starters
129
+ * reach meaningful governed% under hexagonal/layered without a hand-written adopt pass.
130
+ */
131
+ export function applyFrameworkLayoutOverlays(config, root) {
132
+ if (!config || !root) return config;
133
+ let signals;
134
+ try {
135
+ signals = collectRepoShapeSignals(root);
136
+ } catch {
137
+ return config;
138
+ }
139
+
140
+ const next = {
141
+ ...config,
142
+ layers: (config.layers ?? []).map((layer) => ({
143
+ ...layer,
144
+ patterns: [...(layer.patterns ?? [])],
145
+ ...(layer.exclude ? { exclude: [...layer.exclude] } : {}),
146
+ })),
147
+ rules: [...(config.rules ?? [])],
148
+ include: [...(config.include ?? ['src'])],
149
+ };
150
+
151
+ // Ensure include covers where framework code actually lives.
152
+ const ensureInclude = (dir) => {
153
+ if (!next.include.includes(dir) && fs.existsSync(path.join(root, dir))) {
154
+ next.include.push(dir);
155
+ }
156
+ };
157
+
158
+ if (signals.nestFramework) {
159
+ ensureInclude('src');
160
+ // Nest flat + modular conventions (controllers/services next to modules).
161
+ mergeLayerPatterns(next, 'PresentationAdapters', [
162
+ 'src/**/*.controller.ts',
163
+ 'src/**/*.controller.js',
164
+ 'src/**/*.gateway.ts',
165
+ 'src/**/*.resolver.ts',
166
+ 'src/**/*.module.ts',
167
+ 'src/**/main.ts',
168
+ 'src/**/main.js',
169
+ ]);
170
+ mergeLayerPatterns(next, 'ApplicationOrchestration', [
171
+ 'src/**/*.service.ts',
172
+ 'src/**/*.service.js',
173
+ 'src/**/*.provider.ts',
174
+ 'src/**/*.interceptor.ts',
175
+ 'src/**/*.guard.ts',
176
+ 'src/**/*.pipe.ts',
177
+ 'src/**/*.use-case.ts',
178
+ 'src/**/*.usecase.ts',
179
+ ]);
180
+ mergeLayerPatterns(next, 'DomainModel', [
181
+ 'src/**/*.entity.ts',
182
+ 'src/**/*.vo.ts',
183
+ 'src/**/*.value-object.ts',
184
+ 'src/**/*.aggregate.ts',
185
+ 'src/**/entities/**',
186
+ 'src/**/domain/**',
187
+ ]);
188
+ mergeLayerPatterns(next, 'PersistenceAdapters', [
189
+ 'src/**/*.repository.ts',
190
+ 'src/**/*.repository.js',
191
+ 'src/**/repositories/**',
192
+ 'src/**/persistence/**',
193
+ 'src/**/infra/**',
194
+ 'src/**/infrastructure/**',
195
+ ]);
196
+ next.frameworkOverlay = 'nestjs';
197
+ }
198
+
199
+ if (signals.nextFramework || (signals.ui && signals.toolHints?.includes('next'))) {
200
+ ensureInclude('src');
201
+ ensureInclude('app');
202
+ ensureInclude('pages');
203
+ mergeLayerPatterns(next, 'PresentationAdapters', [
204
+ 'src/app/**',
205
+ 'src/pages/**',
206
+ 'src/components/**',
207
+ 'src/layouts/**',
208
+ 'src/ui/**',
209
+ 'app/**',
210
+ 'pages/**',
211
+ 'components/**',
212
+ 'src/**/page.tsx',
213
+ 'src/**/page.ts',
214
+ 'src/**/layout.tsx',
215
+ 'src/**/layout.ts',
216
+ 'src/**/loading.tsx',
217
+ 'src/**/error.tsx',
218
+ 'src/**/route.ts',
219
+ 'src/**/route.tsx',
220
+ ]);
221
+ mergeLayerPatterns(next, 'ApplicationOrchestration', [
222
+ 'src/features/**',
223
+ 'src/server/**',
224
+ 'src/services/**',
225
+ 'src/use-cases/**',
226
+ 'src/lib/**',
227
+ 'src/actions/**',
228
+ 'src/**/actions.ts',
229
+ 'src/**/actions.tsx',
230
+ ]);
231
+ mergeLayerPatterns(next, 'DomainModel', [
232
+ 'src/domain/**',
233
+ 'src/entities/**',
234
+ 'src/**/model/**',
235
+ 'src/**/models/**',
236
+ ]);
237
+ mergeLayerPatterns(next, 'PersistenceAdapters', [
238
+ 'src/db/**',
239
+ 'src/data/**',
240
+ 'src/repositories/**',
241
+ 'src/persistence/**',
242
+ 'src/infrastructure/**',
243
+ 'src/lib/db/**',
244
+ 'src/lib/prisma/**',
245
+ 'src/server/db/**',
246
+ ]);
247
+ next.frameworkOverlay = next.frameworkOverlay
248
+ ? `${next.frameworkOverlay}+next`
249
+ : 'next';
250
+ }
251
+
252
+ if (signals.expressLike && !signals.nestFramework) {
253
+ ensureInclude('src');
254
+ mergeLayerPatterns(next, 'PresentationAdapters', [
255
+ 'src/**/routes/**',
256
+ 'src/**/controllers/**',
257
+ 'src/**/http/**',
258
+ 'src/**/api/**',
259
+ 'src/**/middlewares/**',
260
+ 'src/**/middleware/**',
261
+ 'src/**/app.ts',
262
+ 'src/**/app.js',
263
+ 'src/**/server.ts',
264
+ 'src/**/server.js',
265
+ 'src/index.ts',
266
+ 'src/index.js',
267
+ ]);
268
+ mergeLayerPatterns(next, 'ApplicationOrchestration', [
269
+ 'src/**/services/**',
270
+ 'src/**/use-cases/**',
271
+ 'src/**/usecases/**',
272
+ 'src/**/controllers/**', // thin express controllers often mix app logic
273
+ ]);
274
+ mergeLayerPatterns(next, 'DomainModel', [
275
+ 'src/**/domain/**',
276
+ 'src/**/entities/**',
277
+ 'src/**/models/**',
278
+ ]);
279
+ mergeLayerPatterns(next, 'PersistenceAdapters', [
280
+ 'src/**/repositories/**',
281
+ 'src/**/persistence/**',
282
+ 'src/**/infrastructure/**',
283
+ 'src/**/db/**',
284
+ 'src/**/data/**',
285
+ ]);
286
+ next.frameworkOverlay = next.frameworkOverlay
287
+ ? `${next.frameworkOverlay}+express`
288
+ : 'express';
289
+ }
290
+
291
+ // Pure library: keep domain/application as the public surface under src/.
292
+ if (signals.libraryOnly && !signals.nestFramework && !signals.nextFramework) {
293
+ ensureInclude('src');
294
+ ensureInclude('lib');
295
+ mergeLayerPatterns(next, 'DomainModel', ['src/**/*.ts', 'src/**/*.tsx', 'lib/**/*.ts']);
296
+ // Prefer domain over application for a single-folder lib: only domain if no split.
297
+ next.frameworkOverlay = next.frameworkOverlay
298
+ ? `${next.frameworkOverlay}+library`
299
+ : 'library';
300
+ }
301
+
302
+ return next;
303
+ }
304
+
305
+ /**
306
+ * Operating mode for the co-pilot surfaces (not "who the user is"):
307
+ * suggest | adapt | enforce
308
+ */
309
+ export function resolveOperatingMode({
310
+ governedPercent = null,
311
+ planMet = null,
312
+ mature = false,
313
+ totalFiles = null,
314
+ } = {}) {
315
+ // Zero files in scope is never ENFORCE — the contract is not looking at any code.
316
+ if (totalFiles === 0) return 'adapt';
317
+ if (planMet === true && (governedPercent == null || governedPercent >= 50)) return 'enforce';
318
+ if (governedPercent != null && governedPercent < 50) return 'adapt';
319
+ if (mature) return 'adapt';
320
+ if (governedPercent != null && governedPercent >= 50 && planMet === false) return 'adapt';
321
+ return 'suggest';
322
+ }
323
+
324
+ /**
325
+ * Find uses of forbidden ambient globals in a TypeScript source file.
326
+ *
327
+ * Detection is deliberately positional, not scope-aware (kept in sync with
328
+ * `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts — the CLIs must not
329
+ * import from dist):
330
+ * - a dotted entry ("Date.now") flags `Date.now` property accesses
331
+ * - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
332
+ * direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
333
+ * Bare identifier mentions in other positions (types, shadowed locals, import names) are
334
+ * NOT flagged, trading a little recall for near-zero false positives without a type checker.
335
+ *
336
+ * Returns [{ name, node }] where `name` is the matched forbidden entry.
337
+ */
338
+ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
339
+ const entries = new Set(forbidden ?? []);
340
+ if (entries.size === 0) return [];
341
+ const uses = [];
342
+
343
+ const visit = (node) => {
344
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
345
+ const dotted = `${node.expression.text}.${node.name.text}`;
346
+ if (entries.has(dotted)) {
347
+ uses.push({ name: dotted, node });
348
+ } else if (entries.has(node.expression.text)) {
349
+ uses.push({ name: node.expression.text, node });
350
+ }
351
+ } else if (
352
+ (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
353
+ node.expression &&
354
+ ts.isIdentifier(node.expression) &&
355
+ entries.has(node.expression.text)
356
+ ) {
357
+ uses.push({ name: node.expression.text, node });
358
+ }
359
+ ts.forEachChild(node, visit);
360
+ };
361
+ visit(sourceFile);
362
+ return uses;
363
+ }
364
+
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
+ }
496
+
497
+ function normalizePrefix(prefix) {
498
+ return prefix.endsWith('.') ? prefix : `${prefix}.`;
499
+ }
500
+
501
+ /**
502
+ * Resolve an intent name to its layer using the SAME semantics as
503
+ * ArchitectureProfile.resolveLayer in src/kernel/layers/ArchitectureProfile.ts (which the
504
+ * ark-mcp write-gate uses via createArchitectureProfile): every prefix is normalized to a
505
+ * trailing '.', and the layer whose matching prefix is longest wins — regardless of config
506
+ * declaration order. Keeping ark-check on these exact rules is what makes the CI gate and
507
+ * the write-path gate classify identically. `layers` is an array of { name, prefixes }.
508
+ */
509
+ export function resolveIntentLayer(intent, layers) {
510
+ const normalized = layers.map((layer) => ({
511
+ name: layer.name,
512
+ prefixes: (layer.prefixes ?? []).map(normalizePrefix),
513
+ }));
514
+ const sorted = [...normalized].sort((a, b) => {
515
+ const maxA = Math.max(0, ...a.prefixes.map((p) => p.length));
516
+ const maxB = Math.max(0, ...b.prefixes.map((p) => p.length));
517
+ return maxB - maxA;
518
+ });
519
+ return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
520
+ }
521
+
522
+ /**
523
+ * Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
524
+ * src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
525
+ * CLIs run standalone (with only `typescript` present, no build), so they must not
526
+ * import from the compiled library. Update both if the layer prefixes change.
527
+ */
528
+ const INTENT_NAME =
529
+ /^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
530
+
531
+ export function looksLikeIntent(value) {
532
+ return INTENT_NAME.test(value);
533
+ }
534
+
535
+ /**
536
+ * Co-pilot Phase F — the work classifier. Every architecture violation is remediated in one of
537
+ * three ways, and this is the TRUST BOUNDARY that decides what an agent may auto-apply:
538
+ *
539
+ * - 'mechanical-safe' : behavior-preserving AND gate-verifiable → an agent may auto-apply it.
540
+ * - 'judgment' : real coupling or a design choice → Ark PROPOSES it, a human decides.
541
+ * - 'deferred' : not enough signal to place it → a human should look first.
542
+ *
543
+ * Deliberately biased toward 'judgment': a false 'mechanical-safe' that auto-lands a bad edit
544
+ * is the failure mode that sinks trust, so only the provably-safe type-only move earns 'auto'.
545
+ * Pure function of one violation object ({ ruleId, typeOnly, ... }) so the CLI, the MCP gate,
546
+ * and (later) the apply-loop all classify identically. Returns { class, confidence, rationale }.
547
+ */
548
+ export const REMEDIATION_CLASSES = ['mechanical-safe', 'judgment', 'deferred'];
549
+
550
+ export function classifyRemediation(violation) {
551
+ const ruleId = violation?.ruleId;
552
+ if (ruleId === 'LAYER_IMPORT_VIOLATION') {
553
+ if (violation.typeOnly) {
554
+ return {
555
+ class: 'mechanical-safe',
556
+ confidence: 0.9,
557
+ rationale:
558
+ 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
559
+ };
560
+ }
561
+ return {
562
+ class: 'judgment',
563
+ confidence: 0.7,
564
+ rationale:
565
+ 'Value import — real runtime coupling. Relocating it (e.g. a route reaching the DB → a repository) is a refactor whose organization is a human choice.',
566
+ };
567
+ }
568
+ if (ruleId === 'FORBIDDEN_GLOBAL') {
569
+ return {
570
+ class: 'judgment',
571
+ confidence: 0.8,
572
+ rationale:
573
+ 'Ambient global in a pure layer: inject the capability through a port (Clock, Config, Http). Introducing the port is a design decision.',
574
+ };
575
+ }
576
+ if (ruleId === 'CIRCULAR_DEPENDENCY') {
577
+ return {
578
+ class: 'judgment',
579
+ confidence: 0.7,
580
+ rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
581
+ };
582
+ }
583
+ if (typeof ruleId === 'string' && ruleId.length > 0) {
584
+ return {
585
+ class: 'judgment',
586
+ confidence: 0.6,
587
+ rationale: 'Needs a human decision on how to satisfy the contract without weakening the gate.',
588
+ };
589
+ }
590
+ return {
591
+ class: 'deferred',
592
+ confidence: 0.3,
593
+ rationale: 'Unrecognized violation shape — a human should look before anything is changed.',
594
+ };
595
+ }
596
+
597
+ /** The three package managers Ark emits commands for. */
598
+ const LOCKFILES = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
599
+
600
+ /**
601
+ * The Corepack `packageManager` field (and the newer `devEngines.packageManager`) is the
602
+ * project's OWN authoritative statement of its package manager. When present it wins over any
603
+ * lockfile guess. Returns 'pnpm' | 'yarn' | 'npm' | undefined.
604
+ */
605
+ function declaredPackageManager(root) {
606
+ let pkg;
607
+ try {
608
+ pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
609
+ } catch {
610
+ return undefined;
611
+ }
612
+ const raw =
613
+ (typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined) ??
614
+ (typeof pkg.devEngines?.packageManager?.name === 'string'
615
+ ? pkg.devEngines.packageManager.name
616
+ : undefined);
617
+ const name = raw?.trim().toLowerCase();
618
+ return name === 'pnpm' || name === 'yarn' || name === 'npm' ? name : undefined;
619
+ }
620
+
621
+ /** Lockfiles present in the project root, in { pnpm, yarn, npm } key order. */
622
+ export function presentLockfiles(root) {
623
+ return Object.entries(LOCKFILES)
624
+ .filter(([, file]) => fs.existsSync(path.join(root, file)))
625
+ .map(([pm]) => pm);
626
+ }
627
+
628
+ /**
629
+ * Detect the project's package manager: 'pnpm' | 'yarn' | 'npm'.
630
+ *
631
+ * Priority: (1) the `packageManager` / `devEngines` field (the project's own declaration);
632
+ * (2) a single lockfile; (3) on CONFLICT (more than one lockfile and no declaration) prefer
633
+ * npm whenever a package-lock.json is present. Rationale: `npx` runs fine inside a pnpm/yarn
634
+ * repo, but `pnpm exec` / `yarn` in an npm repo BREAKS (frozen-lockfile / no-TTY / a spurious
635
+ * pnpm-lock). So a stray pnpm-lock.yaml left in an npm project must NOT hijack it into pnpm —
636
+ * package-lock.json wins the tie, and the field is the escape hatch for a genuine pnpm repo
637
+ * that still carries a package-lock.json. Falls back to npm when nothing is detectable.
638
+ */
639
+ export function detectPackageManager(root) {
640
+ const declared = declaredPackageManager(root);
641
+ if (declared) return declared;
642
+ const locks = presentLockfiles(root);
643
+ if (locks.length <= 1) return locks[0] ?? 'npm';
644
+ if (locks.includes('npm')) return 'npm';
645
+ return locks[0]; // pnpm over yarn when only those two collide
646
+ }
647
+
648
+ // pnpm 10+ `pnpm exec` runs a deps-status pre-check that fails with ERR_PNPM_IGNORED_BUILDS
649
+ // when the repo has un-approved native build scripts (sharp, esbuild, tailwind oxide, …) —
650
+ // the common state of real pnpm apps. Skip that gate so Ark's emitted commands still run.
651
+ const PNPM_EXEC = 'pnpm --config.verify-deps-before-run=false exec';
652
+ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
653
+
654
+ /**
655
+ * The command prefix that runs an INSTALLED package binary, matched to the project's
656
+ * package manager. `npx` is used for npm and as the safe fallback.
657
+ *
658
+ * This is the single source of truth that makes every command Ark EMITS — the AGENTS.md
659
+ * contract, .mcp.json, the Claude/Codex hooks, the check:architecture script, the
660
+ * SessionStart summary and every console hint — respect a pnpm-only or yarn repo instead
661
+ * of hardcoding `npx`. (A "pnpm only, never npx" repo treats an emitted `npx` as a policy
662
+ * violation.) `packageManager()` in ark-check.mjs builds the CI-workflow variant on the
663
+ * same detection.
664
+ */
665
+ export function execRunner(root) {
666
+ return RUNNER_BY_PM[detectPackageManager(root)];
667
+ }
668
+
669
+ /** Full runnable command string for an installed Ark binary, package-manager aware. */
670
+ export function arkCommand(root, bin, argsStr = '') {
671
+ return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
672
+ }
673
+
674
+ /**
675
+ * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
676
+ * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]
677
+ * so the runner is a real argv[0], not a space-joined string a shell would mis-split.
678
+ */
679
+ export function execCommandParts(root, bin, binArgs = []) {
680
+ const runner = execRunner(root);
681
+ if (runner === PNPM_EXEC || runner.startsWith('pnpm ')) {
682
+ return {
683
+ command: 'pnpm',
684
+ args: ['--config.verify-deps-before-run=false', 'exec', bin, ...binArgs],
685
+ };
686
+ }
687
+ if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
688
+ return { command: 'npx', args: [bin, ...binArgs] };
689
+ }
690
+
691
+ /** Package-manager aware "install a dev dependency" hint (e.g. for a missing typescript). */
692
+ export function installDevHint(root, pkg) {
693
+ const pm = detectPackageManager(root);
694
+ if (pm === 'pnpm') return `pnpm add -D ${pkg}`;
695
+ if (pm === 'yarn') return `yarn add -D ${pkg}`;
696
+ return `npm install -D ${pkg}`;
697
+ }
698
+
699
+ export const ARCHETYPE_IDS = [
700
+ 'crud-product',
701
+ 'api-backend',
702
+ 'frontend-surface',
703
+ 'library-sdk',
704
+ 'cli-utility',
705
+ 'worker-pipeline',
706
+ 'event-coordinator',
707
+ 'integration-bridge',
708
+ 'multi-app-workspace',
709
+ 'prototype-spike',
710
+ ];
711
+
712
+ const UI_DIR_NAMES = new Set([
713
+ 'components',
714
+ 'pages',
715
+ 'app',
716
+ 'ui',
717
+ 'presentation',
718
+ 'views',
719
+ 'widgets',
720
+ ]);
721
+ const API_DIR_NAMES = new Set(['routes', 'controllers', 'http', 'api', 'handlers', 'server']);
722
+ const PERSISTENCE_DIR_NAMES = new Set([
723
+ 'persistence',
724
+ 'repositories',
725
+ 'repository',
726
+ 'data',
727
+ 'infrastructure',
728
+ 'adapters',
729
+ 'db',
730
+ ]);
731
+ const JOB_DIR_NAMES = new Set(['jobs', 'workers', 'worker', 'cron', 'schedules', 'queues']);
732
+ const WORKFLOW_DIR_NAMES = new Set(['workflows', 'sagas', 'saga']);
733
+ const INTEGRATION_DIR_NAMES = new Set([
734
+ 'integrations',
735
+ 'integration',
736
+ 'webhooks',
737
+ 'sync',
738
+ 'external',
739
+ ]);
740
+ const FSD_DIR_NAMES = new Set(['app', 'pages', 'features', 'entities', 'shared', 'widgets']);
741
+
742
+ function normalizeRel(value) {
743
+ return value.split(path.sep).join('/');
744
+ }
745
+
746
+ function readPackageJson(root) {
747
+ const file = path.join(root, 'package.json');
748
+ if (!fs.existsSync(file)) return null;
749
+ try {
750
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
751
+ } catch {
752
+ return null;
753
+ }
754
+ }
755
+
756
+ /** Workspace roots from package.json workspaces and pnpm-workspace.yaml (no YAML dependency). */
757
+ export function detectWorkspaces(root) {
758
+ const dirs = new Set();
759
+ const addGlob = (glob) => {
760
+ if (typeof glob !== 'string') return;
761
+ const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
762
+ if (beforeStar && beforeStar !== '.') dirs.add(normalizeRel(beforeStar));
763
+ };
764
+ const pkg = readPackageJson(root);
765
+ const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
766
+ if (Array.isArray(ws)) ws.forEach(addGlob);
767
+ const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
768
+ if (fs.existsSync(pnpmFile)) {
769
+ let inPackages = false;
770
+ for (const line of fs.readFileSync(pnpmFile, 'utf8').split('\n')) {
771
+ const keyMatch = line.match(/^([A-Za-z0-9_-]+):/);
772
+ if (keyMatch) {
773
+ inPackages = keyMatch[1] === 'packages';
774
+ continue;
775
+ }
776
+ if (!inPackages) continue;
777
+ const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/);
778
+ if (item) addGlob(item[1].trim());
779
+ }
780
+ }
781
+ return [...dirs];
782
+ }
783
+
784
+ function isSourceFile(name) {
785
+ return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
786
+ }
787
+
788
+ function walkSourceFiles(dir, files = [], depth = 0) {
789
+ if (depth > 12) return files;
790
+ const stat = fs.statSync(dir, { throwIfNoEntry: false });
791
+ if (!stat) return files;
792
+ if (stat.isFile()) {
793
+ if (isSourceFile(path.basename(dir))) files.push(dir);
794
+ return files;
795
+ }
796
+ if (!stat.isDirectory()) return files;
797
+ let entries;
798
+ try {
799
+ entries = fs.readdirSync(dir, { withFileTypes: true });
800
+ } catch {
801
+ return files;
802
+ }
803
+ for (const entry of entries) {
804
+ // Skip node_modules/dist and ALL dot-directories (.github, .next, .claude, .cursor, …):
805
+ // they aren't application source, and counting them skews the shape signals — e.g. a
806
+ // `.github/workflows/` CI dir must not read as an app "workflows"/saga signal, and Ark's
807
+ // own installed `.claude`/`.codex` dirs must not perturb a re-run's recommendation.
808
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue;
809
+ walkSourceFiles(path.join(dir, entry.name), files, depth + 1);
810
+ }
811
+ return files;
812
+ }
813
+
814
+ function listTopLevelDirNames(root, baseDir) {
815
+ const base = path.join(root, baseDir);
816
+ if (!fs.existsSync(base)) return [];
817
+ try {
818
+ return fs
819
+ .readdirSync(base, { withFileTypes: true })
820
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
821
+ .map((e) => e.name);
822
+ } catch {
823
+ return [];
824
+ }
825
+ }
826
+
827
+ function dirExistsAnywhere(root, names) {
828
+ const queue = ['.'];
829
+ const seen = new Set();
830
+ while (queue.length > 0) {
831
+ const rel = queue.shift();
832
+ if (seen.has(rel)) continue;
833
+ seen.add(rel);
834
+ const abs = path.join(root, rel);
835
+ if (!fs.existsSync(abs)) continue;
836
+ let entries;
837
+ try {
838
+ entries = fs.readdirSync(abs, { withFileTypes: true });
839
+ } catch {
840
+ continue;
841
+ }
842
+ for (const entry of entries) {
843
+ if (!entry.isDirectory()) continue;
844
+ // Skip node_modules/dist and ALL dot-dirs so `.github/workflows/` (CI YAML) can't be read
845
+ // as an app "workflows"/saga signal, and Ark's own `.claude`/`.codex` don't self-perturb.
846
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue;
847
+ if (names.has(entry.name)) return true;
848
+ const child = rel === '.' ? entry.name : `${rel}/${entry.name}`;
849
+ if (child.split('/').length < 8) queue.push(child);
850
+ }
851
+ }
852
+ return false;
853
+ }
854
+
855
+ function countTsxFiles(files) {
856
+ return files.filter((f) => /\.(tsx|jsx)$/i.test(f)).length;
857
+ }
858
+
859
+ /**
860
+ * Collect deterministic repo shape signals for architecture archetype scoring.
861
+ * Vendor packages may appear in toolHints only — never as the primary label.
862
+ */
863
+ export function collectRepoShapeSignals(root) {
864
+ const pkg = readPackageJson(root);
865
+ const workspaceDirs = detectWorkspaces(root);
866
+ const workspaces = workspaceDirs.length > 0;
867
+ const srcDirs = ['src', 'lib', 'api', 'packages', 'apps'].filter((d) =>
868
+ fs.existsSync(path.join(root, d))
869
+ );
870
+ const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
871
+ const sourceFiles = scanRoots.flatMap((dir) => walkSourceFiles(dir));
872
+ const sourceFileCount = sourceFiles.length;
873
+ const tinyTree = sourceFileCount < 3;
874
+
875
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
876
+ const hasUiFramework = Object.keys(deps).some((name) =>
877
+ /^(react|react-dom|vue|svelte|preact|solid-js)$/i.test(name.split('/')[0])
878
+ );
879
+ const srcUiFiles = sourceFiles.filter((file) => {
880
+ const rel = path.relative(root, file).split(path.sep).join('/');
881
+ return rel.startsWith('src/') && /\.(tsx|jsx)$/i.test(file);
882
+ });
883
+
884
+ const topNames = new Set(srcDirs.flatMap((d) => listTopLevelDirNames(root, d)));
885
+ // Framework / filename signals — strong enough that a tiny Nest starter is not a "prototype".
886
+ const nestFramework =
887
+ Object.keys(deps).some((name) => name.startsWith('@nestjs/')) ||
888
+ sourceFiles.some((file) =>
889
+ /\.(controller|module|service|guard|interceptor|pipe)\.ts$/i.test(file)
890
+ );
891
+ const nextFramework =
892
+ Boolean(deps.next) ||
893
+ sourceFiles.some((file) => {
894
+ const rel = path.relative(root, file).split(path.sep).join('/');
895
+ return (
896
+ /(^|\/)next\.config\./.test(rel) ||
897
+ /(^|\/)app\/.*\/page\.(t|j)sx?$/.test(rel) ||
898
+ /(^|\/)pages\/.+\.(t|j)sx?$/.test(rel)
899
+ );
900
+ });
901
+ const expressLike = Object.keys(deps).some((name) =>
902
+ /^(express|fastify|hono|koa|@hapi\/hako|restify)$/i.test(name)
903
+ );
904
+
905
+ const ui =
906
+ dirExistsAnywhere(root, UI_DIR_NAMES) ||
907
+ countTsxFiles(sourceFiles) >= 2 ||
908
+ topNames.has('components') ||
909
+ topNames.has('pages') ||
910
+ nextFramework ||
911
+ (hasUiFramework && srcUiFiles.length >= 1);
912
+ const apiSurface =
913
+ dirExistsAnywhere(root, API_DIR_NAMES) ||
914
+ topNames.has('routes') ||
915
+ topNames.has('controllers') ||
916
+ nestFramework ||
917
+ expressLike;
918
+ const persistenceFromDeps = Object.keys(deps).some((name) =>
919
+ /^(prisma|drizzle-orm|typeorm|@libsql\/client|@supabase\/supabase-js|mongodb|pg|mysql2|better-sqlite3|knex)$/i.test(
920
+ name
921
+ )
922
+ );
923
+ const persistence = dirExistsAnywhere(root, PERSISTENCE_DIR_NAMES) || persistenceFromDeps;
924
+ const jobs = dirExistsAnywhere(root, JOB_DIR_NAMES);
925
+ // App saga/workflow code only — CI under .github is skipped by walk/dirExists (dot-dirs).
926
+ const workflows = dirExistsAnywhere(root, WORKFLOW_DIR_NAMES);
927
+ const integration = dirExistsAnywhere(root, INTEGRATION_DIR_NAMES);
928
+ const domain = dirExistsAnywhere(root, new Set(['domain'])) || topNames.has('domain');
929
+ const application =
930
+ dirExistsAnywhere(root, new Set(['application', 'app', 'services'])) ||
931
+ topNames.has('application') ||
932
+ nestFramework;
933
+ const featureSlicedLayout =
934
+ fs.existsSync(path.join(root, 'src')) &&
935
+ ['app', 'pages', 'features', 'entities', 'shared'].some((name) =>
936
+ fs.existsSync(path.join(root, 'src', name))
937
+ );
938
+
939
+ const hasBin = Boolean(pkg?.bin);
940
+ const hasExports = Boolean(pkg?.exports);
941
+ const hasMain = Boolean(pkg?.main || pkg?.module);
942
+ // A Nest/Next/express app is never a library-sdk, even if it has "main".
943
+ const library =
944
+ !hasBin &&
945
+ !nestFramework &&
946
+ !nextFramework &&
947
+ !expressLike &&
948
+ (hasExports || hasMain || pkg?.type === 'module') &&
949
+ !ui &&
950
+ !apiSurface &&
951
+ sourceFileCount > 0 &&
952
+ sourceFileCount < 80;
953
+ const cli = hasBin;
954
+
955
+ const tsxCount = countTsxFiles(sourceFiles);
956
+ const uiHeavy = ui && tsxCount >= 3;
957
+ const apiSurfaceOnly = apiSurface && !uiHeavy;
958
+ const persistenceHeavy = persistence && sourceFileCount >= 8;
959
+ const domainHeavy = domain && application;
960
+ const jobsOnly = jobs && !ui && !apiSurface;
961
+ const uiOnly = ui && !persistence && !apiSurface && !jobs;
962
+ const libraryOnly = library && !cli;
963
+
964
+ const fullStackProduct = ui && apiSurface && persistence;
965
+
966
+ const toolHints = Object.keys(deps).filter((name) =>
967
+ /^(next|@nestjs|express|fastify|hono|prisma|drizzle|typeorm|supabase|react|vue|svelte)$/i.test(
968
+ name.split('/')[0]
969
+ )
970
+ );
971
+ if (nestFramework && !toolHints.some((h) => h.startsWith('@nestjs') || h === 'nestjs')) {
972
+ toolHints.push('@nestjs/*');
973
+ }
974
+ if (nextFramework && !toolHints.includes('next')) toolHints.push('next');
975
+
976
+ return {
977
+ workspaces,
978
+ workspaceDirs,
979
+ ui,
980
+ uiHeavy,
981
+ apiSurface,
982
+ apiSurfaceOnly,
983
+ persistence,
984
+ persistenceHeavy,
985
+ jobs,
986
+ jobsOnly,
987
+ workflows,
988
+ integration,
989
+ cli,
990
+ library,
991
+ libraryOnly,
992
+ tinyTree,
993
+ sourceFileCount,
994
+ domain,
995
+ application,
996
+ domainHeavy,
997
+ uiOnly,
998
+ featureSlicedLayout,
999
+ fullStackProduct,
1000
+ persistenceFromDeps,
1001
+ nestFramework,
1002
+ nextFramework,
1003
+ expressLike,
1004
+ toolHints,
1005
+ };
1006
+ }
1007
+
1008
+ const SIGNAL_WHY = {
1009
+ workspaces: (signals) => `workspace roots declared (${signals.workspaceDirs.join(', ')})`,
1010
+ tinyTree: (signals) => `few source files (${signals.sourceFileCount})`,
1011
+ ui: () => 'UI directories or multiple TSX files present',
1012
+ uiHeavy: () => 'substantial UI surface (multiple TSX files)',
1013
+ apiSurface: () => 'API/route/controller directories present',
1014
+ apiSurfaceOnly: () => 'API surface without a heavy UI layer',
1015
+ persistence: () => 'persistence or data-access directories present',
1016
+ persistenceHeavy: () => 'substantial persistence layer',
1017
+ jobs: () => 'jobs/workers/schedules directories present',
1018
+ jobsOnly: () => 'background jobs without UI or API entrypoints',
1019
+ workflows: () => 'workflows or sagas directories present',
1020
+ integration: () => 'integration/webhook/sync directories present',
1021
+ cli: () => 'package.json declares a bin entry',
1022
+ library: () => 'publishable package shape (exports/main, no CLI bin)',
1023
+ libraryOnly: () => 'library package without a CLI entry',
1024
+ featureSlicedLayout: () => 'feature-sliced directory layout under src/',
1025
+ domain: () => 'domain directory present',
1026
+ application: () => 'application or services directory present',
1027
+ domainHeavy: () => 'both domain and application directories present',
1028
+ uiOnly: () => 'UI without persistence, API, or jobs',
1029
+ fullStackProduct: () => 'UI, API handlers, and persistence dependencies together',
1030
+ persistenceFromDeps: () => 'database client library in package.json dependencies',
1031
+ nestFramework: () => 'NestJS modules/controllers/services (or @nestjs/* deps)',
1032
+ nextFramework: () => 'Next.js app/pages router or next dependency',
1033
+ expressLike: () => 'HTTP framework dependency (express/fastify/hono/…)',
1034
+ };
1035
+
1036
+ const NEGATIVE_SIGNAL_WHY = {
1037
+ workspaces: () => 'not a workspace monorepo (penalized for this shape)',
1038
+ cli: () => 'CLI bin entry present (penalized for this shape)',
1039
+ ui: () => 'UI directories present (penalized for this shape)',
1040
+ uiHeavy: () => 'heavy UI surface (penalized for this shape)',
1041
+ persistence: () => 'persistence directories present (penalized for this shape)',
1042
+ apiSurfaceOnly: () => 'API-only surface (penalized for this shape)',
1043
+ tinyTree: () => 'very small source tree (penalized for this shape)',
1044
+ jobs: () => 'background jobs present (penalized for this shape)',
1045
+ jobsOnly: () => 'jobs without UI/API (penalized for this shape)',
1046
+ workflows: () => 'workflows present (penalized for this shape)',
1047
+ domainHeavy: () => 'rich domain layer (penalized for this shape)',
1048
+ libraryOnly: () => 'library-only package (penalized for this shape)',
1049
+ persistenceHeavy: () => 'heavy persistence usage (penalized for this shape)',
1050
+ };
1051
+
1052
+ /** Plain-language reasons for signals that scored the winning archetype. */
1053
+ export function whyFromMatchedSignals(signals, matched) {
1054
+ const why = [];
1055
+ for (const token of matched ?? []) {
1056
+ if (token.startsWith('!')) {
1057
+ const neg = token.slice(1);
1058
+ const label = NEGATIVE_SIGNAL_WHY[neg];
1059
+ if (label && signals[neg]) why.push(label(signals));
1060
+ continue;
1061
+ }
1062
+ const label = SIGNAL_WHY[token];
1063
+ if (label && signals[token]) why.push(label(signals));
1064
+ }
1065
+ return why;
1066
+ }
1067
+
1068
+ export function defaultPlaybookPath() {
1069
+ return path.join(
1070
+ path.dirname(fileURLToPath(import.meta.url)),
1071
+ '..',
1072
+ 'templates',
1073
+ 'architecture-playbook.json'
1074
+ );
1075
+ }
1076
+
1077
+ export function loadArchitecturePlaybook(playbookPath = defaultPlaybookPath()) {
1078
+ const raw = fs.readFileSync(playbookPath, 'utf8');
1079
+ const playbook = JSON.parse(raw);
1080
+ const ids = Object.keys(playbook.archetypes ?? {});
1081
+ if (ids.length !== ARCHETYPE_IDS.length) {
1082
+ throw new Error(
1083
+ `architecture-playbook.json must define exactly ${ARCHETYPE_IDS.length} archetypes (found ${ids.length})`
1084
+ );
1085
+ }
1086
+ for (const id of ARCHETYPE_IDS) {
1087
+ if (!playbook.archetypes[id]) {
1088
+ throw new Error(`architecture-playbook.json missing archetype: ${id}`);
1089
+ }
1090
+ }
1091
+ return playbook;
1092
+ }
1093
+
1094
+ function resolvePreset(archetypeDef, signals) {
1095
+ const alt = archetypeDef.presetAlternatives?.['feature-sliced'];
1096
+ if (alt?.whenSignal && signals[alt.whenSignal]) return 'feature-sliced';
1097
+ return archetypeDef.preset;
1098
+ }
1099
+
1100
+ /**
1101
+ * Score playbook archetypes against collected repo shape signals.
1102
+ * Returns sorted matches (highest score first) with confidence in [0, 1].
1103
+ */
1104
+ export function scoreArchetypes(signals, playbook) {
1105
+ const scored = [];
1106
+ for (const [id, def] of Object.entries(playbook.archetypes)) {
1107
+ let score = 0;
1108
+ const matched = [];
1109
+ for (const [signal, weight] of Object.entries(def.detectionSignals ?? {})) {
1110
+ if (signals[signal]) {
1111
+ score += Number(weight);
1112
+ matched.push(signal);
1113
+ }
1114
+ }
1115
+ for (const [signal, weight] of Object.entries(def.negativeSignals ?? {})) {
1116
+ if (signals[signal]) {
1117
+ score -= Number(weight);
1118
+ matched.push(`!${signal}`);
1119
+ }
1120
+ }
1121
+ const maxPositive = Object.values(def.detectionSignals ?? {}).reduce(
1122
+ (sum, w) => sum + Number(w),
1123
+ 0
1124
+ );
1125
+ scored.push({
1126
+ id,
1127
+ label: def.label,
1128
+ preset: resolvePreset(def, signals),
1129
+ score,
1130
+ maxPositive: maxPositive || 1,
1131
+ matched,
1132
+ phases: def.phases,
1133
+ analogy: def.analogy,
1134
+ antiPatterns: def.antiPatterns ?? [],
1135
+ books: def.books ?? [],
1136
+ });
1137
+ }
1138
+ scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
1139
+
1140
+ const top = scored[0];
1141
+ const second = scored[1];
1142
+ if (!top || top.score <= 0) {
1143
+ const spike = scored.find((entry) => entry.id === 'prototype-spike');
1144
+ const fallback = spike ?? top;
1145
+ const confidence = signals.tinyTree ? 0.55 : 0.35;
1146
+ return {
1147
+ ranked: scored,
1148
+ archetype: fallback.id,
1149
+ label: fallback.label,
1150
+ preset: fallback.preset,
1151
+ confidence,
1152
+ phases: fallback.phases,
1153
+ analogy: fallback.analogy,
1154
+ antiPatterns: fallback.antiPatterns,
1155
+ books: fallback.books,
1156
+ matched: fallback.matched,
1157
+ runnerUp: second && second.id !== fallback.id ? { id: second.id, score: second.score } : { id: 'crud-product', score: 0 },
1158
+ };
1159
+ }
1160
+
1161
+ const rawConfidence = top.score / top.maxPositive;
1162
+ const margin =
1163
+ second && second.score > 0 ? (top.score - second.score) / Math.max(top.score, 1) : 0.25;
1164
+ const confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
1165
+
1166
+ return {
1167
+ ranked: scored,
1168
+ archetype: top.id,
1169
+ label: top.label,
1170
+ preset: top.preset,
1171
+ confidence: Math.round(confidence * 1000) / 1000,
1172
+ phases: top.phases,
1173
+ analogy: top.analogy,
1174
+ antiPatterns: top.antiPatterns,
1175
+ books: top.books,
1176
+ matched: top.matched,
1177
+ runnerUp: second
1178
+ ? { id: second.id, label: second.label, score: second.score, preset: second.preset }
1179
+ : { id: 'prototype-spike', score: 0 },
1180
+ };
1181
+ }
1182
+
1183
+ // Source-file count above which a repo is treated as an established codebase rather than a
1184
+ // fresh project — the boundary between the `ark init` starter flow and the /ark-adopt flow.
1185
+ export const MATURE_REPO_FILE_THRESHOLD = 150;
1186
+
1187
+ export function buildArchitectureRecommendation(root, options = {}) {
1188
+ const playbookPath = options.playbookPath ?? defaultPlaybookPath();
1189
+ const playbook = loadArchitecturePlaybook(playbookPath);
1190
+ const signals = collectRepoShapeSignals(root);
1191
+ const result = scoreArchetypes(signals, playbook);
1192
+
1193
+ const adoptInOrder = {
1194
+ phase1: result.phases?.['1'] ?? [],
1195
+ phase2: result.phases?.['2'] ?? [],
1196
+ phase3: result.phases?.['3'] ?? [],
1197
+ };
1198
+
1199
+ return {
1200
+ ok: true,
1201
+ playbookVersion: playbook.version,
1202
+ archetype: result.archetype,
1203
+ label: result.label,
1204
+ preset: result.preset,
1205
+ confidence: result.confidence,
1206
+ phases: result.phases,
1207
+ adoptInOrder,
1208
+ analogy: result.analogy,
1209
+ antiPatterns: result.antiPatterns,
1210
+ books: result.books,
1211
+ why: whyFromMatchedSignals(signals, result.matched),
1212
+ matchedSignals: result.matched,
1213
+ runnerUp: result.runnerUp,
1214
+ toolHints: signals.toolHints,
1215
+ signals: {
1216
+ sourceFileCount: signals.sourceFileCount,
1217
+ workspaces: signals.workspaces,
1218
+ ui: signals.ui,
1219
+ apiSurface: signals.apiSurface,
1220
+ persistence: signals.persistence,
1221
+ jobs: signals.jobs,
1222
+ workflows: signals.workflows,
1223
+ integration: signals.integration,
1224
+ cli: signals.cli,
1225
+ library: signals.library,
1226
+ tinyTree: signals.tinyTree,
1227
+ fullStackProduct: signals.fullStackProduct,
1228
+ persistenceFromDeps: signals.persistenceFromDeps,
1229
+ nestFramework: signals.nestFramework,
1230
+ nextFramework: signals.nextFramework,
1231
+ expressLike: signals.expressLike,
1232
+ },
1233
+ // A repo past this size is not greenfield: `ark init` would scaffold a starter that governs
1234
+ // a thin slice and can mis-flag framework internals, so steer these to the adoption flow.
1235
+ mature: signals.sourceFileCount >= MATURE_REPO_FILE_THRESHOLD,
1236
+ initCommand: `${arkCommand(root, 'ark', `init --archetype ${result.archetype} --yes`)}`,
1237
+ firstCommand: `${arkCommand(root, 'ark', `init --archetype ${result.archetype} --yes`)}`,
1238
+ adoptCommand: arkCommand(root, 'ark-check', '--recommend --write-plan'),
1239
+ recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
1240
+ checkCommand: arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config'),
1241
+ };
1242
+ }
1243
+
1244
+ /** English wizard choices (application shape, not vendor stack). */
1245
+ export const INIT_WIZARD_CHOICES = [
1246
+ { key: '1', archetype: 'crud-product', label: 'A product with UI and stored data' },
1247
+ { key: '2', archetype: 'api-backend', label: 'An API server without UI in this repo' },
1248
+ { key: '3', archetype: 'frontend-surface', label: 'A UI-focused app (backend elsewhere)' },
1249
+ { key: '4', archetype: 'cli-utility', label: 'A command-line tool' },
1250
+ { key: '5', archetype: 'worker-pipeline', label: 'Background jobs or workers' },
1251
+ { key: '6', archetype: 'multi-app-workspace', label: 'Several apps in one repository' },
1252
+ { key: '7', archetype: 'prototype-spike', label: 'A quick experiment or learning project' },
1253
+ { key: '8', archetype: 'auto', label: 'Analyze my repo and suggest (recommended if unsure)' },
1254
+ ];
1255
+
1256
+ export function isValidArchetypeId(id) {
1257
+ return ARCHETYPE_IDS.includes(id);
1258
+ }
1259
+
1260
+ export function resolveArchetypePreset(archetypeId, playbookPath = defaultPlaybookPath()) {
1261
+ if (!isValidArchetypeId(archetypeId)) {
1262
+ throw new Error(
1263
+ `Unknown archetype "${archetypeId}". Valid ids: ${ARCHETYPE_IDS.join(', ')}`
1264
+ );
1265
+ }
1266
+ const playbook = loadArchitecturePlaybook(playbookPath);
1267
+ const def = playbook.archetypes[archetypeId];
1268
+ return {
1269
+ archetype: archetypeId,
1270
+ preset: def.preset,
1271
+ label: def.label,
1272
+ phases: def.phases,
1273
+ };
1274
+ }
1275
+
1276
+ export function mapWizardChoiceToArchetype(choiceKey) {
1277
+ const entry = INIT_WIZARD_CHOICES.find((c) => c.key === String(choiceKey).trim());
1278
+ if (!entry) return null;
1279
+ return entry.archetype;
1280
+ }
1281
+
1282
+ const NEW_HERE_GOVERNED_THRESHOLD = 50;
1283
+
1284
+ /** Show onboarding nudge when coverage is low or config is missing. */
1285
+ export function shouldShowNewHereNudge(root, configPath, governedPercent, configMissing) {
1286
+ if (configMissing) return true;
1287
+ if (typeof governedPercent === 'number' && governedPercent < NEW_HERE_GOVERNED_THRESHOLD) {
1288
+ return true;
1289
+ }
1290
+ if (fs.existsSync(configPath)) {
1291
+ try {
1292
+ const stat = fs.statSync(configPath);
1293
+ const ageDays = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
1294
+ if (ageDays < 7 && governedPercent < 80) return true;
1295
+ } catch {
1296
+ /* ignore */
1297
+ }
1298
+ }
1299
+ return false;
1300
+ }
1301
+
1302
+ /**
1303
+ * Deterministic fix-class labels for JSON output (English, shared with future skills).
1304
+ */
1305
+ export function enrichViolationWithFixClass(violation) {
1306
+ const enriched = { ...violation };
1307
+ switch (violation.ruleId) {
1308
+ case 'LAYER_IMPORT_VIOLATION':
1309
+ if (violation.typeOnly) {
1310
+ enriched.fixClass = 'file-move';
1311
+ enriched.effort = 'small';
1312
+ enriched.enthusiastHint =
1313
+ 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
1314
+ } else {
1315
+ enriched.fixClass = 'port-inversion';
1316
+ enriched.effort = 'medium';
1317
+ enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
1318
+ }
1319
+ break;
1320
+ case 'FORBIDDEN_GLOBAL':
1321
+ enriched.fixClass = 'inject-port';
1322
+ enriched.effort = 'small';
1323
+ enriched.enthusiastHint = `Do not call "${violation.target ?? 'that global'}" here. Pass the capability in through a small interface (for example a Clock, HttpPort, or Config provider).`;
1324
+ break;
1325
+ case 'RAW_EVENT_PUBLISH':
1326
+ enriched.fixClass = 'registered-intent';
1327
+ enriched.effort = 'small';
1328
+ enriched.enthusiastHint =
1329
+ 'Register the event intent first, then publish through the creator returned by the registry — not a raw string or object.';
1330
+ break;
1331
+ case 'PUBLISH_MISSING_SOURCE':
1332
+ enriched.fixClass = 'add-source-metadata';
1333
+ enriched.effort = 'small';
1334
+ enriched.enthusiastHint =
1335
+ 'Add metadata.source to the publish call so Ark knows which layer is publishing the event.';
1336
+ break;
1337
+ case 'PUBLISH_SOURCE_LAYER_MISMATCH':
1338
+ enriched.fixClass = 'fix-source-layer';
1339
+ enriched.effort = 'small';
1340
+ enriched.enthusiastHint =
1341
+ 'Use a source intent that belongs to the same layer as this file, or move the publish call to the layer that owns the source.';
1342
+ break;
1343
+ case 'LAYER_INTENT_REFERENCE_VIOLATION':
1344
+ enriched.fixClass = 'intent-relocation';
1345
+ enriched.effort = 'small';
1346
+ enriched.enthusiastHint =
1347
+ 'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
1348
+ break;
1349
+ case 'CIRCULAR_DEPENDENCY':
1350
+ enriched.fixClass = 'break-cycle';
1351
+ enriched.effort = 'medium';
1352
+ enriched.enthusiastHint =
1353
+ 'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
1354
+ break;
1355
+ default:
1356
+ enriched.fixClass = 'review-contract';
1357
+ enriched.effort = 'small';
1358
+ enriched.enthusiastHint =
1359
+ 'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
1360
+ }
1361
+ return enriched;
1362
+ }
1363
+
1364
+ export function formatArchitectureRecommendationHuman(recommendation) {
1365
+ const lines = [];
1366
+ lines.push('Ark architecture recommendation (application shape, not vendor stack)');
1367
+ lines.push('');
1368
+ lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1369
+ lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
1370
+ if (recommendation.runnerUp?.id) {
1371
+ lines.push(
1372
+ `Runner-up: ${recommendation.runnerUp.id}${recommendation.runnerUp.label ? ` (${recommendation.runnerUp.label})` : ''}`
1373
+ );
1374
+ }
1375
+ lines.push('');
1376
+ lines.push('Phase 1 layers (start here):');
1377
+ for (const layer of recommendation.adoptInOrder.phase1) {
1378
+ lines.push(` - ${layer}`);
1379
+ }
1380
+ if (recommendation.adoptInOrder.phase2?.length) {
1381
+ lines.push('Phase 2 (when you add integrations or similar):');
1382
+ for (const layer of recommendation.adoptInOrder.phase2) {
1383
+ lines.push(` - ${layer}`);
1384
+ }
1385
+ }
1386
+ lines.push('');
1387
+ lines.push(`Analogy: ${recommendation.analogy}`);
1388
+ if (recommendation.why?.length) {
1389
+ lines.push('');
1390
+ lines.push('Why (repo shape signals):');
1391
+ for (const item of recommendation.why) {
1392
+ lines.push(` - ${item}`);
1393
+ }
1394
+ }
1395
+ if (recommendation.antiPatterns?.length) {
1396
+ lines.push('');
1397
+ lines.push('Avoid:');
1398
+ for (const item of recommendation.antiPatterns) {
1399
+ lines.push(` - ${item}`);
1400
+ }
1401
+ }
1402
+ lines.push('');
1403
+ if (recommendation.mature) {
1404
+ // Established codebase: `ark init` would scaffold a thin/mis-scoped starter. Route to the
1405
+ // adoption flow, which aligns the contract to the repo's real structure with judgment.
1406
+ lines.push(
1407
+ `This is an established codebase (${recommendation.signals?.sourceFileCount} source files) — use the adoption flow,`
1408
+ );
1409
+ lines.push('not the greenfield starter, so the contract matches your real structure:');
1410
+ lines.push(`Next: ${recommendation.adoptCommand}`);
1411
+ lines.push('Then: run /ark-adopt in your agent (re-scope layers to reality, freeze real debt only)');
1412
+ } else {
1413
+ lines.push(`Next: ${recommendation.firstCommand}`);
1414
+ lines.push(`Then: ${recommendation.checkCommand}`);
1415
+ }
1416
+ return lines.join('\n');
1417
+ }
1418
+
1419
+ export const ADOPTION_PLAN_FILENAME = 'ark-adoption-plan.json';
1420
+
1421
+ const GALLERY_STARTER_BY_ARCHETYPE = {
1422
+ 'crud-product': 'examples/crud-product-starter/',
1423
+ 'api-backend': 'examples/api-backend-starter/',
1424
+ 'worker-pipeline': 'examples/worker-pipeline-starter/',
1425
+ 'multi-app-workspace': 'examples/multi-app-workspace-starter/',
1426
+ };
1427
+
1428
+ /** Machine-readable adoption record for optional commit (Phase E). */
1429
+ export function buildAdoptionPlanDocument(recommendation) {
1430
+ const preset = recommendation.preset;
1431
+ const policyPackId =
1432
+ preset === 'hexagonal' ||
1433
+ preset === 'layered' ||
1434
+ preset === 'feature-sliced' ||
1435
+ preset === 'monorepo'
1436
+ ? `enthusiast-${preset}`
1437
+ : null;
1438
+
1439
+ return {
1440
+ version: '1',
1441
+ generatedAt: new Date().toISOString(),
1442
+ playbookVersion: recommendation.playbookVersion,
1443
+ archetype: recommendation.archetype,
1444
+ label: recommendation.label,
1445
+ preset: recommendation.preset,
1446
+ confidence: recommendation.confidence,
1447
+ phases: recommendation.phases,
1448
+ adoptInOrder: recommendation.adoptInOrder,
1449
+ matchedSignals: recommendation.matchedSignals ?? [],
1450
+ analogy: recommendation.analogy,
1451
+ antiPatterns: recommendation.antiPatterns ?? [],
1452
+ books: recommendation.books ?? [],
1453
+ why: recommendation.why ?? [],
1454
+ runnerUp: recommendation.runnerUp,
1455
+ mature: recommendation.mature ?? false,
1456
+ initCommand: recommendation.initCommand,
1457
+ firstCommand: recommendation.firstCommand,
1458
+ adoptCommand: recommendation.adoptCommand,
1459
+ checkCommand: recommendation.checkCommand,
1460
+ recommendCommand: recommendation.recommendCommand,
1461
+ galleryStarter: GALLERY_STARTER_BY_ARCHETYPE[recommendation.archetype] ?? null,
1462
+ policyPack: policyPackId,
1463
+ writePlanCommand: 'ark-check --recommend --write-plan',
1464
+ };
1465
+ }
1466
+
1467
+ /** Write ark-adoption-plan.json; never weakens the gate — JSON only. */
1468
+ export function writeAdoptionPlan(root, recommendation, filename = ADOPTION_PLAN_FILENAME) {
1469
+ const document = buildAdoptionPlanDocument(recommendation);
1470
+ const outPath = path.join(root, filename);
1471
+ fs.writeFileSync(outPath, `${JSON.stringify(document, null, 2)}\n`);
1472
+ return { path: outPath, document };
1473
+ }
1474
+
1475
+ const __arkSharedDir = path.dirname(fileURLToPath(import.meta.url));
1476
+
1477
+ export function defaultPolicyPacksPath() {
1478
+ return path.resolve(__arkSharedDir, '../templates/policy-packs');
1479
+ }
1480
+
1481
+ export function listPolicyPackIds(packsPath = defaultPolicyPacksPath()) {
1482
+ if (!fs.existsSync(packsPath)) return [];
1483
+ return fs
1484
+ .readdirSync(packsPath)
1485
+ .filter((name) => name.endsWith('.json'))
1486
+ .map((name) => name.replace(/\.json$/, ''))
1487
+ .sort();
1488
+ }
1489
+
1490
+ export function loadPolicyPackMeta(packId, packsPath = defaultPolicyPacksPath()) {
1491
+ if (typeof packId !== 'string' || !packId.length) {
1492
+ throw new Error('Policy pack id is required');
1493
+ }
1494
+ if (!/^[a-z][a-z0-9-]*$/.test(packId)) {
1495
+ throw new Error(
1496
+ `Invalid policy pack id "${packId}". Valid packs: ${listPolicyPackIds(packsPath).join(', ') || '(none)'}`
1497
+ );
1498
+ }
1499
+ const ids = listPolicyPackIds(packsPath);
1500
+ if (!ids.includes(packId)) {
1501
+ throw new Error(
1502
+ `Unknown policy pack "${packId}". Valid packs: ${ids.join(', ') || '(none)'}`
1503
+ );
1504
+ }
1505
+ const filePath = path.join(packsPath, `${packId}.json`);
1506
+ const resolved = path.resolve(filePath);
1507
+ if (!resolved.startsWith(`${path.resolve(packsPath)}${path.sep}`)) {
1508
+ throw new Error(`Invalid policy pack id "${packId}"`);
1509
+ }
1510
+ if (!fs.existsSync(filePath)) {
1511
+ throw new Error(
1512
+ `Unknown policy pack "${packId}". Valid packs: ${ids.join(', ') || '(none)'}`
1513
+ );
1514
+ }
1515
+ const pack = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1516
+ if (!pack.id || !pack.preset) {
1517
+ throw new Error(`Policy pack ${filePath} must define "id" and "preset"`);
1518
+ }
1519
+ return pack;
1520
+ }