arkgate 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +113 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-layer-match.mjs +9 -4
  6. package/bin/ark-mcp-runtime.mjs +1976 -0
  7. package/bin/ark-mcp.mjs +84 -1495
  8. package/bin/ark-shared.mjs +34 -38
  9. package/bin/ark.mjs +33 -66
  10. package/bin/lib/adapter-contract.mjs +161 -9
  11. package/bin/lib/agent-gates.mjs +1 -0
  12. package/bin/lib/analysis-completeness.mjs +28 -0
  13. package/bin/lib/analysis-engine.mjs +8 -8
  14. package/bin/lib/analysis-policy.mjs +27 -0
  15. package/bin/lib/architecture-scan.mjs +70 -357
  16. package/bin/lib/auto-patch.mjs +76 -8
  17. package/bin/lib/ci-and-commands.mjs +1 -1
  18. package/bin/lib/codex-home.mjs +43 -16
  19. package/bin/lib/design-delta.mjs +4 -0
  20. package/bin/lib/doctor-advisories.mjs +4 -3
  21. package/bin/lib/doctor-plan.mjs +40 -41
  22. package/bin/lib/enforcement-state.mjs +2 -0
  23. package/bin/lib/github-enforcement.mjs +443 -0
  24. package/bin/lib/hook-templates.mjs +12 -148
  25. package/bin/lib/html-report-advisories.mjs +1 -1
  26. package/bin/lib/html-report-depth.mjs +9 -0
  27. package/bin/lib/html-report.mjs +5 -5
  28. package/bin/lib/install-migrate.mjs +83 -79
  29. package/bin/lib/managed-upgrade.mjs +622 -0
  30. package/bin/lib/mcp-adoption.mjs +3 -1
  31. package/bin/lib/parse-health.mjs +6 -5
  32. package/bin/lib/port-proof.mjs +2 -2
  33. package/bin/lib/prepare-change.mjs +68 -38
  34. package/bin/lib/prepare-write.mjs +7 -1
  35. package/bin/lib/resident-doctor-client.mjs +55 -0
  36. package/bin/lib/resident-hook.mjs +247 -0
  37. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  38. package/bin/lib/scan-files.mjs +19 -6
  39. package/bin/lib/snippet-analysis.mjs +119 -0
  40. package/bin/lib/source-policy.mjs +24 -0
  41. package/bin/lib/typescript-host.mjs +15 -18
  42. package/bin/lib/unavailable-analysis.mjs +76 -0
  43. package/bin/lib/upgrade-command.mjs +115 -0
  44. package/bin/lib/weakest-link.mjs +21 -179
  45. package/bin/lib/write-path-capabilities.mjs +167 -16
  46. package/bin/lib/write-path-detect.mjs +3 -2
  47. package/dist/eslint/index.cjs +3 -3
  48. package/dist/eslint/index.d.ts +4 -1
  49. package/dist/eslint/index.js +3 -3
  50. package/dist/index.cjs +6 -6
  51. package/dist/index.d.ts +1111 -151
  52. package/dist/index.js +7 -7
  53. package/docs/agent-guide.md +106 -62
  54. package/docs/ai-gates.md +97 -16
  55. package/docs/configuration.md +3 -0
  56. package/docs/demos/01-write-gate-self-correction.md +2 -2
  57. package/docs/enthusiast/README.md +10 -10
  58. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  59. package/docs/enthusiast/reference-commands.md +18 -1
  60. package/docs/enthusiast/tutorial-first-project.md +2 -2
  61. package/docs/package-surface.md +98 -12
  62. package/docs/typescript-support.md +108 -37
  63. package/package.json +32 -4
  64. package/schemas/ark.analysis-result.schema.json +159 -2
  65. package/schemas/ark.design-delta.schema.json +1 -0
  66. package/schemas/ark.enforcement-state.schema.json +84 -0
  67. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  68. package/server.json +2 -2
  69. package/templates/skills/ark-explore.md +5 -5
  70. package/templates/skills/ark-fix.md +1 -1
  71. package/templates/skills/ark-runtime.md +15 -8
  72. package/templates/skills/ark-upgrade.md +122 -182
  73. package/bin/lib/ai-velocity.mjs +0 -293
  74. package/bin/lib/graph-cycles.mjs +0 -6
  75. package/bin/lib/safety-diagnostics.mjs +0 -284
  76. package/bin/lib/ts-resolve.mjs +0 -228
  77. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  78. package/dist/eslint/index.d.cts +0 -146
  79. package/dist/index.d.cts +0 -986
package/bin/ark-mcp.mjs CHANGED
@@ -1,1527 +1,116 @@
1
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, autoPatch? } and sets isError when invalid.
16
- * autoPatch (W1) is a gate-revalidated rewrite for mechanical-safe
17
- * import-type kinds only (not W6 port-proof — signature change is judgment);
18
- * discarded if post-patch still invalid.
19
- * - tool ark_prepare_write — W2: place + constrain + validate + autoPatch + judgmentBrief
20
- * + contentHash (composes ark_place + write gate; not a second contract).
21
- * - tool ark_prepare_change — atomically preflights a create/update/delete batch without writes.
22
- * - tool ark_policy_delta — classifies a base/candidate ark.config.json transition and
23
- * rejects weakening without an exact hash-bound acknowledgement.
24
- * - tool ark_recommend — deterministic application-shape plan (same as
25
- * ark-check --recommend --json)
26
- *
27
- * Usage: ark-mcp [--root <dir>] [--config ark.config.json] [--manifest <manifest.json>]
28
- * ark-mcp --hook [--hook-repair] [--root <dir>] [--config ark.config.json]
29
- *
30
- * --hook runs one-shot instead of serving: it reads a Claude Code PreToolUse payload from
31
- * stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
32
- * 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
33
- * the copy-paste integration for agent runtimes whose hooks run shell commands.
34
- *
35
- * --hook-repair (W4, also ARK_HOOK_REPAIR=1): on deny, emit machine-readable
36
- * ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on stderr (and autoPatch in Grok deny JSON).
37
- * Never silently writes the file — default and repair mode both hard-block.
38
- *
39
- * --session-context runs one-shot and prints a compact contract summary (layers, rule
40
- * count, forbidden globals, baseline state, check command) to stdout. Bind it to a
41
- * SessionStart hook so the agent has the architecture in context from the first token,
42
- * instead of learning it by rejection.
43
- */
2
+ /** Lightweight launcher; ark-mcp-runtime owns all MCP and hook semantics. */
44
3
  import fs from 'node:fs';
45
4
  import path from 'node:path';
46
- import readline from 'node:readline';
47
- import { spawnSync } from 'node:child_process';
48
5
  import { fileURLToPath } from 'node:url';
49
- import {
50
- DEFAULT_INTENT_PREFIXES,
51
- DEFAULT_LAYER_DIRECTORIES,
52
- DEFAULT_RULES,
53
- arkCommand,
54
- globToRegExp,
55
- layerForFile,
56
- shouldShowNewHereNudge,
57
- detectWorkspaces,
58
- detectTsPackageRoots,
59
- resolveIncludeRoots,
60
- } from './ark-shared.mjs';
61
- import { effectiveCapabilityDeny } from './lib/analysis-engine.mjs';
62
- import { createImportTargetResolver } from './lib/import-resolve.mjs';
63
- import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
64
- import { composePrepareWrite } from './lib/prepare-write.mjs';
65
- import { loadArkConfigContract } from './lib/config-contract.mjs';
66
- import { ARK_ANALYSIS_RESULT_SCHEMA, createAdapterResult } from './lib/adapter-contract.mjs';
67
- import { loadGoldenPattern, attachGoldenToPlacement } from './lib/golden-pattern.mjs';
68
- import { prepareChangeFromRoot } from './lib/prepare-change.mjs';
69
- import { detectWritePathCapabilities } from './lib/write-path-detect.mjs';
70
6
 
71
- const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
7
+ import {
8
+ RESIDENT_HOOK_PROTOCOL_VERSION,
9
+ requestResidentHook,
10
+ residentHookEndpoint,
11
+ } from './lib/resident-hook.mjs';
72
12
 
73
- /**
74
- * W4 — opt-in hook repair payload.
75
- * True when CLI `--hook-repair` or env ARK_HOOK_REPAIR is 1/true/yes.
76
- * Default remains hard block with prose violations only (no machine-readable patch).
77
- */
78
- function envTruthy(name) {
79
- const v = process.env[name];
80
- if (v == null || v === '') return false;
81
- return /^(1|true|yes|on)$/i.test(String(v).trim());
82
- }
13
+ const launcher = fileURLToPath(import.meta.url);
83
14
 
