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.
- package/CHANGELOG.md +1249 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/SECURITY.md +39 -0
- package/bin/ark-check.mjs +5204 -0
- package/bin/ark-mcp.mjs +898 -0
- package/bin/ark-shared.mjs +1520 -0
- package/bin/ark.mjs +491 -0
- package/dist/eslint/index.cjs +222 -0
- package/dist/eslint/index.cjs.map +1 -0
- package/dist/eslint/index.d.cts +42 -0
- package/dist/eslint/index.d.ts +40 -0
- package/dist/eslint/index.js +193 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/index.cjs +3080 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +577 -0
- package/dist/index.d.ts +577 -0
- package/dist/index.js +2998 -0
- package/dist/index.js.map +1 -0
- package/dist/nestjs/index.cjs +2332 -0
- package/dist/nestjs/index.cjs.map +1 -0
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +22 -0
- package/dist/nestjs/index.js +2308 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/types-DpdVN7Lm.d.cts +1023 -0
- package/dist/types-DpdVN7Lm.d.ts +1023 -0
- package/docs/agent-guide.md +490 -0
- package/docs/ai-gates.md +337 -0
- package/docs/ark-check-example.json +87 -0
- package/docs/assets/ark-write-gate.svg +28 -0
- package/docs/brownfield-adoption.md +87 -0
- package/docs/demos/01-write-gate-self-correction.md +74 -0
- package/docs/demos/02-brownfield-baseline-adoption.md +71 -0
- package/docs/demos/03-copilot-autopilot.md +83 -0
- package/docs/enthusiast/README.md +62 -0
- package/docs/enthusiast/explanation-application-shape.md +29 -0
- package/docs/enthusiast/how-to-agent-gates.md +36 -0
- package/docs/enthusiast/how-to-gallery-starter.md +27 -0
- package/docs/enthusiast/how-to-pick-shape.md +45 -0
- package/docs/enthusiast/how-to-policy-pack.md +37 -0
- package/docs/enthusiast/reference-archetypes.md +36 -0
- package/docs/enthusiast/reference-commands.md +50 -0
- package/docs/enthusiast/tutorial-first-project.md +86 -0
- package/docs/production-hardening.md +59 -0
- package/package.json +125 -0
- package/server.json +39 -0
- package/templates/architecture-playbook.json +339 -0
- package/templates/policy-packs/enthusiast-feature-sliced.json +20 -0
- package/templates/policy-packs/enthusiast-hexagonal.json +18 -0
- package/templates/policy-packs/enthusiast-layered.json +18 -0
- package/templates/policy-packs/enthusiast-monorepo.json +18 -0
- package/templates/skills/ark-adopt.md +103 -0
- package/templates/skills/ark-architect.md +90 -0
- package/templates/skills/ark-autopilot.md +95 -0
- package/templates/skills/ark-contract.md +98 -0
- package/templates/skills/ark-coverage.md +96 -0
- package/templates/skills/ark-explain.md +78 -0
- package/templates/skills/ark-fix.md +96 -0
- package/templates/skills/ark-loop.md +69 -0
- package/templates/skills/ark-place.md +68 -0
- package/templates/skills/ark-runtime.md +62 -0
- package/templates/skills/ark-upgrade.md +109 -0
package/bin/ark-mcp.mjs
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
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
|
+
* - tool ark_recommend — deterministic application-shape plan (same as
|
|
17
|
+
* ark-check --recommend --json)
|
|
18
|
+
*
|
|
19
|
+
* Usage: ark-mcp [--root <dir>] [--config ark.config.json] [--manifest <manifest.json>]
|
|
20
|
+
* ark-mcp --hook [--root <dir>] [--config ark.config.json]
|
|
21
|
+
*
|
|
22
|
+
* --hook runs one-shot instead of serving: it reads a Claude Code PreToolUse payload from
|
|
23
|
+
* stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
|
|
24
|
+
* 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
|
|
25
|
+
* the copy-paste integration for agent runtimes whose hooks run shell commands.
|
|
26
|
+
*
|
|
27
|
+
* --session-context runs one-shot and prints a compact contract summary (layers, rule
|
|
28
|
+
* count, forbidden globals, baseline state, check command) to stdout. Bind it to a
|
|
29
|
+
* SessionStart hook so the agent has the architecture in context from the first token,
|
|
30
|
+
* instead of learning it by rejection.
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import readline from 'node:readline';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_INTENT_PREFIXES,
|
|
39
|
+
DEFAULT_LAYER_DIRECTORIES,
|
|
40
|
+
DEFAULT_RULES,
|
|
41
|
+
arkCommand,
|
|
42
|
+
layerForFile,
|
|
43
|
+
shouldShowNewHereNudge,
|
|
44
|
+
} from './ark-shared.mjs';
|
|
45
|
+
|
|
46
|
+
const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const args = {
|
|
50
|
+
root: process.cwd(),
|
|
51
|
+
config: 'ark.config.json',
|
|
52
|
+
configExplicit: false,
|
|
53
|
+
manifest: undefined,
|
|
54
|
+
hook: false,
|
|
55
|
+
sessionContext: false,
|
|
56
|
+
};
|
|
57
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === '--hook') args.hook = true;
|
|
60
|
+
else if (a === '--session-context') args.sessionContext = true;
|
|
61
|
+
else if (a === '--root') args.root = path.resolve(argv[++i]);
|
|
62
|
+
else if (a === '--config') {
|
|
63
|
+
args.config = argv[++i];
|
|
64
|
+
args.configExplicit = true;
|
|
65
|
+
} else if (a === '--manifest') args.manifest = argv[++i];
|
|
66
|
+
}
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read a JSON file. Missing files return undefined unless `required` (so the caller can
|
|
72
|
+
* fall back), but malformed JSON always throws — silently swallowing a syntax error would
|
|
73
|
+
* turn the layer gate into a no-op that reports every write as valid.
|
|
74
|
+
*/
|
|
75
|
+
function readJson(file, { required } = {}) {
|
|
76
|
+
if (!fs.existsSync(file)) {
|
|
77
|
+
if (required) throw new Error(`File not found: ${file}`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveInRoot(root, maybePath) {
|
|
88
|
+
if (!maybePath) return undefined;
|
|
89
|
+
return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function inferLayer(filePath, config, root) {
|
|
93
|
+
if (!filePath) return undefined;
|
|
94
|
+
return layerForFile(root, filePath, config.layers);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read tsconfig path aliases via the TypeScript config parser (so JSONC + `extends` work).
|
|
98
|
+
// Returns { baseUrl (absolute), aliases: [{ from, to }] } — `from` a prefix like "@/", `to`
|
|
99
|
+
// its base like "./src/". Empty when typescript/tsconfig is unavailable; callers then resolve
|
|
100
|
+
// only relative imports (aliased ones fall through to the infra heuristic — no worse than before).
|
|
101
|
+
function readTsconfigAliases(ts, root) {
|
|
102
|
+
if (!ts) return { baseUrl: root, aliases: [] };
|
|
103
|
+
try {
|
|
104
|
+
const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
|
|
105
|
+
if (!configPath) return { baseUrl: root, aliases: [] };
|
|
106
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
107
|
+
if (read.error) return { baseUrl: root, aliases: [] };
|
|
108
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
109
|
+
const opts = parsed.options || {};
|
|
110
|
+
const baseUrl = opts.baseUrl || path.dirname(configPath);
|
|
111
|
+
const aliases = [];
|
|
112
|
+
for (const [pattern, targets] of Object.entries(opts.paths || {})) {
|
|
113
|
+
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
114
|
+
aliases.push({ from: pattern.replace(/\*$/, ''), to: String(targets[0]).replace(/\*$/, '') });
|
|
115
|
+
}
|
|
116
|
+
aliases.sort((a, b) => b.from.length - a.from.length); // longest prefix wins
|
|
117
|
+
return { baseUrl, aliases };
|
|
118
|
+
} catch {
|
|
119
|
+
return { baseUrl: root, aliases: [] };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Resolve an import specifier to a repo-relative target path (directory-level — enough to
|
|
124
|
+
// classify the layer). Relative and tsconfig-aliased imports resolve; bare packages return
|
|
125
|
+
// undefined (ungoverned → the gate's infra heuristic decides those).
|
|
126
|
+
function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
|
|
127
|
+
let abs;
|
|
128
|
+
if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
|
129
|
+
if (!fromFilePath) return undefined;
|
|
130
|
+
abs = path.resolve(path.dirname(path.resolve(root, fromFilePath)), specifier);
|
|
131
|
+
} else {
|
|
132
|
+
const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
|
|
133
|
+
if (!alias) return undefined; // bare package specifier
|
|
134
|
+
abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
|
|
135
|
+
}
|
|
136
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
137
|
+
return rel.startsWith('..') ? undefined : rel; // outside the project root
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build the resolveImportLayer callback for the AI write gate: specifier → target layer, so
|
|
141
|
+
// the gate lets ark.config.json layer RULES govern a resolvable edge instead of the infra
|
|
142
|
+
// path-heuristic. A barrel/dir import (`@/lib/db`) is classified like a file under it.
|
|
143
|
+
function createImportLayerResolver(ts, root, config) {
|
|
144
|
+
const layers = config.layers ?? [];
|
|
145
|
+
if (layers.length === 0) return undefined;
|
|
146
|
+
const tsAliases = readTsconfigAliases(ts, root);
|
|
147
|
+
return (specifier, fromFilePath) => {
|
|
148
|
+
const rel = resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases);
|
|
149
|
+
if (!rel) return undefined;
|
|
150
|
+
// When the target is a real directory (a barrel import like `@/lib/db`), classify a file
|
|
151
|
+
// INSIDE it so it resolves to the `src/lib/db/**` layer, not a broader `src/lib/**` that
|
|
152
|
+
// the bare path would also match. Falls back to the same probe when the target isn't on
|
|
153
|
+
// disk (e.g. a file referenced without its extension).
|
|
154
|
+
let probe = rel;
|
|
155
|
+
try {
|
|
156
|
+
if (fs.statSync(path.join(root, rel)).isDirectory()) probe = `${rel}/index.ts`;
|
|
157
|
+
} catch {
|
|
158
|
+
/* not on disk — classify the path as-is, with the dir-style fallback below */
|
|
159
|
+
}
|
|
160
|
+
return layerForFile(root, probe, layers) || layerForFile(root, `${rel}/index.ts`, layers);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function loadArk() {
|
|
165
|
+
const url = new URL('../dist/index.js', import.meta.url);
|
|
166
|
+
if (!fs.existsSync(url)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'ark-mcp requires the built library at dist/index.js. Run "npm run build" first.'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return await import(url.href);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`ark-mcp failed to load dist/index.js (rebuild with "npm run build"): ${
|
|
176
|
+
err instanceof Error ? err.message : String(err)
|
|
177
|
+
}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function loadOptionalTypeScript() {
|
|
183
|
+
try {
|
|
184
|
+
return await import('typescript');
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Normalize agent PreToolUse payloads.
|
|
194
|
+
* Claude Code: { tool_name, tool_input: { file_path, content | old_string/new_string } }
|
|
195
|
+
* Grok Build: { toolName, toolInput: { file_path, content | old_string/new_string } }
|
|
196
|
+
* (aliases Write/Edit/MultiEdit → write/search_replace; matcher keeps both)
|
|
197
|
+
*/
|
|
198
|
+
function normalizeHookPayload(payload) {
|
|
199
|
+
const rawName = payload?.tool_name ?? payload?.toolName ?? '';
|
|
200
|
+
const toolInput = payload?.tool_input ?? payload?.toolInput ?? {};
|
|
201
|
+
const nameMap = {
|
|
202
|
+
Write: 'Write',
|
|
203
|
+
write: 'Write',
|
|
204
|
+
Edit: 'Edit',
|
|
205
|
+
search_replace: 'Edit',
|
|
206
|
+
MultiEdit: 'MultiEdit',
|
|
207
|
+
};
|
|
208
|
+
const toolName = nameMap[rawName] ?? rawName;
|
|
209
|
+
const filePath =
|
|
210
|
+
toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? toolInput.target_file;
|
|
211
|
+
return {
|
|
212
|
+
toolName,
|
|
213
|
+
toolInput: { ...toolInput, file_path: filePath },
|
|
214
|
+
// Grok-style camelCase (or GROK_HOOK_EVENT) → also emit deny JSON on stdout.
|
|
215
|
+
grokStyle:
|
|
216
|
+
Boolean(process.env.GROK_HOOK_EVENT) ||
|
|
217
|
+
(payload != null && typeof payload === 'object' && 'toolName' in payload),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
|
|
223
|
+
* to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
|
|
224
|
+
* snippet out of context. Replacement uses a function argument so `$&`-style sequences in
|
|
225
|
+
* generated code are inserted literally, never interpreted as replacement patterns.
|
|
226
|
+
*/
|
|
227
|
+
function proposedSource(toolName, toolInput) {
|
|
228
|
+
if (toolName === 'Write') return toolInput.content;
|
|
229
|
+
|
|
230
|
+
let text = '';
|
|
231
|
+
try {
|
|
232
|
+
text = fs.readFileSync(toolInput.file_path, 'utf8');
|
|
233
|
+
} catch {
|
|
234
|
+
// New file created via Edit: fall through with an empty base.
|
|
235
|
+
}
|
|
236
|
+
const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
|
|
237
|
+
for (const edit of edits) {
|
|
238
|
+
const from = edit.old_string ?? '';
|
|
239
|
+
const to = edit.new_string ?? '';
|
|
240
|
+
if (from === '') {
|
|
241
|
+
text = to;
|
|
242
|
+
} else if (edit.replace_all) {
|
|
243
|
+
text = text.split(from).join(to);
|
|
244
|
+
} else {
|
|
245
|
+
text = text.replace(from, () => to);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return text;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One-shot PreToolUse gate (Claude Code + Grok Build hook contracts): payload on stdin,
|
|
253
|
+
* exit 2 + violations on stderr to block, exit 0 to allow. Grok also receives a deny
|
|
254
|
+
* decision JSON on stdout. Gate plumbing problems (no stdin, malformed JSON, non-file
|
|
255
|
+
* tools, non-source files) never block the agent.
|
|
256
|
+
*/
|
|
257
|
+
function runHook(gate, config, args) {
|
|
258
|
+
let payload;
|
|
259
|
+
try {
|
|
260
|
+
payload = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { toolName, toolInput, grokStyle } = normalizeHookPayload(payload);
|
|
266
|
+
const filePath = toolInput.file_path;
|
|
267
|
+
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return;
|
|
268
|
+
if (typeof filePath !== 'string' || !SOURCE_FILE.test(filePath) || filePath.endsWith('.d.ts')) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const rel = path.relative(args.root, path.resolve(filePath));
|
|
272
|
+
const segments = rel.split(path.sep);
|
|
273
|
+
if (segments[0] === '..' || segments.includes('node_modules')) return;
|
|
274
|
+
|
|
275
|
+
const source = proposedSource(toolName, toolInput);
|
|
276
|
+
if (typeof source !== 'string') return;
|
|
277
|
+
|
|
278
|
+
const layer = inferLayer(filePath, config, args.root);
|
|
279
|
+
const result = gate.validate(source, { layer, filePath });
|
|
280
|
+
if (result.valid) return;
|
|
281
|
+
|
|
282
|
+
// Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
|
|
283
|
+
// when it ADDS violations relative to the file's current on-disk state. Otherwise a
|
|
284
|
+
// pre-existing violation — frozen in a baseline or predating Ark adoption — would make
|
|
285
|
+
// every subsequent edit to that file un-writable while CI passes. Keys ignore line
|
|
286
|
+
// numbers (edits shift them) and collapse duplicates, mirroring ark-check's baselineKey.
|
|
287
|
+
const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
|
|
288
|
+
let existingKeys = new Set();
|
|
289
|
+
try {
|
|
290
|
+
const current = fs.readFileSync(filePath, 'utf8');
|
|
291
|
+
existingKeys = new Set(
|
|
292
|
+
gate.validate(current, { layer, filePath }).violations.map(violationKey)
|
|
293
|
+
);
|
|
294
|
+
} catch {
|
|
295
|
+
// New file: nothing pre-exists, every violation is new.
|
|
296
|
+
}
|
|
297
|
+
const newViolations = result.violations.filter(
|
|
298
|
+
(violation) => !existingKeys.has(violationKey(violation))
|
|
299
|
+
);
|
|
300
|
+
if (newViolations.length === 0) return;
|
|
301
|
+
|
|
302
|
+
const lines = newViolations.map(
|
|
303
|
+
(violation) =>
|
|
304
|
+
`- [${violation.ruleId}] ${violation.message}${violation.line ? ` (line ${violation.line})` : ''}`
|
|
305
|
+
);
|
|
306
|
+
// Surface the per-violation fix hints (the gate carries them in `suggestion`,
|
|
307
|
+
// but the hook was dropping them). Dedupe so two infra violations sharing one
|
|
308
|
+
// hint — e.g. the mayImportInfrastructure escape hatch — print it once.
|
|
309
|
+
const suggestions = [
|
|
310
|
+
...new Set(newViolations.map((violation) => violation.suggestion).filter(Boolean)),
|
|
311
|
+
];
|
|
312
|
+
const message = [
|
|
313
|
+
`Ark architecture gate blocked this write to ${rel}${layer ? ` (layer: ${layer})` : ''}:`,
|
|
314
|
+
...lines,
|
|
315
|
+
...(suggestions.length > 0 ? ['Fix:', ...suggestions.map((s) => ` ${s}`)] : []),
|
|
316
|
+
'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
|
|
317
|
+
].join('\n');
|
|
318
|
+
process.stderr.write(message + '\n');
|
|
319
|
+
// Grok Build honors { decision: "deny" } on stdout (exit 2 alone is also deny).
|
|
320
|
+
if (grokStyle) {
|
|
321
|
+
process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\n');
|
|
322
|
+
}
|
|
323
|
+
process.exitCode = 2;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function runArkCheckJsonFromRoot(root, config, extraArgs, manifest) {
|
|
327
|
+
const manifestArgs = manifest ? ['--manifest', manifest] : [];
|
|
328
|
+
const result = spawnSync(
|
|
329
|
+
process.execPath,
|
|
330
|
+
[arkCheckBin, '--root', root, '--config', config, ...manifestArgs, '--json', ...extraArgs],
|
|
331
|
+
{ encoding: 'utf8' }
|
|
332
|
+
);
|
|
333
|
+
const stdout = result.stdout ?? '';
|
|
334
|
+
try {
|
|
335
|
+
return { data: JSON.parse(stdout), raw: stdout };
|
|
336
|
+
} catch {
|
|
337
|
+
return { data: null, raw: stdout || result.stderr || 'ark-check produced no output' };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* One-shot SessionStart context: a compact summary of the contract on stdout so the
|
|
343
|
+
* agent starts the session already knowing the architecture. Advisory — never blocks
|
|
344
|
+
* and never exits non-zero for missing optional inputs (e.g. no baseline file).
|
|
345
|
+
*/
|
|
346
|
+
function printSessionContext(config, profile, forbiddenGlobals, args, configPath) {
|
|
347
|
+
const lines = ['Ark architecture contract governs this project (ark.config.json is authoritative).'];
|
|
348
|
+
|
|
349
|
+
const configLayers = Array.isArray(config.layers) ? config.layers : [];
|
|
350
|
+
if (configLayers.length > 0) {
|
|
351
|
+
lines.push('Layers:');
|
|
352
|
+
for (const layer of configLayers) {
|
|
353
|
+
const globals = forbiddenGlobals[layer.name];
|
|
354
|
+
const globalsNote = globals ? ` — forbidden globals: ${globals.join(', ')}` : '';
|
|
355
|
+
lines.push(` - ${layer.name}: ${(layer.patterns ?? []).join(', ')}${globalsNote}`);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(
|
|
359
|
+
`Layers: none configured — the default 11-layer profile applies to intent references.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const denied = (profile.rules ?? []).filter((rule) => !rule.allowed).length;
|
|
364
|
+
lines.push(
|
|
365
|
+
`Rules: ${denied} denied layer edge(s). Full contract: ark://manifest MCP resource.`
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Advisory output: a malformed baseline must not abort the summary.
|
|
369
|
+
let baseline;
|
|
370
|
+
try {
|
|
371
|
+
baseline = readJson(path.join(args.root, '.ark-baseline.json'));
|
|
372
|
+
} catch {
|
|
373
|
+
baseline = undefined;
|
|
374
|
+
}
|
|
375
|
+
if (Array.isArray(baseline?.violations)) {
|
|
376
|
+
lines.push(
|
|
377
|
+
`Baseline: ${baseline.violations.length} frozen violation(s) — only NEW violations fail; do not add to them.`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
lines.push(
|
|
382
|
+
`After edits run: ${arkCommand(args.root, 'ark-check', '--root . --config ark.config.json --strict-config')}`
|
|
383
|
+
);
|
|
384
|
+
lines.push('If Ark reports violations, fix the architecture instead of weakening the gate.');
|
|
385
|
+
|
|
386
|
+
const { data: coverage } = runArkCheckJsonFromRoot(args.root, args.config, ['--coverage'], undefined);
|
|
387
|
+
const governedPercent = coverage?.coverage?.governed?.percent ?? coverage?.governed?.percent;
|
|
388
|
+
if (shouldShowNewHereNudge(args.root, configPath, governedPercent, false)) {
|
|
389
|
+
lines.push('');
|
|
390
|
+
lines.push('New to Ark? Run /ark-architect or: ark-check --recommend');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function main() {
|
|
397
|
+
const args = parseArgs(process.argv);
|
|
398
|
+
const configPath = resolveInRoot(args.root, args.config);
|
|
399
|
+
|
|
400
|
+
// SessionStart contract injection is only meaningful in Ark-governed projects. Bail
|
|
401
|
+
// out silently (before loading dist) when there is no config, so the hook is safe
|
|
402
|
+
// even if a user installs it in their GLOBAL settings instead of per-project.
|
|
403
|
+
if (args.sessionContext && !(configPath && fs.existsSync(configPath))) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const ark = await loadArk();
|
|
408
|
+
const ts = await loadOptionalTypeScript();
|
|
409
|
+
|
|
410
|
+
const config =
|
|
411
|
+
(configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
|
|
412
|
+
include: ['src'],
|
|
413
|
+
layers: [],
|
|
414
|
+
rules: [],
|
|
415
|
+
};
|
|
416
|
+
if (!config.layers || config.layers.length === 0) {
|
|
417
|
+
process.stderr.write(
|
|
418
|
+
'[ark-mcp] warning: no layers configured — file→layer inference from config patterns ' +
|
|
419
|
+
'is unavailable, so layer-reference checks run only when the caller passes an explicit ' +
|
|
420
|
+
'"layer" (checked against the default 11-layer profile).\n'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const manifestPath = resolveInRoot(args.root, args.manifest);
|
|
425
|
+
const projectManifest = manifestPath ? readJson(manifestPath, { required: true }) : undefined;
|
|
426
|
+
|
|
427
|
+
const intents = Array.isArray(projectManifest?.intents)
|
|
428
|
+
? projectManifest.intents.map((i) => (typeof i === 'string' ? i : i?.name)).filter(Boolean)
|
|
429
|
+
: [];
|
|
430
|
+
|
|
431
|
+
// Build the enforcement profile with the SAME semantics ark-check (CI) applies to the
|
|
432
|
+
// config, so the write-path gate and CI can't disagree:
|
|
433
|
+
// - rules: config.rules ?? DEFAULT_RULES (ark-check readConfig substitutes DEFAULT_RULES)
|
|
434
|
+
// - intent prefixes: the config layers that declare intentPrefixes; when none do, fall
|
|
435
|
+
// back to DEFAULT_INTENT_PREFIXES (mirrors ark-check's layerForIntent fallback).
|
|
436
|
+
// Only layers WITH prefixes enter the profile, so no layer has empty prefixes (which would
|
|
437
|
+
// also make it unresolvable). A project with no layers at all gets the 11-layer default.
|
|
438
|
+
const configLayers = Array.isArray(config.layers) ? config.layers : [];
|
|
439
|
+
const manifestLayers = Array.isArray(projectManifest?.architecture?.layers)
|
|
440
|
+
? projectManifest.architecture.layers
|
|
441
|
+
: [];
|
|
442
|
+
const usedProjectConfig = configLayers.length > 0;
|
|
443
|
+
let profile;
|
|
444
|
+
if (manifestLayers.length > 0) {
|
|
445
|
+
profile = ark.createArchitectureProfile({
|
|
446
|
+
name: projectManifest.architecture.profile ?? 'manifest',
|
|
447
|
+
layers: manifestLayers.map((layer) => ({
|
|
448
|
+
name: layer.name,
|
|
449
|
+
prefixes: layer.prefixes,
|
|
450
|
+
})),
|
|
451
|
+
rules: projectManifest.architecture.rules ?? DEFAULT_RULES,
|
|
452
|
+
});
|
|
453
|
+
} else if (!usedProjectConfig) {
|
|
454
|
+
profile = ark.elevenLayerProfile;
|
|
455
|
+
} else {
|
|
456
|
+
const layersWithPrefixes = configLayers.filter(
|
|
457
|
+
(layer) => (layer.intentPrefixes ?? []).length > 0
|
|
458
|
+
);
|
|
459
|
+
const profileLayers =
|
|
460
|
+
layersWithPrefixes.length > 0
|
|
461
|
+
? layersWithPrefixes.map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }))
|
|
462
|
+
: DEFAULT_INTENT_PREFIXES.map((d) => ({ name: d.layer, prefixes: d.prefixes }));
|
|
463
|
+
profile = ark.createArchitectureProfile({
|
|
464
|
+
name: 'ark.config',
|
|
465
|
+
layers: profileLayers,
|
|
466
|
+
rules: config.rules ?? DEFAULT_RULES,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Layer → forbidden ambient globals, straight from ark.config.json. Enforced by the
|
|
471
|
+
// gate only when the target file's layer is known (same data ark-check enforces in CI).
|
|
472
|
+
const forbiddenGlobals = Object.fromEntries(
|
|
473
|
+
configLayers
|
|
474
|
+
.filter(
|
|
475
|
+
(layer) =>
|
|
476
|
+
layer.name &&
|
|
477
|
+
Array.isArray(layer.forbiddenGlobals) &&
|
|
478
|
+
layer.forbiddenGlobals.some((entry) => typeof entry === 'string')
|
|
479
|
+
)
|
|
480
|
+
.map((layer) => [
|
|
481
|
+
layer.name,
|
|
482
|
+
layer.forbiddenGlobals.filter((entry) => typeof entry === 'string'),
|
|
483
|
+
])
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Layers explicitly flagged as infrastructure in ark.config.json may import
|
|
487
|
+
// infrastructure — the built-in infra-import heuristics skip them (in addition
|
|
488
|
+
// to layers whose name conventionally signals an infra role). Lets a project
|
|
489
|
+
// with an unconventionally-named infra layer opt in without renaming.
|
|
490
|
+
const infrastructureLayers = configLayers
|
|
491
|
+
.filter((layer) => layer.name && layer.mayImportInfrastructure === true)
|
|
492
|
+
.map((layer) => layer.name);
|
|
493
|
+
|
|
494
|
+
const gate = ark.createAICodeGate({
|
|
495
|
+
architectureProfile: profile,
|
|
496
|
+
intents,
|
|
497
|
+
enforceIntentAllowlist: intents.length > 0,
|
|
498
|
+
typescript: ts,
|
|
499
|
+
forbiddenGlobals,
|
|
500
|
+
infrastructureLayers,
|
|
501
|
+
// Contract-first: a resolvable import edge is judged by the config's layer rules, not the
|
|
502
|
+
// infra path-heuristic — so the write gate agrees with ark-check (ark.config.json wins).
|
|
503
|
+
resolveImportLayer: createImportLayerResolver(ts, args.root, config),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (args.hook) {
|
|
507
|
+
runHook(gate, config, args);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (args.sessionContext) {
|
|
512
|
+
printSessionContext(config, profile, forbiddenGlobals, args, configPath);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const SERVER_INFO = { name: 'arkgate', version: ark.version };
|
|
517
|
+
const DEFAULT_PROTOCOL = '2024-11-05';
|
|
518
|
+
|
|
519
|
+
const TOOLS = [
|
|
520
|
+
{
|
|
521
|
+
name: 'validate_code',
|
|
522
|
+
description:
|
|
523
|
+
"Validate a source snippet about to be written against Ark's architecture " +
|
|
524
|
+
'(forbidden infra imports, unknown intents, and layer-reference violations). ' +
|
|
525
|
+
'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
|
|
526
|
+
'Returns { valid, violations }; isError is true when the code is invalid.',
|
|
527
|
+
inputSchema: {
|
|
528
|
+
type: 'object',
|
|
529
|
+
properties: {
|
|
530
|
+
source: { type: 'string', description: 'Full source text about to be written.' },
|
|
531
|
+
layer: {
|
|
532
|
+
type: 'string',
|
|
533
|
+
description:
|
|
534
|
+
'Architecture layer of the target file (e.g. DomainModel). If omitted, ' +
|
|
535
|
+
'inferred from filePath via ark.config.json layer patterns.',
|
|
536
|
+
},
|
|
537
|
+
filePath: {
|
|
538
|
+
type: 'string',
|
|
539
|
+
description: 'Target file path (used to infer layer and for messages).',
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
required: ['source'],
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: 'ark_check',
|
|
547
|
+
description:
|
|
548
|
+
'Run the full Ark architecture check on the project and return structured results ' +
|
|
549
|
+
'(layer-import violations, forbidden globals, circular deps, config warnings). Use ' +
|
|
550
|
+
'this to answer "is the architecture currently valid?" instead of shelling out to ' +
|
|
551
|
+
'ark-check. Applies the baseline automatically when one exists. isError when not ok.',
|
|
552
|
+
inputSchema: {
|
|
553
|
+
type: 'object',
|
|
554
|
+
properties: {
|
|
555
|
+
strict: {
|
|
556
|
+
type: 'boolean',
|
|
557
|
+
description: 'Fail on config warnings too (--strict-config). Default true.',
|
|
558
|
+
},
|
|
559
|
+
baseline: {
|
|
560
|
+
type: 'boolean',
|
|
561
|
+
description:
|
|
562
|
+
'Suppress pre-frozen violations via .ark-baseline.json. Default: auto (on when the file exists).',
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: 'ark_coverage',
|
|
569
|
+
description:
|
|
570
|
+
'Report what each layer actually governs: per-layer file counts, the FULL list of ' +
|
|
571
|
+
'unclassified (ungoverned) files, layers whose patterns match nothing, and layers ' +
|
|
572
|
+
'with no rule edge. Use this to audit config coverage instead of hand-rolling ' +
|
|
573
|
+
'find/readdir. Report only — never an error.',
|
|
574
|
+
inputSchema: { type: 'object', properties: {} },
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: 'ark_place',
|
|
578
|
+
description:
|
|
579
|
+
'Given a target file path, return which layer it belongs to, which layers it may and ' +
|
|
580
|
+
'must NOT import, and its forbidden globals — so generated code lands in a governed ' +
|
|
581
|
+
'location with the right dependencies. Call this BEFORE writing a new file.',
|
|
582
|
+
inputSchema: {
|
|
583
|
+
type: 'object',
|
|
584
|
+
properties: {
|
|
585
|
+
filePath: {
|
|
586
|
+
type: 'string',
|
|
587
|
+
description: 'Path (relative to project root or absolute) of the file to place.',
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
required: ['filePath'],
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: 'ark_recommend',
|
|
595
|
+
description:
|
|
596
|
+
'Score this repository against templates/architecture-playbook.json and return the ' +
|
|
597
|
+
'tool-agnostic application shape to adopt (archetype, preset, phased layer plan, ' +
|
|
598
|
+
'analogy, anti-patterns). Same structured output as ark-check --recommend --json. ' +
|
|
599
|
+
'Call BEFORE generating project structure on greenfield or early-adoption repos.',
|
|
600
|
+
inputSchema: { type: 'object', properties: {} },
|
|
601
|
+
},
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
const RESOURCES = [
|
|
605
|
+
{
|
|
606
|
+
uri: 'ark://manifest',
|
|
607
|
+
name: 'Ark architectural contract',
|
|
608
|
+
description:
|
|
609
|
+
'The architecture agents must obey before generating code: layers and layer rules ' +
|
|
610
|
+
'(plus the full project manifest when --manifest is provided).',
|
|
611
|
+
mimeType: 'application/json',
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
// Layers from the 11-layer profile that this project has NOT declared, with their
|
|
616
|
+
// conventional directories: tells the agent where a new kind of code (a saga, a job,
|
|
617
|
+
// a read model, ...) belongs BEFORE it improvises a location the gate can't govern.
|
|
618
|
+
// A default layer is dropped when the project already claims any of its intent
|
|
619
|
+
// prefixes under another name (e.g. a `core` layer owning `Domain.`) — suggesting
|
|
620
|
+
// DomainModel there would tell the agent to create a second layer for the same
|
|
621
|
+
// prefix, making longest-prefix resolution ambiguous.
|
|
622
|
+
function suggestedLayers() {
|
|
623
|
+
const activeNames = new Set(profile.layers.map((layer) => layer.name));
|
|
624
|
+
const claimedPrefixes = new Set(
|
|
625
|
+
profile.layers.flatMap((layer) =>
|
|
626
|
+
(layer.prefixes ?? []).map((p) => (p.endsWith('.') ? p : `${p}.`))
|
|
627
|
+
)
|
|
628
|
+
);
|
|
629
|
+
return DEFAULT_INTENT_PREFIXES.filter(
|
|
630
|
+
(entry) =>
|
|
631
|
+
!activeNames.has(entry.layer) &&
|
|
632
|
+
!entry.prefixes.some((p) => claimedPrefixes.has(p.endsWith('.') ? p : `${p}.`))
|
|
633
|
+
).map((entry) => ({
|
|
634
|
+
layer: entry.layer,
|
|
635
|
+
intentPrefixes: entry.prefixes,
|
|
636
|
+
conventionalDirectories: DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [],
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function manifestText() {
|
|
641
|
+
if (projectManifest) {
|
|
642
|
+
return JSON.stringify(
|
|
643
|
+
{ ...projectManifest, source: projectManifest.source ?? 'manifest' },
|
|
644
|
+
null,
|
|
645
|
+
2
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const suggestions = suggestedLayers();
|
|
649
|
+
return JSON.stringify(
|
|
650
|
+
{
|
|
651
|
+
source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
|
|
652
|
+
name: profile.name,
|
|
653
|
+
layers: profile.layers,
|
|
654
|
+
rules: profile.rules,
|
|
655
|
+
...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
|
|
656
|
+
...(suggestions.length > 0
|
|
657
|
+
? {
|
|
658
|
+
suggestedLayers: suggestions,
|
|
659
|
+
suggestedLayersNote:
|
|
660
|
+
'Layers from the default 11-layer profile this project has not declared. ' +
|
|
661
|
+
'When creating a NEW kind of code that fits one of these, place it in a ' +
|
|
662
|
+
'conventional directory and add the layer to ark.config.json instead of ' +
|
|
663
|
+
'inventing an ungoverned location.',
|
|
664
|
+
}
|
|
665
|
+
: {}),
|
|
666
|
+
},
|
|
667
|
+
null,
|
|
668
|
+
2
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function runValidate(params) {
|
|
673
|
+
const source = params?.arguments?.source;
|
|
674
|
+
if (typeof source !== 'string') {
|
|
675
|
+
return { content: [{ type: 'text', text: 'Missing required "source" argument.' }], isError: true };
|
|
676
|
+
}
|
|
677
|
+
const layer = params.arguments.layer ?? inferLayer(params.arguments.filePath, config, args.root);
|
|
678
|
+
const result = gate.validate(source, {
|
|
679
|
+
layer,
|
|
680
|
+
filePath: params.arguments.filePath,
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
content: [{ type: 'text', text: JSON.stringify({ ...result, layer }, null, 2) }],
|
|
684
|
+
isError: !result.valid,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ark_check / ark_coverage reuse the canonical CLI engine (TS resolver, baseline,
|
|
689
|
+
// Tarjan cycle detection) by shelling out to the sibling ark-check.mjs with --json —
|
|
690
|
+
// no second copy of the check logic to drift. These are occasional agent queries, not
|
|
691
|
+
// a hot path, so the per-call spawn cost is irrelevant.
|
|
692
|
+
function runArkCheckJson(extraArgs) {
|
|
693
|
+
return runArkCheckJsonFromRoot(args.root, args.config, extraArgs, args.manifest);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function runCheckTool(params) {
|
|
697
|
+
const strict = params?.arguments?.strict !== false; // default true
|
|
698
|
+
const baselineArg = params?.arguments?.baseline;
|
|
699
|
+
const baselineExists = fs.existsSync(path.join(args.root, '.ark-baseline.json'));
|
|
700
|
+
const useBaseline = baselineArg === undefined ? baselineExists : Boolean(baselineArg);
|
|
701
|
+
const extra = [];
|
|
702
|
+
if (strict) extra.push('--strict-config');
|
|
703
|
+
if (useBaseline) extra.push('--baseline');
|
|
704
|
+
const { data, raw } = runArkCheckJson(extra);
|
|
705
|
+
if (!data) {
|
|
706
|
+
return { content: [{ type: 'text', text: `ark-check produced no JSON:\n${raw}` }], isError: true };
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
710
|
+
isError: data.ok === false,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function runCoverageTool() {
|
|
715
|
+
const { data, raw } = runArkCheckJson(['--coverage']);
|
|
716
|
+
if (!data) {
|
|
717
|
+
return {
|
|
718
|
+
content: [{ type: 'text', text: `ark-check --coverage produced no JSON:\n${raw}` }],
|
|
719
|
+
isError: true,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], isError: false };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function runRecommendTool() {
|
|
726
|
+
const { data, raw } = runArkCheckJson(['--recommend']);
|
|
727
|
+
if (!data) {
|
|
728
|
+
return {
|
|
729
|
+
content: [{ type: 'text', text: `ark-check --recommend produced no JSON:\n${raw}` }],
|
|
730
|
+
isError: true,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
735
|
+
isError: data.ok === false,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Deterministic placement guidance (in-process; no TS resolver needed): which layer a
|
|
740
|
+
// path falls in, and — from the same rules ark-check enforces (default allow, explicit
|
|
741
|
+
// `allowed:false` denies) — which layers it may and must not import.
|
|
742
|
+
function runPlace(params) {
|
|
743
|
+
const filePath = params?.arguments?.filePath;
|
|
744
|
+
if (typeof filePath !== 'string' || !filePath) {
|
|
745
|
+
return { content: [{ type: 'text', text: 'Missing required "filePath" argument.' }], isError: true };
|
|
746
|
+
}
|
|
747
|
+
const layerName = inferLayer(filePath, config, args.root);
|
|
748
|
+
if (!layerName) {
|
|
749
|
+
// Two distinct reasons the path matched no layer: either this project declares no
|
|
750
|
+
// path-based layers at all (the gate still enforces the default 11-layer profile by
|
|
751
|
+
// intent-name PREFIX — placement just can't be inferred from the path), or it does
|
|
752
|
+
// declare layers and this path falls outside all of them (genuinely ungoverned).
|
|
753
|
+
const noLayers = configLayers.length === 0;
|
|
754
|
+
return {
|
|
755
|
+
content: [
|
|
756
|
+
{
|
|
757
|
+
type: 'text',
|
|
758
|
+
text: JSON.stringify(
|
|
759
|
+
{
|
|
760
|
+
filePath,
|
|
761
|
+
layer: null,
|
|
762
|
+
governed: noLayers, // default-profile intent rules still apply when no layers configured
|
|
763
|
+
message: noLayers
|
|
764
|
+
? 'This project declares no path-based layers in ark.config.json, so a ' +
|
|
765
|
+
'layer cannot be inferred from the path. The gate still enforces the ' +
|
|
766
|
+
'default 11-layer profile by intent-name prefix — read ark://manifest ' +
|
|
767
|
+
'for the layers and validate the actual snippet with validate_code.'
|
|
768
|
+
: 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
|
|
769
|
+
'rules enforced). Place it under a directory a layer in ark.config.json ' +
|
|
770
|
+
'matches, or add a layer. See suggestedLayers for conventional homes.',
|
|
771
|
+
suggestedLayers: suggestedLayers(),
|
|
772
|
+
},
|
|
773
|
+
null,
|
|
774
|
+
2
|
|
775
|
+
),
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
isError: false,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
const layerMeta = configLayers.find((layer) => layer.name === layerName);
|
|
782
|
+
const rules = config.rules ?? DEFAULT_RULES;
|
|
783
|
+
const otherNames = configLayers.map((layer) => layer.name).filter((name) => name !== layerName);
|
|
784
|
+
const mustNotImport = otherNames.filter((to) =>
|
|
785
|
+
rules.some((rule) => !rule.allowed && rule.from === layerName && rule.to === to)
|
|
786
|
+
);
|
|
787
|
+
const mayImport = otherNames.filter((name) => !mustNotImport.includes(name));
|
|
788
|
+
return {
|
|
789
|
+
content: [
|
|
790
|
+
{
|
|
791
|
+
type: 'text',
|
|
792
|
+
text: JSON.stringify(
|
|
793
|
+
{
|
|
794
|
+
filePath,
|
|
795
|
+
layer: layerName,
|
|
796
|
+
governed: true,
|
|
797
|
+
description: layerMeta?.description,
|
|
798
|
+
forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
|
|
799
|
+
...(layerMeta?.mayImportInfrastructure
|
|
800
|
+
? { mayImportInfrastructure: true }
|
|
801
|
+
: {}),
|
|
802
|
+
mayImport,
|
|
803
|
+
mustNotImport,
|
|
804
|
+
note:
|
|
805
|
+
'mayImport = layers with no explicit deny (default is allow). Respect ' +
|
|
806
|
+
'forbiddenGlobals, then verify the actual snippet with validate_code.',
|
|
807
|
+
},
|
|
808
|
+
null,
|
|
809
|
+
2
|
|
810
|
+
),
|
|
811
|
+
},
|
|
812
|
+
],
|
|
813
|
+
isError: false,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const TOOL_HANDLERS = {
|
|
818
|
+
validate_code: runValidate,
|
|
819
|
+
ark_check: runCheckTool,
|
|
820
|
+
ark_coverage: runCoverageTool,
|
|
821
|
+
ark_place: runPlace,
|
|
822
|
+
ark_recommend: runRecommendTool,
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
826
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
827
|
+
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
828
|
+
|
|
829
|
+
function handle(msg) {
|
|
830
|
+
const { id, method, params } = msg;
|
|
831
|
+
|
|
832
|
+
// Notifications carry no id and MUST never receive a response (JSON-RPC 2.0).
|
|
833
|
+
// The only notification we care about is notifications/initialized (a no-op here).
|
|
834
|
+
if (!('id' in msg)) return;
|
|
835
|
+
|
|
836
|
+
switch (method) {
|
|
837
|
+
case 'initialize':
|
|
838
|
+
reply(id, {
|
|
839
|
+
protocolVersion: params?.protocolVersion ?? DEFAULT_PROTOCOL,
|
|
840
|
+
capabilities: { tools: {}, resources: {} },
|
|
841
|
+
serverInfo: SERVER_INFO,
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
case 'ping':
|
|
845
|
+
reply(id, {});
|
|
846
|
+
return;
|
|
847
|
+
case 'tools/list':
|
|
848
|
+
reply(id, { tools: TOOLS });
|
|
849
|
+
return;
|
|
850
|
+
case 'tools/call': {
|
|
851
|
+
const handler = TOOL_HANDLERS[params?.name];
|
|
852
|
+
if (!handler) {
|
|
853
|
+
fail(id, -32602, `Unknown tool: ${params?.name}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
reply(id, handler(params));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
case 'resources/list':
|
|
860
|
+
reply(id, { resources: RESOURCES });
|
|
861
|
+
return;
|
|
862
|
+
case 'resources/read':
|
|
863
|
+
if (params?.uri !== 'ark://manifest') {
|
|
864
|
+
fail(id, -32602, `Unknown resource: ${params?.uri}`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
reply(id, {
|
|
868
|
+
contents: [{ uri: 'ark://manifest', mimeType: 'application/json', text: manifestText() }],
|
|
869
|
+
});
|
|
870
|
+
return;
|
|
871
|
+
default:
|
|
872
|
+
fail(id, -32601, `Method not found: ${method}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
877
|
+
rl.on('line', (line) => {
|
|
878
|
+
const trimmed = line.trim();
|
|
879
|
+
if (!trimmed) return;
|
|
880
|
+
let msg;
|
|
881
|
+
try {
|
|
882
|
+
msg = JSON.parse(trimmed);
|
|
883
|
+
} catch {
|
|
884
|
+
fail(null, -32700, 'Parse error');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
handle(msg);
|
|
889
|
+
} catch (err) {
|
|
890
|
+
fail(msg?.id ?? null, -32603, err instanceof Error ? err.message : String(err));
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
main().catch((err) => {
|
|
896
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
897
|
+
process.exitCode = 1;
|
|
898
|
+
});
|