ark-runtime-kernel 1.0.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.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ark-mcp — zero-dependency MCP server exposing Ark's architectural contract and a
4
+ * code-validation gate over stdio (JSON-RPC 2.0, newline-delimited).
5
+ *
6
+ * Purpose: the AI write-path gate. A host (e.g. Claude Code) binds the `validate_code`
7
+ * tool to PreToolUse on Write/Edit, so generated code is checked against the architecture
8
+ * BEFORE it lands — turning Ark's manifest + AI code gate from a library you must remember
9
+ * to call into an enforced checkpoint on the operation that actually matters for agents.
10
+ *
11
+ * Capabilities:
12
+ * - resource ark://manifest — the architectural contract (layers + rules, or a project
13
+ * manifest file when --manifest is provided)
14
+ * - tool validate_code — runs Ark's AI code gate on a source snippet; returns
15
+ * { valid, violations } and sets isError when invalid
16
+ *
17
+ * Usage: ark-mcp [--root <dir>] [--config ark.config.json] [--manifest <manifest.json>]
18
+ * ark-mcp --hook [--root <dir>] [--config ark.config.json]
19
+ *
20
+ * --hook runs one-shot instead of serving: it reads a Claude Code PreToolUse payload from
21
+ * stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
22
+ * 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
23
+ * the copy-paste integration for agent runtimes whose hooks run shell commands.
24
+ */
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import readline from 'node:readline';
28
+ import { DEFAULT_INTENT_PREFIXES, DEFAULT_RULES, layerForFile } from './ark-shared.mjs';
29
+
30
+ function parseArgs(argv) {
31
+ const args = {
32
+ root: process.cwd(),
33
+ config: 'ark.config.json',
34
+ configExplicit: false,
35
+ manifest: undefined,
36
+ hook: false,
37
+ };
38
+ for (let i = 2; i < argv.length; i += 1) {
39
+ const a = argv[i];
40
+ if (a === '--hook') args.hook = true;
41
+ else if (a === '--root') args.root = path.resolve(argv[++i]);
42
+ else if (a === '--config') {
43
+ args.config = argv[++i];
44
+ args.configExplicit = true;
45
+ } else if (a === '--manifest') args.manifest = argv[++i];
46
+ }
47
+ return args;
48
+ }
49
+
50
+ /**
51
+ * Read a JSON file. Missing files return undefined unless `required` (so the caller can
52
+ * fall back), but malformed JSON always throws — silently swallowing a syntax error would
53
+ * turn the layer gate into a no-op that reports every write as valid.
54
+ */
55
+ function readJson(file, { required } = {}) {
56
+ if (!fs.existsSync(file)) {
57
+ if (required) throw new Error(`File not found: ${file}`);
58
+ return undefined;
59
+ }
60
+ try {
61
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ } catch (err) {
63
+ throw new Error(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }
66
+
67
+ function resolveInRoot(root, maybePath) {
68
+ if (!maybePath) return undefined;
69
+ return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
70
+ }
71
+
72
+ function inferLayer(filePath, config, root) {
73
+ if (!filePath) return undefined;
74
+ return layerForFile(root, filePath, config.layers);
75
+ }
76
+
77
+ async function loadArk() {
78
+ const url = new URL('../dist/index.js', import.meta.url);
79
+ if (!fs.existsSync(url)) {
80
+ throw new Error(
81
+ 'ark-mcp requires the built library at dist/index.js. Run "npm run build" first.'
82
+ );
83
+ }
84
+ try {
85
+ return await import(url.href);
86
+ } catch (err) {
87
+ throw new Error(
88
+ `ark-mcp failed to load dist/index.js (rebuild with "npm run build"): ${
89
+ err instanceof Error ? err.message : String(err)
90
+ }`
91
+ );
92
+ }
93
+ }
94
+
95
+ async function loadOptionalTypeScript() {
96
+ try {
97
+ return await import('typescript');
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
104
+
105
+ /**
106
+ * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
107
+ * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
108
+ * snippet out of context. Replacement uses a function argument so `$&`-style sequences in
109
+ * generated code are inserted literally, never interpreted as replacement patterns.
110
+ */
111
+ function proposedSource(toolName, toolInput) {
112
+ if (toolName === 'Write') return toolInput.content;
113
+
114
+ let text = '';
115
+ try {
116
+ text = fs.readFileSync(toolInput.file_path, 'utf8');
117
+ } catch {
118
+ // New file created via Edit: fall through with an empty base.
119
+ }
120
+ const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
121
+ for (const edit of edits) {
122
+ const from = edit.old_string ?? '';
123
+ const to = edit.new_string ?? '';
124
+ if (from === '') {
125
+ text = to;
126
+ } else if (edit.replace_all) {
127
+ text = text.split(from).join(to);
128
+ } else {
129
+ text = text.replace(from, () => to);
130
+ }
131
+ }
132
+ return text;
133
+ }
134
+
135
+ /**
136
+ * One-shot PreToolUse gate (Claude Code hook contract): payload on stdin, exit 2 +
137
+ * violations on stderr to block, exit 0 to allow. Gate plumbing problems (no stdin,
138
+ * malformed JSON, non-file tools, non-source files) never block the agent.
139
+ */
140
+ function runHook(gate, config, args) {
141
+ let payload;
142
+ try {
143
+ payload = JSON.parse(fs.readFileSync(0, 'utf8'));
144
+ } catch {
145
+ return;
146
+ }
147
+
148
+ const toolName = payload?.tool_name;
149
+ const toolInput = payload?.tool_input ?? {};
150
+ const filePath = toolInput.file_path;
151
+ if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return;
152
+ if (typeof filePath !== 'string' || !SOURCE_FILE.test(filePath) || filePath.endsWith('.d.ts')) {
153
+ return;
154
+ }
155
+ const rel = path.relative(args.root, path.resolve(filePath));
156
+ const segments = rel.split(path.sep);
157
+ if (segments[0] === '..' || segments.includes('node_modules')) return;
158
+
159
+ const source = proposedSource(toolName, toolInput);
160
+ if (typeof source !== 'string') return;
161
+
162
+ const layer = inferLayer(filePath, config, args.root);
163
+ const result = gate.validate(source, { layer, filePath });
164
+ if (result.valid) return;
165
+
166
+ const lines = result.violations.map(
167
+ (violation) =>
168
+ `- [${violation.ruleId}] ${violation.message}${violation.line ? ` (line ${violation.line})` : ''}`
169
+ );
170
+ process.stderr.write(
171
+ [
172
+ `Ark architecture gate blocked this write to ${rel}${layer ? ` (layer: ${layer})` : ''}:`,
173
+ ...lines,
174
+ 'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
175
+ ].join('\n') + '\n'
176
+ );
177
+ process.exitCode = 2;
178
+ }
179
+
180
+ async function main() {
181
+ const args = parseArgs(process.argv);
182
+ const ark = await loadArk();
183
+ const ts = await loadOptionalTypeScript();
184
+
185
+ const configPath = resolveInRoot(args.root, args.config);
186
+ const config =
187
+ (configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
188
+ include: ['src'],
189
+ layers: [],
190
+ rules: [],
191
+ };
192
+ if (!config.layers || config.layers.length === 0) {
193
+ process.stderr.write(
194
+ '[ark-mcp] warning: no layers configured — file→layer inference from config patterns ' +
195
+ 'is unavailable, so layer-reference checks run only when the caller passes an explicit ' +
196
+ '"layer" (checked against the default 11-layer profile).\n'
197
+ );
198
+ }
199
+
200
+ const manifestPath = resolveInRoot(args.root, args.manifest);
201
+ const projectManifest = manifestPath ? readJson(manifestPath, { required: true }) : undefined;
202
+
203
+ const intents = Array.isArray(projectManifest?.intents)
204
+ ? projectManifest.intents.map((i) => (typeof i === 'string' ? i : i?.name)).filter(Boolean)
205
+ : [];
206
+
207
+ // Build the enforcement profile with the SAME semantics ark-check (CI) applies to the
208
+ // config, so the write-path gate and CI can't disagree:
209
+ // - rules: config.rules ?? DEFAULT_RULES (ark-check readConfig substitutes DEFAULT_RULES)
210
+ // - intent prefixes: the config layers that declare intentPrefixes; when none do, fall
211
+ // back to DEFAULT_INTENT_PREFIXES (mirrors ark-check's layerForIntent fallback).
212
+ // Only layers WITH prefixes enter the profile, so no layer has empty prefixes (which would
213
+ // also make it unresolvable). A project with no layers at all gets the 11-layer default.
214
+ const configLayers = Array.isArray(config.layers) ? config.layers : [];
215
+ const manifestLayers = Array.isArray(projectManifest?.architecture?.layers)
216
+ ? projectManifest.architecture.layers
217
+ : [];
218
+ const usedProjectConfig = configLayers.length > 0;
219
+ let profile;
220
+ if (manifestLayers.length > 0) {
221
+ profile = ark.createArchitectureProfile({
222
+ name: projectManifest.architecture.profile ?? 'manifest',
223
+ layers: manifestLayers.map((layer) => ({
224
+ name: layer.name,
225
+ prefixes: layer.prefixes,
226
+ })),
227
+ rules: projectManifest.architecture.rules ?? DEFAULT_RULES,
228
+ });
229
+ } else if (!usedProjectConfig) {
230
+ profile = ark.elevenLayerProfile;
231
+ } else {
232
+ const layersWithPrefixes = configLayers.filter(
233
+ (layer) => (layer.intentPrefixes ?? []).length > 0
234
+ );
235
+ const profileLayers =
236
+ layersWithPrefixes.length > 0
237
+ ? layersWithPrefixes.map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }))
238
+ : DEFAULT_INTENT_PREFIXES.map((d) => ({ name: d.layer, prefixes: d.prefixes }));
239
+ profile = ark.createArchitectureProfile({
240
+ name: 'ark.config',
241
+ layers: profileLayers,
242
+ rules: config.rules ?? DEFAULT_RULES,
243
+ });
244
+ }
245
+
246
+ const gate = ark.createAICodeGate({
247
+ architectureProfile: profile,
248
+ intents,
249
+ enforceIntentAllowlist: intents.length > 0,
250
+ typescript: ts,
251
+ });
252
+
253
+ if (args.hook) {
254
+ runHook(gate, config, args);
255
+ return;
256
+ }
257
+
258
+ const SERVER_INFO = { name: 'ark-runtime-kernel', version: ark.version };
259
+ const DEFAULT_PROTOCOL = '2024-11-05';
260
+
261
+ const TOOLS = [
262
+ {
263
+ name: 'validate_code',
264
+ description:
265
+ "Validate a source snippet about to be written against Ark's architecture " +
266
+ '(forbidden infra imports, unknown intents, and layer-reference violations). ' +
267
+ 'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
268
+ 'Returns { valid, violations }; isError is true when the code is invalid.',
269
+ inputSchema: {
270
+ type: 'object',
271
+ properties: {
272
+ source: { type: 'string', description: 'Full source text about to be written.' },
273
+ layer: {
274
+ type: 'string',
275
+ description:
276
+ 'Architecture layer of the target file (e.g. DomainModel). If omitted, ' +
277
+ 'inferred from filePath via ark.config.json layer patterns.',
278
+ },
279
+ filePath: {
280
+ type: 'string',
281
+ description: 'Target file path (used to infer layer and for messages).',
282
+ },
283
+ },
284
+ required: ['source'],
285
+ },
286
+ },
287
+ ];
288
+
289
+ const RESOURCES = [
290
+ {
291
+ uri: 'ark://manifest',
292
+ name: 'Ark architectural contract',
293
+ description:
294
+ 'The architecture agents must obey before generating code: layers and layer rules ' +
295
+ '(plus the full project manifest when --manifest is provided).',
296
+ mimeType: 'application/json',
297
+ },
298
+ ];
299
+
300
+ function manifestText() {
301
+ if (projectManifest) {
302
+ return JSON.stringify(
303
+ { ...projectManifest, source: projectManifest.source ?? 'manifest' },
304
+ null,
305
+ 2
306
+ );
307
+ }
308
+ return JSON.stringify(
309
+ {
310
+ source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
311
+ name: profile.name,
312
+ layers: profile.layers,
313
+ rules: profile.rules,
314
+ },
315
+ null,
316
+ 2
317
+ );
318
+ }
319
+
320
+ function runValidate(params) {
321
+ const source = params?.arguments?.source;
322
+ if (typeof source !== 'string') {
323
+ return { content: [{ type: 'text', text: 'Missing required "source" argument.' }], isError: true };
324
+ }
325
+ const layer = params.arguments.layer ?? inferLayer(params.arguments.filePath, config, args.root);
326
+ const result = gate.validate(source, {
327
+ layer,
328
+ filePath: params.arguments.filePath,
329
+ });
330
+ return {
331
+ content: [{ type: 'text', text: JSON.stringify({ ...result, layer }, null, 2) }],
332
+ isError: !result.valid,
333
+ };
334
+ }
335
+
336
+ const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
337
+ const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
338
+ const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
339
+
340
+ function handle(msg) {
341
+ const { id, method, params } = msg;
342
+
343
+ // Notifications carry no id and MUST never receive a response (JSON-RPC 2.0).
344
+ // The only notification we care about is notifications/initialized (a no-op here).
345
+ if (!('id' in msg)) return;
346
+
347
+ switch (method) {
348
+ case 'initialize':
349
+ reply(id, {
350
+ protocolVersion: params?.protocolVersion ?? DEFAULT_PROTOCOL,
351
+ capabilities: { tools: {}, resources: {} },
352
+ serverInfo: SERVER_INFO,
353
+ });
354
+ return;
355
+ case 'ping':
356
+ reply(id, {});
357
+ return;
358
+ case 'tools/list':
359
+ reply(id, { tools: TOOLS });
360
+ return;
361
+ case 'tools/call':
362
+ if (params?.name !== 'validate_code') {
363
+ fail(id, -32602, `Unknown tool: ${params?.name}`);
364
+ return;
365
+ }
366
+ reply(id, runValidate(params));
367
+ return;
368
+ case 'resources/list':
369
+ reply(id, { resources: RESOURCES });
370
+ return;
371
+ case 'resources/read':
372
+ if (params?.uri !== 'ark://manifest') {
373
+ fail(id, -32602, `Unknown resource: ${params?.uri}`);
374
+ return;
375
+ }
376
+ reply(id, {
377
+ contents: [{ uri: 'ark://manifest', mimeType: 'application/json', text: manifestText() }],
378
+ });
379
+ return;
380
+ default:
381
+ fail(id, -32601, `Method not found: ${method}`);
382
+ }
383
+ }
384
+
385
+ const rl = readline.createInterface({ input: process.stdin });
386
+ rl.on('line', (line) => {
387
+ const trimmed = line.trim();
388
+ if (!trimmed) return;
389
+ let msg;
390
+ try {
391
+ msg = JSON.parse(trimmed);
392
+ } catch {
393
+ fail(null, -32700, 'Parse error');
394
+ return;
395
+ }
396
+ try {
397
+ handle(msg);
398
+ } catch (err) {
399
+ fail(msg?.id ?? null, -32603, err instanceof Error ? err.message : String(err));
400
+ }
401
+ });
402
+ }
403
+
404
+ main().catch((err) => {
405
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
406
+ process.exitCode = 1;
407
+ });
@@ -0,0 +1,219 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Default layer rule matrix + intent-prefix map, shared by both CLIs and by the ark-mcp
5
+ * write-path gate so they enforce identically. These mirror the elevenLayerProfile in
6
+ * src/kernel/layers/ArchitectureProfile.ts; kept here (not imported from dist) because the
7
+ * CLIs run standalone with only `typescript` present, no build step.
8
+ */
9
+ export const DEFAULT_INTENT_PREFIXES = [
10
+ { layer: 'DomainModel', prefixes: ['Domain.'] },
11
+ { layer: 'ApplicationOrchestration', prefixes: ['Application.'] },
12
+ { layer: 'PersistenceAdapters', prefixes: ['Adapter.Persistence.', 'Adapter.Repository.'] },
13
+ { layer: 'IntegrationAdapters', prefixes: ['Adapter.Integration.', 'Adapter.External.'] },
14
+ { layer: 'WorkflowSagaEngine', prefixes: ['Workflow.'] },
15
+ { layer: 'BackgroundJobsScheduling', prefixes: ['Job.'] },
16
+ { layer: 'PresentationAdapters', prefixes: ['Presentation.', 'Adapter.Presentation.', 'Adapter.Api.'] },
17
+ { layer: 'ReportingReadModels', prefixes: ['Reporting.'] },
18
+ { layer: 'ExtensibilityMetadata', prefixes: ['Metadata.'] },
19
+ { layer: 'SecurityAuditObservability', prefixes: ['Security.', 'Audit.', 'Observability.'] },
20
+ { layer: 'Kernel', prefixes: ['Kernel.'] },
21
+ ];
22
+
23
+ export const DEFAULT_LAYER_DIRECTORIES = {
24
+ DomainModel: ['domain'],
25
+ ApplicationOrchestration: ['application', 'app'],
26
+ PersistenceAdapters: [
27
+ 'adapters/persistence',
28
+ 'adapters/repository',
29
+ 'repositories',
30
+ 'infra/persistence',
31
+ ],
32
+ IntegrationAdapters: ['adapters/integration', 'adapters/external', 'integrations'],
33
+ WorkflowSagaEngine: ['workflows', 'sagas'],
34
+ BackgroundJobsScheduling: ['jobs', 'schedules'],
35
+ PresentationAdapters: ['presentation', 'adapters/presentation', 'adapters/api'],
36
+ ReportingReadModels: ['reporting', 'read-models', 'projections'],
37
+ ExtensibilityMetadata: ['metadata', 'extensions'],
38
+ SecurityAuditObservability: ['security', 'audit', 'observability'],
39
+ Kernel: ['kernel'],
40
+ };
41
+
42
+ const DEFAULT_ALLOWED_FLOWS = [
43
+ { from: 'PresentationAdapters', to: 'ApplicationOrchestration' },
44
+ { from: 'ApplicationOrchestration', to: 'DomainModel' },
45
+ { from: 'WorkflowSagaEngine', to: 'ApplicationOrchestration' },
46
+ { from: 'WorkflowSagaEngine', to: 'DomainModel' },
47
+ { from: 'BackgroundJobsScheduling', to: 'ApplicationOrchestration' },
48
+ ];
49
+
50
+ function flowKey(from, to) {
51
+ return `${from}->${to}`;
52
+ }
53
+
54
+ function createStrictDenyRules(layers, allowedFlows) {
55
+ const allowed = new Set(allowedFlows.map((flow) => flowKey(flow.from, flow.to)));
56
+ const rules = [];
57
+ for (const from of layers) {
58
+ for (const to of layers) {
59
+ if (from.layer === to.layer) continue;
60
+ if (allowed.has(flowKey(from.layer, to.layer))) continue;
61
+ rules.push({ from: from.layer, to: to.layer, allowed: false });
62
+ }
63
+ }
64
+ return rules;
65
+ }
66
+
67
+ export const DEFAULT_RULES = createStrictDenyRules(
68
+ DEFAULT_INTENT_PREFIXES,
69
+ DEFAULT_ALLOWED_FLOWS
70
+ );
71
+
72
+ export function createElevenLayerConfig(options = {}) {
73
+ const rootDir = options.rootDir ?? 'src';
74
+ const optional = options.optionalLayers ?? true;
75
+ return {
76
+ include: options.include ?? [rootDir],
77
+ layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
78
+ name: entry.layer,
79
+ patterns: (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [entry.layer]).map(
80
+ (directory) => `${rootDir}/${directory}/**`
81
+ ),
82
+ intentPrefixes: entry.prefixes,
83
+ optional,
84
+ })),
85
+ rules: DEFAULT_RULES,
86
+ };
87
+ }
88
+
89
+ const _regexpCache = new Map();
90
+
91
+ function escapeLiteral(ch) {
92
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
93
+ }
94
+
95
+ /** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
96
+ function bracesBalanced(glob) {
97
+ let depth = 0;
98
+ for (let i = 0; i < glob.length; i += 1) {
99
+ const c = glob[i];
100
+ if (c === '\\') {
101
+ i += 1; // skip the escaped character
102
+ continue;
103
+ }
104
+ if (c === '{') depth += 1;
105
+ else if (c === '}') {
106
+ depth -= 1;
107
+ if (depth < 0) return false;
108
+ }
109
+ }
110
+ return depth === 0;
111
+ }
112
+
113
+ /**
114
+ * Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
115
+ * pattern, then cached).
116
+ *
117
+ * IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
118
+ * (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
119
+ * because the second step re-matches the star inside the substitution the first step just
120
+ * inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
121
+ * every file in a subdirectory. Scanning one character at a time also lets us support
122
+ * brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
123
+ *
124
+ * Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
125
+ * typo) is treated as a literal so the gate never crashes on `new RegExp`.
126
+ */
127
+ export function globToRegExp(pattern) {
128
+ const cached = _regexpCache.get(pattern);
129
+ if (cached) return cached;
130
+
131
+ const glob = pattern.split(path.sep).join('/');
132
+ const useBraces = bracesBalanced(glob);
133
+ let out = '';
134
+ let braceDepth = 0;
135
+ for (let i = 0; i < glob.length; i += 1) {
136
+ const c = glob[i];
137
+ if (c === '\\' && i + 1 < glob.length) {
138
+ out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
139
+ i += 1;
140
+ } else if (c === '*') {
141
+ if (glob[i + 1] === '*') {
142
+ if (glob[i + 2] === '/') {
143
+ out += '(?:.*/)?'; // `**/` matches zero or more path segments
144
+ i += 2;
145
+ } else {
146
+ out += '.*'; // `**` matches across `/`
147
+ i += 1;
148
+ }
149
+ } else {
150
+ out += '[^/]*'; // `*` matches within a single segment
151
+ }
152
+ } else if (c === '?') {
153
+ out += '[^/]';
154
+ } else if (c === '{' && useBraces) {
155
+ out += '(?:';
156
+ braceDepth += 1;
157
+ } else if (c === '}' && useBraces && braceDepth > 0) {
158
+ out += ')';
159
+ braceDepth -= 1;
160
+ } else if (c === ',' && useBraces && braceDepth > 0) {
161
+ out += '|';
162
+ } else {
163
+ out += escapeLiteral(c);
164
+ }
165
+ }
166
+ const re = new RegExp(`^${out}$`);
167
+ _regexpCache.set(pattern, re);
168
+ return re;
169
+ }
170
+
171
+ /** Resolve a file's architecture layer from ark.config.json layer glob patterns. */
172
+ export function layerForFile(root, file, layers) {
173
+ const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
174
+ const rel = path.relative(root, abs).split(path.sep).join('/');
175
+ for (const layer of layers ?? []) {
176
+ for (const pattern of layer.patterns ?? []) {
177
+ if (globToRegExp(pattern).test(rel)) return layer.name;
178
+ }
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function normalizePrefix(prefix) {
184
+ return prefix.endsWith('.') ? prefix : `${prefix}.`;
185
+ }
186
+
187
+ /**
188
+ * Resolve an intent name to its layer using the SAME semantics as
189
+ * ArchitectureProfile.resolveLayer in src/kernel/layers/ArchitectureProfile.ts (which the
190
+ * ark-mcp write-gate uses via createArchitectureProfile): every prefix is normalized to a
191
+ * trailing '.', and the layer whose matching prefix is longest wins — regardless of config
192
+ * declaration order. Keeping ark-check on these exact rules is what makes the CI gate and
193
+ * the write-path gate classify identically. `layers` is an array of { name, prefixes }.
194
+ */
195
+ export function resolveIntentLayer(intent, layers) {
196
+ const normalized = layers.map((layer) => ({
197
+ name: layer.name,
198
+ prefixes: (layer.prefixes ?? []).map(normalizePrefix),
199
+ }));
200
+ const sorted = [...normalized].sort((a, b) => {
201
+ const maxA = Math.max(0, ...a.prefixes.map((p) => p.length));
202
+ const maxB = Math.max(0, ...b.prefixes.map((p) => p.length));
203
+ return maxB - maxA;
204
+ });
205
+ return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
206
+ }
207
+
208
+ /**
209
+ * Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
210
+ * src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
211
+ * CLIs run standalone (with only `typescript` present, no build), so they must not
212
+ * import from the compiled library. Update both if the layer prefixes change.
213
+ */
214
+ const INTENT_NAME =
215
+ /^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
216
+
217
+ export function looksLikeIntent(value) {
218
+ return INTENT_NAME.test(value);
219
+ }