84
- function parseArgs(argv) {
15
+ function launcherArgs(argv) {
85
16
  const args = {
86
17
  root: process.cwd(),
87
18
  config: 'ark.config.json',
88
- configExplicit: false,
89
19
  manifest: undefined,
20
+ tsconfig: undefined,
90
21
  hook: false,
91
- /** When true with --hook: emit ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON (never silent write). */
92
- hookRepair: false,
93
- sessionContext: false,
22
+ hookRepair: /^(?:1|true|yes|on)$/i.test(String(process.env.ARK_HOOK_REPAIR ?? '').trim()),
23
+ failOnNewSmells: /^(?:1|true|yes|on)$/i.test(
24
+ String(process.env.ARK_FAIL_ON_NEW_SMELLS ?? '').trim()
25
+ ),
94
26
  };
95
- for (let i = 2; i < argv.length; i += 1) {
96
- const a = argv[i];
97
- if (a === '--hook') args.hook = true;
98
- else if (a === '--hook-repair') {
27
+ for (let index = 2; index < argv.length; index += 1) {
28
+ const value = argv[index];
29
+ if (value === '--hook') args.hook = true;
30
+ else if (value === '--hook-repair') {
99
31
  args.hook = true;
100
32
  args.hookRepair = true;
101
- } else if (a === '--session-context') args.sessionContext = true;
102
- else if (a === '--root') args.root = path.resolve(argv[++i]);
103
- else if (a === '--config') {
104
- args.config = argv[++i];
105
- args.configExplicit = true;
106
- } else if (a === '--manifest') args.manifest = argv[++i];
107
- }
108
- // Env can enable repair without rewriting host templates (ARK_HOOK_REPAIR=1).
109
- if (envTruthy('ARK_HOOK_REPAIR')) {
110
- args.hookRepair = true;
33
+ } else if (value === '--fail-on-new-smells') args.failOnNewSmells = true;
34
+ else if (value === '--root' && argv[index + 1]) args.root = path.resolve(argv[++index]);
35
+ else if (value === '--config' && argv[index + 1]) args.config = argv[++index];
36
+ else if (value === '--manifest' && argv[index + 1]) args.manifest = argv[++index];
37
+ else if (value === '--tsconfig' && argv[index + 1]) args.tsconfig = argv[++index];
111
38
  }
112
39
  return args;
113
40
  }
114
41
 
115
- /**
116
- * Read a JSON file. Missing files return undefined unless `required` (so the caller can
117
- * fall back), but malformed JSON always throws — silently swallowing a syntax error would
118
- * turn the layer gate into a no-op that reports every write as valid.
119
- */
120
- function readJson(file, { required } = {}) {
121
- if (!fs.existsSync(file)) {
122
- if (required) throw new Error(`File not found: ${file}`);
123
- return undefined;
124
- }
125
- try {
126
- return JSON.parse(fs.readFileSync(file, 'utf8'));
127
- } catch (err) {
128
- throw new Error(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
129
- }
130
- }
131
-
132
- function readArkConfig(file, { required } = {}) {
133
- const raw = readJson(file, { required });
134
- return raw === undefined ? undefined : loadArkConfigContract(raw, file).config;
135
- }
136
-
137
- function resolveInRoot(root, maybePath) {
138
- if (!maybePath) return undefined;
139
- return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
140
- }
141
-
142
- function inferLayer(filePath, config, root) {
143
- if (!filePath) return undefined;
144
- return layerForFile(root, filePath, config.layers);
145
- }
146
-
147
- async function loadArk() {
148
- const url = new URL('../dist/index.js', import.meta.url);
149
- if (!fs.existsSync(url)) {
150
- throw new Error(
151
- 'ark-mcp requires the built library at dist/index.js. Run "npm run build" first.'
152
- );
153
- }
154
- try {
155
- return await import(url.href);
156
- } catch (err) {
157
- throw new Error(
158
- `ark-mcp failed to load dist/index.js (rebuild with "npm run build"): ${
159
- err instanceof Error ? err.message : String(err)
160
- }`
161
- );
162
- }
163
- }
164
-
165
- async function loadOptionalTypeScript() {
166
- try {
167
- return await import('typescript');
168
- } catch {
169
- return undefined;
170
- }
42
+ function residentEligiblePayload(payload) {
43
+ const toolName = payload?.tool_name ?? payload?.toolName;
44
+ return !['ApplyPatch', 'apply_patch'].includes(toolName);
171
45
  }
172
46
 
173
- const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
174
-
175
- /**
176
- * Normalize agent PreToolUse payloads.
177
- * Claude Code: { tool_name, tool_input: { file_path, content | old_string/new_string } }
178
- * Grok Build: { toolName, toolInput: { file_path, content | old_string/new_string } }
179
- * (aliases Write/Edit/MultiEdit → write/search_replace; matcher keeps both)
180
- */
181
- function normalizeHookPayload(payload) {
182
- const rawName = payload?.tool_name ?? payload?.toolName ?? '';
183
- const toolInput = payload?.tool_input ?? payload?.toolInput ?? {};
184
- const nameMap = {
185
- Write: 'Write',
186
- write: 'Write',
187
- Edit: 'Edit',
188
- search_replace: 'Edit',
189
- MultiEdit: 'MultiEdit',
190
- ApplyPatch: 'ApplyPatch',
191
- apply_patch: 'ApplyPatch',
192
- };
193
- const toolName = nameMap[rawName] ?? rawName;
194
- const filePath =
195
- toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? toolInput.target_file;
196
- return {
197
- toolName,
198
- toolInput: { ...toolInput, file_path: filePath },
199
- // Grok-style camelCase (or GROK_HOOK_EVENT) → also emit deny JSON on stdout.
200
- grokStyle:
201
- Boolean(process.env.GROK_HOOK_EVENT) ||
202
- (payload != null && typeof payload === 'object' && 'toolName' in payload),
203
- };
204
- }
205
-
206
- function applyCodexUpdatePatch(current, lines) {
207
- let source = current.split('\n');
208
- let cursor = 0;
209
- const hunks = [];
210
- let hunk = null;
211
- for (const line of lines) {
212
- if (line.startsWith('@@')) {
213
- if (hunk) hunks.push(hunk);
214
- hunk = { anchor: line.slice(2).trim(), entries: [] };
215
- } else if (/^[ +\-]/.test(line)) {
216
- if (!hunk) return null;
217
- hunk.entries.push(line);
218
- }
219
- }
220
- if (hunk) hunks.push(hunk);
221
- for (const { anchor, entries } of hunks) {
222
- if (anchor) {
223
- const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
224
- if (anchorAt < 0) return null;
225
- cursor = anchorAt + 1;
226
- }
227
- const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
228
- const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
229
- let found = -1;
230
- for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
231
- if (oldLines.every((line, index) => source[at + index] === line)) {
232
- found = at;
233
- break;
234
- }
235
- }
236
- if (found < 0) return null;
237
- source.splice(found, oldLines.length, ...newLines);
238
- cursor = found + newLines.length;
239
- }
240
- return source.join('\n');
241
- }
242
-
243
- function codexPatchWrites(patch, root) {
244
- if (typeof patch !== 'string') {
245
- return { writes: [], complete: false };
246
- }
247
- const lines = patch.split('\n');
248
- const begin = lines.indexOf('*** Begin Patch');
249
- const end = lines.indexOf('*** End Patch', begin + 1);
250
- if (begin < 0 || end <= begin) return { writes: [], complete: false };
251
- const writes = [];
252
- const seenPaths = new Set();
253
- let complete = [
254
- ...lines.slice(0, begin),
255
- ...lines.slice(end + 1),
256
- ].every((line) => line.trim() === '');
257
- let sawFileDirective = false;
258
- for (let index = begin + 1; index < end; index += 1) {
259
- const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
260
- if (!match) {
261
- if (lines[index].trim() !== '') complete = false;
262
- continue;
263
- }
264
- sawFileDirective = true;
265
- const [, action, relativePath] = match;
266
- const body = [];
267
- for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
268
- body.push(lines[index]);
269
- }
270
- index -= 1;
271
- const filePath = path.resolve(root, relativePath);
272
- const rel = path.relative(root, filePath);
273
- if (
274
- seenPaths.has(filePath) ||
275
- rel.startsWith(`..${path.sep}`) ||
276
- rel === '..' ||
277
- path.isAbsolute(rel)
278
- ) {
279
- complete = false;
280
- continue;
281
- }
282
- seenPaths.add(filePath);
283
- if (action === 'Delete') {
284
- if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
285
- complete = false;
286
- continue;
287
- }
288
- writes.push({ path: relativePath, delete: true });
289
- continue;
290
- }
291
- let content;
292
- if (action === 'Add') {
293
- if (
294
- body.length === 0 ||
295
- fs.existsSync(filePath) ||
296
- body.some((line) => !line.startsWith('+'))
297
- ) {
298
- complete = false;
299
- continue;
300
- }
301
- content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
302
- if (body.some((line) => line.startsWith('+'))) content += '\n';
303
- } else {
304
- if (
305
- !body.some((line) => line.startsWith('@@')) ||
306
- body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
307
- ) {
308
- complete = false;
309
- continue;
310
- }
311
- let current;
312
- try {
313
- current = fs.readFileSync(filePath, 'utf8');
314
- } catch {
315
- complete = false;
316
- continue;
317
- }
318
- content = applyCodexUpdatePatch(current, body);
319
- if (content === null) complete = false;
320
- }
321
- if (typeof content === 'string') writes.push({ path: relativePath, filePath, content });
322
- }
323
- return { writes, complete: complete && sawFileDirective };
324
- }
325
-
326
- function hookEnforcement(root, host, operation, completePatch = false) {
327
- return detectWritePathCapabilities(root, host, {
328
- boundary: 'pre-tool',
329
- operation,
330
- completePatch,
331
- }).enforcementLadder;
332
- }
333
-
334
- /**
335
- * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
336
- * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
337
- * snippet out of context. Replacement uses a function argument so `$&`-style sequences in
338
- * generated code are inserted literally, never interpreted as replacement patterns.
339
- */
340
- function proposedSource(toolName, toolInput) {
341
- if (toolName === 'Write') return toolInput.content;
342
-
343
- let text = '';
344
- try {
345
- text = fs.readFileSync(toolInput.file_path, 'utf8');
346
- } catch {
347
- // New file created via Edit: fall through with an empty base.
348
- }
349
- const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
350
- for (const edit of edits) {
351
- const from = edit.old_string ?? '';
352
- const to = edit.new_string ?? '';
353
- if (from === '') {
354
- text = to;
355
- } else if (edit.replace_all) {
356
- text = text.split(from).join(to);
357
- } else {
358
- text = text.replace(from, () => to);
359
- }
360
- }
361
- return text;
362
- }
363
-
364
- /**
365
- * One-shot PreToolUse gate (Claude Code + Grok Build hook contracts): payload on stdin,
366
- * exit 2 + violations on stderr to block, exit 0 to allow. Grok also receives a deny
367
- * decision JSON on stdout. Gate plumbing problems (no stdin, malformed JSON, non-file
368
- * tools, non-source files) never block the agent.
369
- */
370
- function runHook(gate, config, args, ts) {
47
+ async function tryResidentHook(args, hookInput) {
48
+ if (process.env.ARK_RESIDENT_HOOK !== '1') return null;
371
49
  let payload;
372
50
  try {
373
- payload = JSON.parse(fs.readFileSync(0, 'utf8'));
51
+ payload = JSON.parse(hookInput);
374
52
  } catch {
375
- return;
376
- }
377
-
378
- runHookPayload(payload, gate, config, args, ts);
379
- }
380
-
381
- function runHookPayload(payload, gate, config, args, ts, attemptContext) {
382
- const { toolName, toolInput, grokStyle } = normalizeHookPayload(payload);
383
- if (toolName === 'ApplyPatch') {
384
- const patch = toolInput.patch ?? toolInput.input ?? toolInput.content;
385
- const parsedPatch = codexPatchWrites(patch, args.root);
386
- // Codex ApplyPatch is only preflighted when Ark can reconstruct every file operation.
387
- // An incomplete reconstruction must not be mislabeled as atomic or hard enforcement.
388
- if (!parsedPatch.complete) return;
389
- const patchWrites = parsedPatch.writes;
390
- const governedWrites = patchWrites.filter((change) => {
391
- const relative = String(change.path).replace(/\\/g, '/');
392
- if (!SOURCE_FILE.test(relative) || relative.endsWith('.d.ts')) return false;
393
- return Boolean(inferLayer(path.resolve(args.root, relative), config, args.root));
394
- });
395
- const changes = governedWrites.map(({ path: relativePath, content, delete: deleted }) =>
396
- deleted ? { path: relativePath, delete: true } : { path: relativePath, content }
397
- );
398
- if (changes.length === 0) return;
399
- let result;
400
- try {
401
- result = prepareChangeFromRoot({
402
- root: args.root,
403
- config,
404
- configSource: path.isAbsolute(args.config)
405
- ? args.config
406
- : path.join(args.root, args.config),
407
- changes,
408
- });
409
- } catch {
410
- return;
411
- }
412
- if (result.valid) {
413
- for (const write of governedWrites) {
414
- if (typeof write.content !== 'string') continue;
415
- runHookPayload(
416
- {
417
- tool_name: 'Write',
418
- tool_input: { file_path: write.filePath, content: write.content },
419
- },
420
- gate,
421
- config,
422
- args,
423
- ts,
424
- { host: 'codex', operation: 'apply_patch', completePatch: true }
425
- );
426
- }
427
- return;
428
- }
429
- const message = [
430
- `Ark architecture gate blocked this complete ${toolName} (${changes.length} governed file(s)):`,
431
- ...result.diagnostics.map(
432
- (diagnostic) =>
433
- `- [${diagnostic.ruleId}] ${diagnostic.message}\n Next action: ${diagnostic.nextAction}`
434
- ),
435
- 'No project file was written. Fix the complete patch and retry.',
436
- ].join('\n');
437
- process.stderr.write(`${message}\n`);
438
- if (args.hookRepair) {
439
- process.stderr.write(
440
- `ARK_REPAIR_JSON:${JSON.stringify({
441
- ...result,
442
- mode: 'repair',
443
- decision: 'deny',
444
- enforcement: hookEnforcement(args.root, 'codex', 'apply_patch', true),
445
- autoPatch: null,
446
- })}\n`
447
- );
448
- }
449
- process.exitCode = 2;
450
- return;
451
- }
452
- const filePath = toolInput.file_path;
453
- if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return;
454
- if (typeof filePath !== 'string' || !SOURCE_FILE.test(filePath) || filePath.endsWith('.d.ts')) {
455
- return;
456
- }
457
- const rel = path.relative(args.root, path.resolve(filePath));
458
- const segments = rel.split(path.sep);
459
- if (segments[0] === '..' || segments.includes('node_modules')) return;
460
-
461
- const source = proposedSource(toolName, toolInput);
462
- if (typeof source !== 'string') return;
463
-
464
- const layer = inferLayer(filePath, config, args.root);
465
- const validateOnce = (src) => gate.validate(src, { layer, filePath });
466
- // W1: one validation pass (+ optional autoPatch). Original write still blocked when
467
- // invalid; hosts must apply autoPatch explicitly (never silent write).
468
- const result = ts
469
- ? validateWithAutoPatch({
470
- source,
471
- filePath,
472
- root: args.root,
473
- ts,
474
- validate: validateOnce,
475
- resolveTargetAbs: resolveImportFileAbs,
476
- })
477
- : (() => {
478
- const once = validateOnce(source);
479
- return {
480
- valid: Boolean(once.valid),
481
- violations: once.violations ?? [],
482
- autoPatch: null,
483
- };
484
- })();
485
- if (result.valid) return;
486
-
487
- // Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
488
- // when it ADDS violations relative to the file's current on-disk state. Otherwise a
489
- // pre-existing violation — frozen in a baseline or predating Ark adoption — would make
490
- // every subsequent edit to that file un-writable while CI passes. Same-file keys ignore
491
- // line numbers (edits shift them); simpler than full baselineKey (no file/layer fields
492
- // needed — this file is fixed).
493
- const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
494
- let existingCounts = new Map();
495
- try {
496
- const current = fs.readFileSync(filePath, 'utf8');
497
- for (const violation of gate.validate(current, { layer, filePath }).violations) {
498
- const key = violationKey(violation);
499
- existingCounts.set(key, (existingCounts.get(key) ?? 0) + 1);
500
- }
501
- } catch {
502
- // New file: nothing pre-exists, every violation is new.
503
- }
504
- const newViolations = (result.violations ?? []).filter((violation) => {
505
- const key = violationKey(violation);
506
- const remaining = existingCounts.get(key) ?? 0;
507
- if (remaining === 0) return true;
508
- existingCounts.set(key, remaining - 1);
509
- return false;
53
+ // Matches the one-shot contract: malformed/no stdin is a non-blocking no-op.
54
+ return { status: 0, stdout: '', stderr: '' };
55
+ }
56
+ if (!residentEligiblePayload(payload)) return null;
57
+ const endpoint = residentHookEndpoint({
58
+ root: args.root,
59
+ config: args.config,
60
+ manifest: args.manifest,
61
+ tsconfig: args.tsconfig,
62
+ launcher,
510
63
  });
511
- if (newViolations.length === 0) return;
512
- const normalizedRel = rel.split(path.sep).join('/');
513
- const adapterResult = createAdapterResult({
514
- valid: false,
515
- violations: newViolations.map((violation) => ({ ...violation, file: normalizedRel })),
516
- });
517
-
518
- const lines = adapterResult.diagnostics.map(
519
- (diagnostic) =>
520
- `- [${diagnostic.ruleId}] ${diagnostic.message}${diagnostic.location.line ? ` (line ${diagnostic.location.line})` : ''}\n Next action: ${diagnostic.nextAction}`
521
- );
522
- // Surface the per-violation fix hints (the gate carries them in `suggestion`,
523
- // but the hook was dropping them). Dedupe so two infra violations sharing one
524
- // hint — e.g. the mayImportInfrastructure escape hatch — print it once.
525
- const suggestions = [
526
- ...new Set(newViolations.map((violation) => violation.suggestion).filter(Boolean)),
527
- ];
528
- const autoPatch = result.autoPatch;
529
- // W4: structured repair payload is opt-in (--hook-repair / ARK_HOOK_REPAIR).
530
- // Default remains hard block with prose only — hosts that cannot re-inject stay clean.
531
- const repair = Boolean(args.hookRepair);
532
- const message = [
533
- `Ark architecture gate blocked this write to ${rel}${layer ? ` (layer: ${layer})` : ''}:`,
534
- ...lines,
535
- ...(suggestions.length > 0 ? ['Fix:', ...suggestions.map((s) => ` ${s}`)] : []),
536
- ...(autoPatch && repair
537
- ? [
538
- `autoPatch available (${autoPatch.remediationKind}, confidence ${autoPatch.confidence}): ` +
539
- 'apply the patched source from ARK_AUTOPATCH_JSON / ARK_REPAIR_JSON on stderr' +
540
- (grokStyle ? ' (or autoPatch in the deny JSON on stdout)' : '') +
541
- ' instead of re-drafting. Gate still denies this write (never silent apply).',
542
- ]
543
- : []),
544
- ...(autoPatch && !repair
545
- ? [
546
- `Mechanical-safe autoPatch is available (${autoPatch.remediationKind}). ` +
547
- 'Enable repair payload with ARK_HOOK_REPAIR=1 or --hook-repair to receive ' +
548
- 'machine-readable source (still hard-blocks; host re-injects).',
549
- ]
550
- : []),
551
- 'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
552
- ].join('\n');
553
- process.stderr.write(message + '\n');
554
-
555
- if (repair) {
556
- // Structured envelope for any host that can re-inject. Never writes the file.
557
- const repairPayload = {
558
- ...adapterResult,
559
- mode: 'repair',
560
- decision: 'deny',
561
- filePath: normalizedRel,
562
- enforcement: hookEnforcement(
563
- args.root,
564
- attemptContext?.host ?? (grokStyle ? 'grok' : 'claude'),
565
- attemptContext?.operation ??
566
- (grokStyle ? (toolName === 'Edit' ? 'search_replace' : 'write') : toolName),
567
- Boolean(attemptContext?.completePatch)
568
- ),
569
- ...(layer ? { layer } : {}),
570
- ...(autoPatch
571
- ? {
572
- autoPatch: {
573
- source: autoPatch.source,
574
- remediationKind: autoPatch.remediationKind,
575
- confidence: autoPatch.confidence,
576
- valid: autoPatch.valid,
577
- },
578
- }
579
- : { autoPatch: null }),
580
- };
581
- process.stderr.write(`ARK_REPAIR_JSON:${JSON.stringify(repairPayload)}\n`);
582
- if (autoPatch) {
583
- process.stderr.write(`ARK_AUTOPATCH_JSON:${JSON.stringify(autoPatch)}\n`);
584
- }
585
- }
586
-
587
- // Grok Build honors { decision: "deny" } on stdout (exit 2 alone is also deny).
588
- // autoPatch in stdout only when repair mode is on (same opt-in as stderr).
589
- if (grokStyle) {
590
- process.stdout.write(
591
- JSON.stringify({
592
- decision: 'deny',
593
- reason: message,
594
- analysis: adapterResult,
595
- ...(repair && autoPatch ? { autoPatch } : {}),
596
- ...(repair ? { repair: true } : {}),
597
- }) + '\n'
598
- );
599
- }
600
- process.exitCode = 2;
601
- }
602
-
603
- function runArkCheckJsonFromRoot(root, config, extraArgs, manifest) {
604
- const manifestArgs = manifest ? ['--manifest', manifest] : [];
605
- const result = spawnSync(
606
- process.execPath,
607
- [arkCheckBin, '--root', root, '--config', config, ...manifestArgs, '--json', ...extraArgs],
608
- { encoding: 'utf8', timeout: 120_000, maxBuffer: 20 * 1024 * 1024 }
609
- );
610
- if (result.error) {
611
- return {
612
- data: null,
613
- raw: `ark-check failed to execute: ${result.error.message}`,
614
- };
615
- }
616
- const stdout = result.stdout ?? '';
617
- try {
618
- return { data: JSON.parse(stdout), raw: stdout };
619
- } catch {
620
- return { data: null, raw: stdout || result.stderr || 'ark-check produced no output' };
621
- }
622
- }
623
-
624
- /**
625
- * One-shot SessionStart context: a compact summary of the contract on stdout so the
626
- * agent starts the session already knowing the architecture. Advisory — never blocks
627
- * and never exits non-zero for missing optional inputs (e.g. no baseline file).
628
- */
629
- function printSessionContext(config, profile, forbiddenGlobals, args, configPath) {
630
- const lines = ['Ark architecture contract governs this project (ark.config.json is authoritative).'];
631
-
632
- const configLayers = Array.isArray(config.layers) ? config.layers : [];
633
- if (configLayers.length > 0) {
634
- lines.push('Layers:');
635
- for (const layer of configLayers) {
636
- const globals = forbiddenGlobals[layer.name];
637
- const globalsNote = globals ? ` — forbidden globals: ${globals.join(', ')}` : '';
638
- lines.push(` - ${layer.name}: ${(layer.patterns ?? []).join(', ')}${globalsNote}`);
639
- }
640
- } else {
641
- lines.push(
642
- `Layers: none configured — the default 11-layer profile applies to intent references.`
643
- );
644
- }
645
-
646
- const denied = (profile.rules ?? []).filter((rule) => !rule.allowed).length;
647
- lines.push(
648
- `Rules: ${denied} denied layer edge(s). Full contract: ark://manifest MCP resource.`
649
- );
650
-
651
- // Advisory output: a malformed baseline must not abort the summary.
652
- let baseline;
653
- try {
654
- baseline = readJson(path.join(args.root, '.ark-baseline.json'));
655
- } catch {
656
- baseline = undefined;
657
- }
658
- if (Array.isArray(baseline?.violations)) {
659
- lines.push(
660
- `Baseline: ${baseline.violations.length} frozen violation(s) — only NEW violations fail; do not add to them.`
661
- );
662
- }
663
-
664
- lines.push(
665
- `After edits run: ${arkCommand(args.root, 'ark-check', '--root . --config ark.config.json --strict-config')}`
666
- );
667
- lines.push('If Ark reports violations, fix the architecture instead of weakening the gate.');
668
-
669
- const { data: coverage } = runArkCheckJsonFromRoot(args.root, args.config, ['--coverage'], undefined);
670
- const governedPercent = coverage?.coverage?.governed?.percent ?? coverage?.governed?.percent;
671
- if (shouldShowNewHereNudge(args.root, configPath, governedPercent, false)) {
672
- lines.push('');
673
- lines.push('New to Ark? Run /ark-architect or: ark-check --recommend');
674
- }
675
-
676
- process.stdout.write(`${lines.join('\n')}\n`);
677
- }
678
-
679
- async function main() {
680
- const args = parseArgs(process.argv);
681
- const configPath = resolveInRoot(args.root, args.config);
682
-
683
- // SessionStart contract injection is only meaningful in Ark-governed projects. Bail
684
- // out silently (before loading dist) when there is no config, so the hook is safe
685
- // even if a user installs it in their GLOBAL settings instead of per-project.
686
- if (args.sessionContext && !(configPath && fs.existsSync(configPath))) {
687
- return;
688
- }
689
-
690
- const ark = await loadArk();
691
- const ts = await loadOptionalTypeScript();
692
-
693
- const config =
694
- (configPath ? readArkConfig(configPath, { required: args.configExplicit }) : undefined) ??
695
- loadArkConfigContract(
696
- { include: ['src'], layers: [], rules: DEFAULT_RULES },
697
- configPath ?? 'ark.config.json'
698
- ).config;
699
- if (!config.layers || config.layers.length === 0) {
700
- process.stderr.write(
701
- '[ark-mcp] warning: no layers configured — file→layer inference from config patterns ' +
702
- 'is unavailable, so layer-reference checks run only when the caller passes an explicit ' +
703
- '"layer" (checked against the default 11-layer profile).\n'
704
- );
705
- }
706
-
707
- const manifestPath = resolveInRoot(args.root, args.manifest);
708
- const projectManifest = manifestPath ? readJson(manifestPath, { required: true }) : undefined;
709
-
710
- const intents = Array.isArray(projectManifest?.intents)
711
- ? projectManifest.intents.map((i) => (typeof i === 'string' ? i : i?.name)).filter(Boolean)
712
- : [];
713
-
714
- // Build the enforcement profile with the SAME semantics ark-check (CI) applies to the
715
- // config, so the write-path gate and CI can't disagree:
716
- // - rules: config.rules ?? DEFAULT_RULES (ark-check readConfig substitutes DEFAULT_RULES)
717
- // - intent prefixes: the config layers that declare intentPrefixes; when none do, fall
718
- // back to DEFAULT_INTENT_PREFIXES (mirrors ark-check's layerForIntent fallback).
719
- // Only layers WITH prefixes enter the profile, so no layer has empty prefixes (which would
720
- // also make it unresolvable). A project with no layers at all gets the 11-layer default.
721
- const configLayers = Array.isArray(config.layers) ? config.layers : [];
722
- const manifestLayers = Array.isArray(projectManifest?.architecture?.layers)
723
- ? projectManifest.architecture.layers
724
- : [];
725
- const usedProjectConfig = configLayers.length > 0;
726
- let profile;
727
- if (manifestLayers.length > 0) {
728
- profile = ark.createArchitectureProfile({
729
- name: projectManifest.architecture.profile ?? 'manifest',
730
- layers: manifestLayers.map((layer) => ({
731
- name: layer.name,
732
- prefixes: layer.prefixes,
733
- })),
734
- rules: projectManifest.architecture.rules ?? DEFAULT_RULES,
735
- });
736
- } else if (!usedProjectConfig) {
737
- profile = ark.elevenLayerProfile;
738
- } else {
739
- const layersWithPrefixes = configLayers.filter(
740
- (layer) => (layer.intentPrefixes ?? []).length > 0
741
- );
742
- const profileLayers =
743
- layersWithPrefixes.length > 0
744
- ? layersWithPrefixes.map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }))
745
- : DEFAULT_INTENT_PREFIXES.map((d) => ({ name: d.layer, prefixes: d.prefixes }));
746
- profile = ark.createArchitectureProfile({
747
- name: 'ark.config',
748
- layers: profileLayers,
749
- rules: config.rules ?? DEFAULT_RULES,
750
- });
751
- }
752
-
753
- // Layer → forbidden ambient globals, straight from ark.config.json. Enforced by the
754
- // gate only when the target file's layer is known (same data ark-check enforces in CI).
755
- const forbiddenGlobals = Object.fromEntries(
756
- configLayers
757
- .filter(
758
- (layer) =>
759
- layer.name &&
760
- Array.isArray(layer.forbiddenGlobals) &&
761
- layer.forbiddenGlobals.some((entry) => typeof entry === 'string')
762
- )
763
- .map((layer) => [
764
- layer.name,
765
- layer.forbiddenGlobals.filter((entry) => typeof entry === 'string'),
766
- ])
767
- );
768
-
769
- // Layer → effective capability deny set (U04 walls). Same opt-in surface the
770
- // CLI enforces; the gate applies it whenever the target file's layer is known.
771
- const capabilityWalls = Object.fromEntries(
772
- configLayers
773
- .map((layer) => [layer.name, effectiveCapabilityDeny(layer)])
774
- .filter(([name, deny]) => name && deny.length > 0)
775
- );
776
-
777
- // Layers explicitly flagged as infrastructure in ark.config.json may import
778
- // infrastructure — the built-in infra-import heuristics skip them (in addition
779
- // to layers whose name conventionally signals an infra role). Lets a project
780
- // with an unconventionally-named infra layer opt in without renaming.
781
- const infrastructureLayers = configLayers
782
- .filter((layer) => layer.name && layer.mayImportInfrastructure === true)
783
- .map((layer) => layer.name);
784
-
785
- const gate = ark.createAICodeGate({
786
- architectureProfile: profile,
787
- intents,
788
- enforceIntentAllowlist: intents.length > 0,
789
- typescript: ts,
790
- forbiddenGlobals,
791
- capabilityWalls,
792
- infrastructureLayers,
793
- // Contract-first: one resolve step yields layer + relPath for rules + peerIsolation.
794
- resolveImportTarget: createImportTargetResolver(ts, args.root, config),
795
- architectureLayers: configLayers.map((layer) => ({
796
- name: layer.name,
797
- patterns: layer.patterns,
798
- })),
799
- allowNonLiteralDynamicImport: (filePath) => {
800
- if (!filePath || !Array.isArray(config.dynamicImportAllowlist)) return false;
801
- const rel = path.relative(args.root, path.resolve(args.root, filePath)).split(path.sep).join('/');
802
- return config.dynamicImportAllowlist.some((pattern) => {
803
- if (typeof pattern !== 'string') return false;
804
- try {
805
- return globToRegExp(pattern).test(rel);
806
- } catch {
807
- return false;
808
- }
809
- });
64
+ const configuredTimeout = Number(process.env.ARK_RESIDENT_HOOK_TIMEOUT_MS);
65
+ const response = await requestResidentHook({
66
+ socket: endpoint.socket,
67
+ timeoutMs: Number.isFinite(configuredTimeout) && configuredTimeout > 0
68
+ ? configuredTimeout
69
+ : 75,
70
+ request: {
71
+ protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
72
+ kind: 'hook',
73
+ root: path.resolve(args.root),
74
+ config: args.config,
75
+ manifest: args.manifest ?? null,
76
+ tsconfig: args.tsconfig ?? null,
77
+ hookRepair: args.hookRepair,
78
+ failOnNewSmells: args.failOnNewSmells,
79
+ grokHookEvent: Boolean(process.env.GROK_HOOK_EVENT),
80
+ payload,
810
81
  },
811
82
  });
812
-
83
+ if (
84
+ !response ||
85
+ response.fallback === true ||
86
+ !Number.isInteger(response.status) ||
87
+ typeof response.stdout !== 'string' ||
88
+ typeof response.stderr !== 'string'
89
+ ) {
90
+ return null;
91
+ }
92
+ return response;
93
+ }
94
+
95
+ const args = launcherArgs(process.argv);
96
+ let hookInput;
97
+ let residentHandled = false;
98
+ try {
813
99
  if (args.hook) {
814
- runHook(gate, config, args, ts);
815
- return;
816
- }
817
-
818
- if (args.sessionContext) {
819
- printSessionContext(config, profile, forbiddenGlobals, args, configPath);
820
- return;
821
- }
822
-
823
- const SERVER_INFO = { name: 'arkgate', version: ark.version };
824
- const DEFAULT_PROTOCOL = '2024-11-05';
825
-
826
- const TOOLS = [
827
- {
828
- name: 'validate_code',
829
- description:
830
- "Validate a source snippet about to be written against Ark's architecture " +
831
- '(forbidden infra imports, unknown intents, and layer-reference violations). ' +
832
- 'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
833
- 'Returns { valid, violations, autoPatch? }. autoPatch (when present) is a ' +
834
- 'mechanical-safe rewrite of the source (import type conversion) that re-validates green; ' +
835
- 'hosts may apply it instead of re-drafting. isError is true when valid is false.',
836
- inputSchema: {
837
- type: 'object',
838
- properties: {
839
- source: { type: 'string', description: 'Full source text about to be written.' },
840
- layer: {
841
- type: 'string',
842
- description:
843
- 'Architecture layer of the target file (e.g. DomainModel). If omitted, ' +
844
- 'inferred from filePath via ark.config.json layer patterns.',
845
- },
846
- filePath: {
847
- type: 'string',
848
- description: 'Target file path (used to infer layer and for messages).',
849
- },
850
- },
851
- required: ['source'],
852
- },
853
- outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
854
- },
855
- {
856
- name: 'ark_check',
857
- description:
858
- 'Run the full Ark architecture check on the project and return structured results ' +
859
- '(layer-import violations, forbidden globals, circular deps, config warnings). Use ' +
860
- 'this to answer "is the architecture currently valid?" instead of shelling out to ' +
861
- 'ark-check. Applies the baseline automatically when one exists. isError when not ok.',
862
- inputSchema: {
863
- type: 'object',
864
- properties: {
865
- strict: {
866
- type: 'boolean',
867
- description: 'Fail on config warnings too (--strict-config). Default true.',
868
- },
869
- baseline: {
870
- type: 'boolean',
871
- description:
872
- 'Suppress pre-frozen violations via .ark-baseline.json. Default: auto (on when the file exists).',
873
- },
874
- },
875
- },
876
- outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
877
- },
878
- {
879
- name: 'ark_policy_delta',
880
- description:
881
- 'Classify a complete ark.config.json transition as strengthening, neutral, ' +
882
- 'judgment-required, or weakening. Pass the previous baseConfig and optional ' +
883
- 'candidateConfig (defaults to this project contract). Weakening and judgment-required ' +
884
- 'results set isError unless acknowledgement exactly matches both policy hashes and all ' +
885
- 'blocking finding ids. Read-only; never edits the contract.',
886
- inputSchema: {
887
- type: 'object',
888
- properties: {
889
- baseConfig: {
890
- type: 'object',
891
- description: 'Previous complete ark.config.json object.',
892
- },
893
- candidateConfig: {
894
- type: 'object',
895
- description: 'Candidate complete config; defaults to the current project contract.',
896
- },
897
- acknowledgement: {
898
- type: 'object',
899
- description:
900
- 'Optional schemaVersion/basePolicyHash/candidatePolicyHash/findingIds/reason object.',
901
- },
902
- },
903
- required: ['baseConfig'],
904
- },
905
- },
906
- {
907
- name: 'ark_coverage',
908
- description:
909
- 'Report what each layer actually governs: per-layer file counts, the FULL list of ' +
910
- 'unclassified (ungoverned) files, layers whose patterns match nothing, and layers ' +
911
- 'with no rule edge. Use this to audit config coverage instead of hand-rolling ' +
912
- 'find/readdir. Report only — never an error.',
913
- inputSchema: { type: 'object', properties: {} },
914
- },
915
- {
916
- name: 'ark_place',
917
- description:
918
- 'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
919
- 'Returns layer, mayImport / mustNotImport, forbiddenGlobals, and optional goldenPattern ' +
920
- '(advisory for NEW code when .ark/golden-pattern.json exists — never clears design-weak). ' +
921
- 'Call BEFORE writing a new file. ' +
922
- 'If only description is given, returns a conventional path proposal under a governed layer. ' +
923
- 'Prefer ark_prepare_write when you already have the source snippet (place+validate+autoPatch in one call).',
924
- inputSchema: {
925
- type: 'object',
926
- properties: {
927
- filePath: {
928
- type: 'string',
929
- description: 'Path (relative to project root or absolute) of the file to place.',
930
- },
931
- description: {
932
- type: 'string',
933
- description:
934
- 'What you are building (e.g. "Remotion caption overlay"). Used when filePath is omitted to propose a path.',
935
- },
936
- },
937
- },
938
- },
939
- {
940
- name: 'ark_prepare_write',
941
- description:
942
- 'Prepare a write against the architecture contract: place (filePath and/or description) + ' +
943
- 'constrain (layer, mayImport, mustNotImport, forbiddenGlobals) + validate source + optional ' +
944
- 'mechanical-safe autoPatch + judgmentBrief when judgment is needed + contentHash for host commit. ' +
945
- 'Composes ark_place + write-gate — call BEFORE Write/Edit when you have the snippet. ' +
946
- 'Returns { filePath, layer, valid, violations?, autoPatch?, judgmentBrief?, contentHash, ... }.',
947
- inputSchema: {
948
- type: 'object',
949
- properties: {
950
- source: { type: 'string', description: 'Full source text about to be written.' },
951
- filePath: {
952
- type: 'string',
953
- description: 'Target path (preferred). Used for layer inference and autoPatch resolution.',
954
- },
955
- description: {
956
- type: 'string',
957
- description: 'When filePath omitted: propose a conventional path from this description.',
958
- },
959
- layer: {
960
- type: 'string',
961
- description: 'Optional explicit layer override (otherwise inferred from filePath).',
962
- },
963
- },
964
- required: ['source'],
965
- },
966
- },
967
- {
968
- name: 'ark_prepare_change',
969
- description:
970
- 'Validate one complete governed-source create/update/delete batch as an atomic in-memory candidate. ' +
971
- 'Catches cross-file forbidden edges and cycles before any host write, and returns ' +
972
- 'per-file content hashes plus base/candidate tree and policy hashes. Never writes files.',
973
- inputSchema: {
974
- type: 'object',
975
- properties: {
976
- changes: {
977
- type: 'array',
978
- description:
979
- 'Full candidate batch. Each item is {path, content} for create/update or {path, delete:true}.',
980
- items: {
981
- type: 'object',
982
- properties: {
983
- path: { type: 'string' },
984
- content: { type: 'string' },
985
- delete: { type: 'boolean' },
986
- },
987
- required: ['path'],
988
- },
989
- },
990
- changeMap: {
991
- type: 'object',
992
- description:
993
- 'Optional strict schema 1.0 architecture change map. Omit it to use ordinary atomic preflight.',
994
- },
995
- },
996
- required: ['changes'],
997
- },
998
- },
999
- {
1000
- name: 'ark_recommend',
1001
- description:
1002
- 'Score this repository against templates/architecture-playbook.json and return the ' +
1003
- 'tool-agnostic application shape to adopt (archetype, preset, phased layer plan, ' +
1004
- 'analogy, anti-patterns). Same structured output as ark-check --recommend --json. ' +
1005
- 'Call BEFORE generating project structure on greenfield or early-adoption repos.',
1006
- inputSchema: { type: 'object', properties: {} },
1007
- },
1008
- {
1009
- name: 'ark_suggest_include',
1010
- description:
1011
- 'Propose ark.config.json include roots from workspaces and nested TypeScript packages ' +
1012
- '(polyglot-safe). Same idea as ark-check --suggest-include. Use when coverage is empty ' +
1013
- 'or the contract misses package roots.',
1014
- inputSchema: { type: 'object', properties: {} },
1015
- },
1016
- ];
1017
-
1018
- const RESOURCES = [
1019
- {
1020
- uri: 'ark://manifest',
1021
- name: 'Ark architectural contract',
1022
- description:
1023
- 'The architecture agents must obey before generating code: layers and layer rules ' +
1024
- '(plus the full project manifest when --manifest is provided).',
1025
- mimeType: 'application/json',
1026
- },
1027
- ];
1028
-
1029
- // Layers from the 11-layer profile that this project has NOT declared, with their
1030
- // conventional directories: tells the agent where a new kind of code (a saga, a job,
1031
- // a read model, ...) belongs BEFORE it improvises a location the gate can't govern.
1032
- // A default layer is dropped when the project already claims any of its intent
1033
- // prefixes under another name (e.g. a `core` layer owning `Domain.`) — suggesting
1034
- // DomainModel there would tell the agent to create a second layer for the same
1035
- // prefix, making longest-prefix resolution ambiguous.
1036
- function suggestedLayers() {
1037
- const activeNames = new Set([
1038
- ...configLayers.map((layer) => layer.name),
1039
- ...profile.layers.map((layer) => layer.name),
1040
- ]);
1041
- const claimedPrefixes = new Set(
1042
- profile.layers.flatMap((layer) =>
1043
- (layer.prefixes ?? []).map((p) => (p.endsWith('.') ? p : `${p}.`))
1044
- )
1045
- );
1046
- return DEFAULT_INTENT_PREFIXES.filter(
1047
- (entry) =>
1048
- !activeNames.has(entry.layer) &&
1049
- !entry.prefixes.some((p) => claimedPrefixes.has(p.endsWith('.') ? p : `${p}.`))
1050
- ).map((entry) => ({
1051
- layer: entry.layer,
1052
- intentPrefixes: entry.prefixes,
1053
- conventionalDirectories: DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [],
1054
- }));
1055
- }
1056
-
1057
- function manifestText() {
1058
- if (projectManifest) {
1059
- return JSON.stringify(
1060
- { ...projectManifest, source: projectManifest.source ?? 'manifest' },
1061
- null,
1062
- 2
1063
- );
1064
- }
1065
- const suggestions = suggestedLayers();
1066
- const contractLayers = usedProjectConfig
1067
- ? configLayers.map((layer) => ({
1068
- ...layer,
1069
- prefixes: Array.isArray(layer.intentPrefixes) ? layer.intentPrefixes : [],
1070
- }))
1071
- : profile.layers;
1072
- return JSON.stringify(
1073
- {
1074
- source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
1075
- name: profile.name,
1076
- // File placement contract: every configured layer, including layers that do not
1077
- // own intent prefixes (e.g. Tooling / FrameworkAdapters).
1078
- layers: contractLayers,
1079
- // Runtime/intent resolution profile kept explicit so consumers never have to infer
1080
- // why a prefix-less file layer is absent from intent resolution.
1081
- intentLayers: profile.layers,
1082
- rules: profile.rules,
1083
- ...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
1084
- ...(Array.isArray(config.dynamicImportAllowlist)
1085
- ? { dynamicImportAllowlist: config.dynamicImportAllowlist }
1086
- : {}),
1087
- ...(config.safety && typeof config.safety === 'object'
1088
- ? { safety: config.safety }
1089
- : {}),
1090
- ...(suggestions.length > 0
1091
- ? {
1092
- suggestedLayers: suggestions,
1093
- suggestedLayersNote:
1094
- 'Layers from the default 11-layer profile this project has not declared. ' +
1095
- 'When creating a NEW kind of code that fits one of these, place it in a ' +
1096
- 'conventional directory and add the layer to ark.config.json instead of ' +
1097
- 'inventing an ungoverned location.',
1098
- }
1099
- : {}),
1100
- },
1101
- null,
1102
- 2
1103
- );
1104
- }
1105
-
1106
- function runValidate(params) {
1107
- const source = params?.arguments?.source;
1108
- if (typeof source !== 'string') {
1109
- return { content: [{ type: 'text', text: 'Missing required "source" argument.' }], isError: true };
1110
- }
1111
- const filePath = params.arguments.filePath;
1112
- const layer = params.arguments.layer ?? inferLayer(filePath, config, args.root);
1113
- const validateOnce = (src) =>
1114
- gate.validate(src, {
1115
- layer,
1116
- filePath,
1117
- });
1118
- // W1: attempt mechanical-safe single-file autoPatch (import type), re-validate or discard.
1119
- const result = validateWithAutoPatch({
1120
- source,
1121
- filePath,
1122
- root: args.root,
1123
- ts,
1124
- validate: validateOnce,
1125
- resolveTargetAbs: resolveImportFileAbs,
1126
- });
1127
- const adapterResult = createAdapterResult({
1128
- valid: result.valid,
1129
- violations: result.violations,
1130
- });
1131
- return {
1132
- content: [
1133
- {
1134
- type: 'text',
1135
- text: JSON.stringify(
1136
- {
1137
- ...adapterResult,
1138
- valid: result.valid,
1139
- violations: result.violations,
1140
- ...(result.autoPatch ? { autoPatch: result.autoPatch } : {}),
1141
- layer,
1142
- },
1143
- null,
1144
- 2
1145
- ),
1146
- },
1147
- ],
1148
- structuredContent: adapterResult,
1149
- isError: !result.valid,
1150
- };
1151
- }
1152
-
1153
- // ark_check / ark_coverage reuse the canonical CLI engine (TS resolver, baseline,
1154
- // Tarjan cycle detection) by shelling out to the sibling ark-check.mjs with --json —
1155
- // no second copy of the check logic to drift. These are occasional agent queries, not
1156
- // a hot path, so the per-call spawn cost is irrelevant.
1157
- function runArkCheckJson(extraArgs) {
1158
- return runArkCheckJsonFromRoot(args.root, args.config, extraArgs, args.manifest);
1159
- }
1160
-
1161
- function runCheckTool(params) {
1162
- const strict = params?.arguments?.strict !== false; // default true
1163
- const baselineArg = params?.arguments?.baseline;
1164
- const baselineExists = fs.existsSync(path.join(args.root, '.ark-baseline.json'));
1165
- const useBaseline = baselineArg === undefined ? baselineExists : Boolean(baselineArg);
1166
- const extra = [];
1167
- if (strict) extra.push('--strict-config');
1168
- if (useBaseline) extra.push('--baseline');
1169
- const { data, raw } = runArkCheckJson(extra);
1170
- if (!data) {
1171
- return { content: [{ type: 'text', text: `ark-check produced no JSON:\n${raw}` }], isError: true };
1172
- }
1173
- return {
1174
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
1175
- structuredContent: {
1176
- schemaVersion: data.schemaVersion,
1177
- valid: data.valid,
1178
- diagnostics: data.diagnostics,
1179
- },
1180
- isError: data.ok === false,
1181
- };
1182
- }
1183
-
1184
- function runCoverageTool() {
1185
- const { data, raw } = runArkCheckJson(['--coverage']);
1186
- if (!data) {
1187
- return {
1188
- content: [{ type: 'text', text: `ark-check --coverage produced no JSON:\n${raw}` }],
1189
- isError: true,
1190
- };
1191
- }
1192
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], isError: false };
1193
- }
1194
-
1195
- function runPolicyDeltaTool(params) {
1196
- const baseConfig = params?.arguments?.baseConfig;
1197
- if (!baseConfig || typeof baseConfig !== 'object' || Array.isArray(baseConfig)) {
1198
- return {
1199
- content: [{ type: 'text', text: 'ark_policy_delta requires baseConfig (object).' }],
1200
- isError: true,
1201
- };
1202
- }
1203
- try {
1204
- const result = ark.analyzePolicyDelta({
1205
- baseConfig,
1206
- candidateConfig: params?.arguments?.candidateConfig ?? config,
1207
- acknowledgement: params?.arguments?.acknowledgement,
1208
- });
1209
- return {
1210
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1211
- structuredContent: result,
1212
- isError: !result.valid,
1213
- };
1214
- } catch (error) {
1215
- return {
1216
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1217
- isError: true,
1218
- };
1219
- }
1220
- }
1221
-
1222
- function runRecommendTool() {
1223
- const { data, raw } = runArkCheckJson(['--recommend']);
1224
- if (!data) {
1225
- return {
1226
- content: [{ type: 'text', text: `ark-check --recommend produced no JSON:\n${raw}` }],
1227
- isError: true,
1228
- };
1229
- }
1230
- return {
1231
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
1232
- isError: data.ok === false,
1233
- };
1234
- }
1235
-
1236
- // Deterministic placement guidance (in-process; no TS resolver needed): which layer a
1237
- // path falls in, and — from the same rules ark-check enforces (default allow, explicit
1238
- // `allowed:false` denies) — which layers it may and must not import.
1239
- // Q03: when present, attach optional `.ark/golden-pattern.json` (advisory for NEW code only).
1240
- function placeResult(filePath, description) {
1241
- const golden = loadGoldenPattern(args.root);
1242
- const withGolden = (placement) => attachGoldenToPlacement(placement, golden);
1243
-
1244
- if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
1245
- const slug = description
1246
- .trim()
1247
- .toLowerCase()
1248
- .replace(/[^a-z0-9]+/g, '-')
1249
- .replace(/^-|-$/g, '')
1250
- .slice(0, 48) || 'component';
1251
- const proposedPath = `src/components/${slug}.tsx`;
1252
- const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
1253
- return withGolden({
1254
- filePath: proposedPath,
1255
- proposed: true,
1256
- description: description.trim(),
1257
- layer: layerName,
1258
- governed: Boolean(inferLayer(proposedPath, config, args.root)),
1259
- note:
1260
- 'filePath was omitted — proposed a conventional path from description. ' +
1261
- 'Pass filePath explicitly for authoritative placement.',
1262
- });
1263
- }
1264
- if (typeof filePath !== 'string' || !filePath) {
1265
- return {
1266
- error:
1267
- 'Needs filePath and/or description. ' +
1268
- 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
1269
- };
1270
- }
1271
- const layerName = inferLayer(filePath, config, args.root);
1272
- if (!layerName) {
1273
- const noLayers = configLayers.length === 0;
1274
- return withGolden({
1275
- filePath,
1276
- layer: null,
1277
- governed: noLayers,
1278
- message: noLayers
1279
- ? 'This project declares no path-based layers in ark.config.json, so a ' +
1280
- 'layer cannot be inferred from the path. The gate still enforces the ' +
1281
- 'default 11-layer profile by intent-name prefix — read ark://manifest ' +
1282
- 'for the layers and validate the actual snippet with validate_code.'
1283
- : 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
1284
- 'rules enforced). Place it under a directory a layer in ark.config.json ' +
1285
- 'matches, or add a layer. See suggestedLayers for conventional homes.',
1286
- suggestedLayers: suggestedLayers(),
1287
- });
1288
- }
1289
- const layerMeta = configLayers.find((layer) => layer.name === layerName);
1290
- const rules = config.rules ?? DEFAULT_RULES;
1291
- const otherNames = configLayers.map((layer) => layer.name).filter((name) => name !== layerName);
1292
- const mustNotImport = otherNames.filter((to) =>
1293
- rules.some((rule) => !rule.allowed && rule.from === layerName && rule.to === to)
1294
- );
1295
- const mayImport = otherNames.filter((name) => !mustNotImport.includes(name));
1296
- return withGolden({
1297
- filePath,
1298
- layer: layerName,
1299
- governed: true,
1300
- description: layerMeta?.description,
1301
- forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
1302
- ...(layerMeta?.mayImportInfrastructure ? { mayImportInfrastructure: true } : {}),
1303
- mayImport,
1304
- mustNotImport,
1305
- note:
1306
- 'mayImport = layers with no explicit deny (default is allow). Respect ' +
1307
- 'forbiddenGlobals, then verify the actual snippet with validate_code or ark_prepare_write.',
1308
- });
1309
- }
1310
-
1311
- function runPlace(params) {
1312
- const placement = placeResult(params?.arguments?.filePath, params?.arguments?.description);
1313
- if (placement.error) {
1314
- return {
1315
- content: [{ type: 'text', text: `ark_place: ${placement.error}` }],
1316
- isError: true,
1317
- };
1318
- }
1319
- return {
1320
- content: [{ type: 'text', text: JSON.stringify(placement, null, 2) }],
1321
- isError: false,
1322
- };
1323
- }
1324
-
1325
- /**
1326
- * W2: place + constrain + validate + autoPatch + judgmentBrief + contentHash.
1327
- * Composes ark_place + write-boundary gate — not a second contract.
1328
- */
1329
- function runPrepareWrite(params) {
1330
- const source = params?.arguments?.source;
1331
- const filePath = params?.arguments?.filePath;
1332
- const description = params?.arguments?.description;
1333
- if (typeof source !== 'string') {
1334
- return {
1335
- content: [
1336
- {
1337
- type: 'text',
1338
- text: 'ark_prepare_write requires "source" (string). Optional: filePath, description.',
1339
- },
1340
- ],
1341
- isError: true,
1342
- };
1343
- }
1344
- const placement = placeResult(filePath, description);
1345
- if (placement.error) {
1346
- return {
1347
- content: [{ type: 'text', text: `ark_prepare_write: ${placement.error}` }],
1348
- isError: true,
1349
- };
1350
- }
1351
- const layer =
1352
- placement.layer ||
1353
- params?.arguments?.layer ||
1354
- inferLayer(placement.filePath, config, args.root);
1355
- const validateOnce = (src) =>
1356
- gate.validate(src, {
1357
- layer,
1358
- filePath: placement.filePath,
1359
- });
1360
- const result = composePrepareWrite({
1361
- source,
1362
- placement: { ...placement, layer },
1363
- root: args.root,
1364
- ts,
1365
- validate: validateOnce,
1366
- resolveTargetAbs: resolveImportFileAbs,
1367
- });
1368
- if (!result.ok) {
1369
- return {
1370
- content: [{ type: 'text', text: result.error || 'prepare_write failed' }],
1371
- isError: true,
1372
- };
1373
- }
1374
- return {
1375
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1376
- // Align with validate_code / --hook: proposed source still invalid → isError.
1377
- // autoPatch is additive recovery guidance in the body, never soft-success.
1378
- isError: !result.valid,
1379
- };
1380
- }
1381
-
1382
- function runPrepareChange(params) {
1383
- try {
1384
- const result = prepareChangeFromRoot({
1385
- root: args.root,
1386
- config,
1387
- configSource: configPath,
1388
- changes: params?.arguments?.changes,
1389
- changeMap: params?.arguments?.changeMap,
1390
- changeMapSource: 'ark_prepare_change.changeMap',
1391
- });
1392
- return {
1393
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1394
- structuredContent: result,
1395
- isError: !result.valid,
1396
- };
1397
- } catch (error) {
1398
- return {
1399
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1400
- isError: true,
1401
- };
100
+ hookInput = fs.readFileSync(0, 'utf8');
101
+ const resident = await tryResidentHook(args, hookInput);
102
+ if (resident) {
103
+ if (resident.stdout) process.stdout.write(resident.stdout);
104
+ if (resident.stderr) process.stderr.write(resident.stderr);
105
+ process.exitCode = resident.status;
106
+ residentHandled = true;
1402
107
  }
1403
108
  }
1404
-
1405
- function runSuggestIncludeTool() {
1406
- try {
1407
- const workspaces = detectWorkspaces(args.root);
1408
- const tsPackages = detectTsPackageRoots(args.root);
1409
- const suggestedInclude = resolveIncludeRoots(args.root);
1410
- return {
1411
- content: [
1412
- {
1413
- type: 'text',
1414
- text: JSON.stringify(
1415
- {
1416
- ok: true,
1417
- workspaces,
1418
- tsPackages,
1419
- suggestedInclude:
1420
- suggestedInclude.length > 0
1421
- ? suggestedInclude
1422
- : tsPackages.length > 0
1423
- ? tsPackages
1424
- : ['src'],
1425
- next: 'npx ark-check --adopt-contract --write',
1426
- },
1427
- null,
1428
- 2
1429
- ),
1430
- },
1431
- ],
1432
- isError: false,
1433
- };
1434
- } catch (error) {
1435
- return {
1436
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1437
- isError: true,
1438
- };
1439
- }
109
+ if (!residentHandled) {
110
+ const runtime = await import('./ark-mcp-runtime.mjs');
111
+ await runtime.runArkMcp({ hookInput });
1440
112
  }
1441
-
1442
- const TOOL_HANDLERS = {
1443
- validate_code: runValidate,
1444
- ark_check: runCheckTool,
1445
- ark_policy_delta: runPolicyDeltaTool,
1446
- ark_coverage: runCoverageTool,
1447
- ark_place: runPlace,
1448
- ark_prepare_write: runPrepareWrite,
1449
- ark_prepare_change: runPrepareChange,
1450
- ark_recommend: runRecommendTool,
1451
- ark_suggest_include: runSuggestIncludeTool,
1452
- };
1453
-
1454
- const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
1455
- const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
1456
- const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
1457
-
1458
- function handle(msg) {
1459
- const { id, method, params } = msg;
1460
-
1461
- // Notifications carry no id and MUST never receive a response (JSON-RPC 2.0).
1462
- // The only notification we care about is notifications/initialized (a no-op here).
1463
- if (!('id' in msg)) return;
1464
-
1465
- switch (method) {
1466
- case 'initialize':
1467
- reply(id, {
1468
- protocolVersion: params?.protocolVersion ?? DEFAULT_PROTOCOL,
1469
- capabilities: { tools: {}, resources: {} },
1470
- serverInfo: SERVER_INFO,
1471
- });
1472
- return;
1473
- case 'ping':
1474
- reply(id, {});
1475
- return;
1476
- case 'tools/list':
1477
- reply(id, { tools: TOOLS });
1478
- return;
1479
- case 'tools/call': {
1480
- const handler = TOOL_HANDLERS[params?.name];
1481
- if (!handler) {
1482
- fail(id, -32602, `Unknown tool: ${params?.name}`);
1483
- return;
1484
- }
1485
- reply(id, handler(params));
1486
- return;
1487
- }
1488
- case 'resources/list':
1489
- reply(id, { resources: RESOURCES });
1490
- return;
1491
- case 'resources/read':
1492
- if (params?.uri !== 'ark://manifest') {
1493
- fail(id, -32602, `Unknown resource: ${params?.uri}`);
1494
- return;
1495
- }
1496
- reply(id, {
1497
- contents: [{ uri: 'ark://manifest', mimeType: 'application/json', text: manifestText() }],
1498
- });
1499
- return;
1500
- default:
1501
- fail(id, -32601, `Method not found: ${method}`);
1502
- }
1503
- }
1504
-
1505
- const rl = readline.createInterface({ input: process.stdin });
1506
- rl.on('line', (line) => {
1507
- const trimmed = line.trim();
1508
- if (!trimmed) return;
1509
- let msg;
1510
- try {
1511
- msg = JSON.parse(trimmed);
1512
- } catch {
1513
- fail(null, -32700, 'Parse error');
1514
- return;
1515
- }
1516
- try {
1517
- handle(msg);
1518
- } catch (err) {
1519
- fail(msg?.id ?? null, -32603, err instanceof Error ? err.message : String(err));
1520
- }
1521
- });
1522
- }
1523
-
1524
- main().catch((err) => {
1525
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
113
+ } catch (error) {
114
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1526
115
  process.exitCode = 1;
1527
- });
116
+ }