arkgate 2.12.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +90 -51
  3. package/bin/ark-check.mjs +156 -39
  4. package/bin/ark-mcp.mjs +119 -6
  5. package/bin/ark-shared.mjs +216 -129
  6. package/bin/ark.mjs +134 -34
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +13 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -127
  11. package/bin/lib/ci-and-commands.mjs +40 -3
  12. package/bin/lib/codex-home.mjs +7 -0
  13. package/bin/lib/config-contract.mjs +331 -0
  14. package/bin/lib/config-warnings.mjs +7 -205
  15. package/bin/lib/doctor-plan.mjs +43 -16
  16. package/bin/lib/enforcement-profiles.mjs +97 -0
  17. package/bin/lib/field-install.mjs +67 -10
  18. package/bin/lib/gate-files.mjs +42 -3
  19. package/bin/lib/graph-cycles.mjs +4 -54
  20. package/bin/lib/hook-templates.mjs +33 -1
  21. package/bin/lib/host-support-matrix.mjs +83 -0
  22. package/bin/lib/install-migrate.mjs +99 -30
  23. package/bin/lib/mcp-adoption.mjs +35 -3
  24. package/bin/lib/open-html.mjs +75 -0
  25. package/bin/lib/presets.mjs +45 -4
  26. package/bin/lib/safety-diagnostics.mjs +36 -15
  27. package/bin/lib/scan-files.mjs +12 -1
  28. package/bin/lib/skill-install.mjs +72 -1
  29. package/bin/lib/source-policy.mjs +36 -0
  30. package/bin/lib/start-preview.mjs +271 -0
  31. package/bin/lib/ts-resolve.mjs +13 -3
  32. package/bin/lib/weakest-link.mjs +417 -0
  33. package/bin/lib/write-path-capabilities.mjs +186 -0
  34. package/bin/lib/write-path-detect.mjs +62 -99
  35. package/compat/nestjs.cjs +2 -0
  36. package/compat/nestjs.d.ts +2 -0
  37. package/compat/nestjs.js +1 -0
  38. package/compat/runtime.cjs +2 -0
  39. package/compat/runtime.d.ts +2 -0
  40. package/compat/runtime.js +1 -0
  41. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  42. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  43. package/dist/eslint/index.cjs +500 -61
  44. package/dist/eslint/index.d.cts +36 -20
  45. package/dist/eslint/index.d.ts +36 -20
  46. package/dist/eslint/index.js +500 -61
  47. package/dist/index.cjs +1349 -2741
  48. package/dist/index.d.cts +449 -483
  49. package/dist/index.d.ts +449 -483
  50. package/dist/index.js +1325 -2687
  51. package/docs/agent-guide.md +58 -34
  52. package/docs/ai-gates.md +79 -21
  53. package/docs/configuration.md +97 -0
  54. package/docs/enthusiast/README.md +3 -3
  55. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  56. package/docs/migrate-from-ark-runtime-kernel.md +5 -3
  57. package/docs/package-surface.md +19 -19
  58. package/docs/production-hardening.md +31 -5
  59. package/docs/threat-model.md +65 -0
  60. package/docs/typescript-support.md +30 -3
  61. package/package.json +46 -11
  62. package/schemas/ark.analysis-result.schema.json +91 -0
  63. package/schemas/ark.config.schema.json +750 -0
  64. package/server.json +2 -2
  65. package/templates/hooks/pre-commit-ark +37 -0
  66. package/templates/skills/ark-architect.md +3 -2
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
  71. package/dist/eslint/index.cjs.map +0 -1
  72. package/dist/eslint/index.js.map +0 -1
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/nestjs/index.cjs +0 -2498
  76. package/dist/nestjs/index.cjs.map +0 -1
  77. package/dist/nestjs/index.d.cts +0 -22
  78. package/dist/nestjs/index.d.ts +0 -22
  79. package/dist/nestjs/index.js +0 -2474
  80. package/dist/nestjs/index.js.map +0 -1
  81. package/dist/runtime/index.cjs +0 -3352
  82. package/dist/runtime/index.cjs.map +0 -1
  83. package/dist/runtime/index.d.cts +0 -2
  84. package/dist/runtime/index.d.ts +0 -2
  85. package/dist/runtime/index.js +0 -3270
  86. package/dist/runtime/index.js.map +0 -1
  87. package/dist/types-BZ17b9i5.d.cts +0 -1068
  88. package/dist/types-BZ17b9i5.d.ts +0 -1068
package/bin/ark-check.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  loadTypeScript,
34
34
  detectSkillGaps,
35
35
  detectCodexHomeGap,
36
+ detectActiveAgentHost,
36
37
  missingGates,
37
38
  staleRunnerGateFiles,
38
39
  brokenMcpGateFiles,
@@ -41,6 +42,7 @@ import {
41
42
  checkArchitectureScriptSnippet,
42
43
  arkCheckCommand,
43
44
  arkPackageVersion,
45
+ compactRouterHost,
44
46
  REQUIRED_GATE_FILES,
45
47
  codexPromptsDir,
46
48
  detectWritePathCapabilities,
@@ -56,6 +58,7 @@ import {
56
58
  reportsDir,
57
59
  readJsonSafe,
58
60
  } from './lib/html-report.mjs';
61
+ import { shouldOpenHtmlReport, openHtmlInBrowser } from './lib/open-html.mjs';
59
62
  import {
60
63
  computeCoverage,
61
64
  runCoverage,
@@ -81,6 +84,8 @@ import {
81
84
  import {
82
85
  ARCHITECTURE_PRESETS,
83
86
  } from './lib/presets.mjs';
87
+ import { loadArkConfigContract, parseArkConfigJson } from './lib/config-contract.mjs';
88
+ import { createAdapterResult } from './lib/adapter-contract.mjs';
84
89
 
85
90
  import {
86
91
  collectGovernedFiles,
@@ -91,6 +96,7 @@ import {
91
96
  configWarning,
92
97
  } from './lib/config-warnings.mjs';
93
98
  import { runArchitectureScan } from './lib/architecture-scan.mjs';
99
+ import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
94
100
 
95
101
 
96
102
  function parseArgs(argv) {
@@ -101,11 +107,12 @@ function parseArgs(argv) {
101
107
  printConfig: undefined,
102
108
  tsconfig: undefined,
103
109
  json: false,
104
- strict: false,
105
110
  strictConfig: false,
106
111
  requireGates: false,
112
+ requireWriteHook: undefined,
107
113
  init: false,
108
114
  installAgentGates: false,
115
+ compact: false,
109
116
  tools: undefined,
110
117
  force: false,
111
118
  skillsOnly: false,
@@ -122,6 +129,8 @@ function parseArgs(argv) {
122
129
  applyPolicyPack: undefined,
123
130
  watch: false,
124
131
  beginner: false,
132
+ openReport: false,
133
+ noOpenReport: false,
125
134
  version: false,
126
135
  help: false,
127
136
  };
@@ -135,16 +144,19 @@ function parseArgs(argv) {
135
144
  for (let i = 2; i < argv.length; i += 1) {
136
145
  const arg = argv[i];
137
146
  if (arg === '--json') args.json = true;
138
- else if (arg === '--strict') {
139
- args.strict = true;
147
+ else if (arg === '--strict' || arg === '--strict-merge') {
140
148
  args.strictConfig = true;
141
149
  args.requireGates = true;
142
150
  }
143
151
  else if (arg === '--strict-config') args.strictConfig = true;
144
152
  else if (arg === '--require-gates') args.requireGates = true;
153
+ else if (arg === '--require-write-hook') {
154
+ args.requireWriteHook = requireValue(arg, i++).trim().toLowerCase();
155
+ }
145
156
  else if (arg === '--init') args.init = true;
146
157
  else if (arg === '--preset') args.preset = requireValue(arg, i++);
147
158
  else if (arg === '--install-agent-gates') args.installAgentGates = true;
159
+ else if (arg === '--compact') args.compact = true;
148
160
  else if (arg === '--tools') {
149
161
  // Consume the next arg only when it isn't another flag (same rule as --baseline),
150
162
  // so `--tools --force` can't silently eat --force as a "tool name".
@@ -183,6 +195,8 @@ function parseArgs(argv) {
183
195
  }
184
196
  else if (arg === '--reset-origin') args.resetOrigin = true;
185
197
  else if (arg === '--no-archive') args.noArchive = true;
198
+ else if (arg === '--open') args.openReport = true;
199
+ else if (arg === '--no-open') args.noOpenReport = true;
186
200
  else if (arg === '--baseline' || arg === '--update-baseline') {
187
201
  if (arg === '--update-baseline') args.updateBaseline = true;
188
202
  // optional path value: consume the next arg only when it isn't another flag
@@ -214,7 +228,7 @@ function usage() {
214
228
  return [
215
229
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
216
230
  ' ark-check --version',
217
- ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict | --strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
231
+ ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-merge | --strict | --strict-config] [--require-gates] [--require-write-hook <host>] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
218
232
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
219
233
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
220
234
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
@@ -224,10 +238,11 @@ function usage() {
224
238
  ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
225
239
  ' ark-check --ratchet-cores when raw graph is green (0 violations; baseline ignored), set optional:false on populated cores only (writes ark.config.json)',
226
240
  ' ark-check --watch re-run the check when governed files change (debounced)',
227
- ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
241
+ ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive] [--open|--no-open]',
228
242
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
243
+ ' Best-effort open in browser (local TTY). No-op if open fails. --no-open / ARK_NO_OPEN_REPORT=1 to skip; --open forces open.',
229
244
  ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture] [--force]',
230
- ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--skills-only] [--codex-home] [--force]',
245
+ ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--require-write-hook <host>] [--skills-only] [--codex-home] [--force]',
231
246
  ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
232
247
  ' ark-check --print-config eleven-layer',
233
248
  '',
@@ -267,8 +282,12 @@ function usage() {
267
282
  '',
268
283
  'Config warnings are advisory by default and are included in JSON output.',
269
284
  'Use --strict-config to make config warnings fail the check.',
270
- 'Use --strict for the fail-closed CI profile: --strict-config + --require-gates',
271
- 'plus the security diagnostics surfaced by doctor.',
285
+ 'Use --strict-merge for the fail-closed CI profile: --strict-config + --require-gates',
286
+ 'plus the security diagnostics surfaced by doctor. --strict is a compatibility alias.',
287
+ 'This merge profile never depends on an editor/agent hook.',
288
+ 'Add --require-write-hook claude|grok to validate a hard local write boundary for that',
289
+ 'specific host. Cursor and Codex expose advisory MCP tools plus the shared CI check;',
290
+ 'merge blocking requires repository policy to make that status required.',
272
291
  '',
273
292
  '--require-gates fails the check when AGENTS.md, .mcp.json, or the generated CI',
274
293
  'workflow is missing, so "installed but never configured" is a red CI. Combine it',
@@ -276,7 +295,8 @@ function usage() {
276
295
  '',
277
296
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
278
297
  'project, plus tool-specific templates. Known tools: claude, cursor, codex, grok',
279
- '(full MCP/hook gates) and windsurf, cline, copilot, kiro, roo, continue, gemini',
298
+ '(Claude/Grok hard-write hooks; Cursor/Codex advisory MCP; shared CI check for all) and',
299
+ 'windsurf, cline, copilot, kiro, roo, continue, gemini',
280
300
  '(instruction-tier rule files derived from the same contract).',
281
301
  'It also installs the /ark-* skills shipped in templates/skills/ into each',
282
302
  'detected tool\'s command location (.claude/skills/, .cursor/commands/,',
@@ -290,7 +310,7 @@ function usage() {
290
310
  'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
291
311
  'from their config directories (.claude/, .cursor/, .codex/, .grok/, .windsurf/,',
292
312
  '.clinerules/, .kiro/, .roo/, .continue/, .gemini/; copilot is explicit-only).',
293
- 'claude+cursor+codex are written when nothing is detected.',
313
+ 'claude+cursor+codex+grok are written when nothing is detected.',
294
314
  '',
295
315
  'Generate a starter 11-layer config:',
296
316
  ' ark-check --print-config eleven-layer > ark.config.json',
@@ -305,25 +325,12 @@ function readConfig(root, configPath) {
305
325
  ? configPath
306
326
  : path.join(root, configPath);
307
327
  if (!fs.existsSync(fullPath)) {
308
- return {
309
- include: ['src'],
310
- layers: [],
311
- rules: DEFAULT_RULES,
312
- };
328
+ return loadArkConfigContract(
329
+ { include: ['src'], layers: [], rules: DEFAULT_RULES },
330
+ fullPath
331
+ ).config;
313
332
  }
314
- const raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
315
- return {
316
- include: raw.include ?? ['src'],
317
- layers: raw.layers ?? [],
318
- rules: raw.rules ?? DEFAULT_RULES,
319
- ...(raw.exclude ? { exclude: raw.exclude } : {}),
320
- ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
321
- ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
322
- ...(raw.dynamicImportAllowlist
323
- ? { dynamicImportAllowlist: raw.dynamicImportAllowlist }
324
- : {}),
325
- ...(raw.safety ? { safety: raw.safety } : {}),
326
- };
333
+ return parseArkConfigJson(fs.readFileSync(fullPath, 'utf8'), fullPath).config;
327
334
  }
328
335
 
329
336
  /**
@@ -976,16 +983,41 @@ async function main() {
976
983
  return;
977
984
  }
978
985
 
979
- if (args.requireGates) {
980
- const missing = missingGates(args.root);
981
- if (args.strict && !detectWritePathCapabilities(args.root).hookPresent) {
982
- missing.push('PreToolUse write hook');
986
+ if (args.requireGates || args.requireWriteHook) {
987
+ let writeRequest = null;
988
+ if (args.requireWriteHook) {
989
+ writeRequest = validateHardWriteRequest({
990
+ root: args.root,
991
+ host: args.requireWriteHook,
992
+ tools: [args.requireWriteHook],
993
+ force: true,
994
+ });
995
+ if (!writeRequest.ok) {
996
+ const payload = {
997
+ ok: false,
998
+ error: 'unsupported-enforcement-profile',
999
+ message: writeRequest.error,
1000
+ };
1001
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
1002
+ else console.error(writeRequest.error);
1003
+ process.exitCode = 2;
1004
+ return;
1005
+ }
1006
+ }
1007
+
1008
+ const missing = args.requireGates ? missingGates(args.root) : [];
1009
+ if (
1010
+ writeRequest?.host &&
1011
+ !detectWritePathCapabilities(args.root, writeRequest.host).capabilities['hard-write']
1012
+ ) {
1013
+ missing.push(`${writeRequest.host} hard-write hook`);
983
1014
  }
984
1015
  if (missing.length > 0) {
985
1016
  const payload = {
986
1017
  ok: false,
987
1018
  error: 'missing-gates',
988
1019
  missing,
1020
+ ...(writeRequest?.host ? { writeHost: writeRequest.host } : {}),
989
1021
  };
990
1022
  if (args.json) {
991
1023
  console.log(JSON.stringify(payload, null, 2));
@@ -994,7 +1026,12 @@ async function main() {
994
1026
  for (const relativePath of missing) {
995
1027
  console.error(` - ${relativePath}`);
996
1028
  }
997
- console.error(`\nRun \`${arkCommand(args.root, 'ark', 'init')}\` (or \`ark-check --install-agent-gates\`) to configure enforcement.`);
1029
+ const installArgs = writeRequest?.host
1030
+ ? `--install-agent-gates --tools ${writeRequest.host} --require-write-hook ${writeRequest.host}`
1031
+ : '--install-agent-gates';
1032
+ console.error(
1033
+ `\nRun \`${arkCommand(args.root, 'ark', 'init')}\` (or \`${arkCommand(args.root, 'ark-check', installArgs)}\`) to configure enforcement.`
1034
+ );
998
1035
  }
999
1036
  process.exitCode = 1;
1000
1037
  return;
@@ -1004,7 +1041,18 @@ async function main() {
1004
1041
  // When --require-gates is the only intent (no config/architecture run needed),
1005
1042
  // callers still get a clear signal from the exit code and the human-mode line.
1006
1043
  if (!args.json) {
1007
- console.log('Ark gates present: ' + REQUIRED_GATE_FILES.join(', '));
1044
+ if (args.requireGates) {
1045
+ const compactHost = compactRouterHost(args.root);
1046
+ console.log(
1047
+ 'Ark gates present (merge profile): ' +
1048
+ (compactHost
1049
+ ? `AGENTS.md, compact host registration (${compactHost})`
1050
+ : REQUIRED_GATE_FILES.join(', '))
1051
+ );
1052
+ }
1053
+ if (writeRequest?.host) {
1054
+ console.log(`Ark hard-write hook present for ${writeRequest.host}.`);
1055
+ }
1008
1056
  }
1009
1057
  }
1010
1058
 
@@ -1277,10 +1325,28 @@ async function main() {
1277
1325
  }
1278
1326
  }
1279
1327
  }
1328
+
1329
+ // Best-effort: open the report in the default browser. If it opens, fine;
1330
+ // if not (headless, no GUI, spawn error), do nothing — never fail the check.
1331
+ // Skipped in CI / Vitest / ARK_NO_OPEN_REPORT; --open / --no-open override.
1332
+ if (
1333
+ shouldOpenHtmlReport({
1334
+ force: Boolean(args.openReport),
1335
+ noOpen: Boolean(args.noOpenReport) || Boolean(args.json),
1336
+ })
1337
+ ) {
1338
+ openHtmlInBrowser(reportPath);
1339
+ }
1280
1340
  }
1281
1341
 
1282
1342
  if (args.json) {
1343
+ const adapterResult = createAdapterResult({
1344
+ valid: ok,
1345
+ violations: activeViolations.map(enrichViolationWithFixClass),
1346
+ warnings,
1347
+ });
1283
1348
  console.log(JSON.stringify({
1349
+ ...adapterResult,
1284
1350
  ok,
1285
1351
  violations: activeViolations.map(enrichViolationWithFixClass),
1286
1352
  suppressedViolations: suppressed.length,
@@ -1385,10 +1451,19 @@ async function main() {
1385
1451
  const parts = [];
1386
1452
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
1387
1453
  if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
1454
+ // Advisory always; when session host is known and not Codex, say so so
1455
+ // /ark-upgrade does not chase home prompts as Incomplete.
1456
+ const activeHost = detectActiveAgentHost();
1457
+ const deferredNote =
1458
+ activeHost != null && activeHost !== 'codex'
1459
+ ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
1460
+ : ' ';
1388
1461
  console.log(
1389
1462
  color.dim(
1390
- `/ark-* skills in ${codexPromptsDir()} are behind this Ark (${parts.join(', ')}). ` +
1391
- `Codex loads them from there, not the repo. Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`
1463
+ `/ark-* skills in ${codexPromptsDir()} are behind this Ark (${parts.join(', ')}).` +
1464
+ deferredNote +
1465
+ `Codex loads them from $CODEX_HOME/prompts, not the repo. ` +
1466
+ `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`
1392
1467
  )
1393
1468
  );
1394
1469
  }
@@ -1430,9 +1505,13 @@ async function runWatchMode(args) {
1430
1505
  const target = path.join(args.root, entry);
1431
1506
  if (!fs.existsSync(target)) continue;
1432
1507
  try {
1433
- fs.watch(target, { recursive: true }, rerun);
1508
+ const watcher = fs.watch(target, { recursive: true }, rerun);
1509
+ watcher.on('error', () => {
1510
+ watcher.close();
1511
+ watchByPolling(target, rerun);
1512
+ });
1434
1513
  } catch {
1435
- fs.watch(target, rerun);
1514
+ watchByPolling(target, rerun);
1436
1515
  }
1437
1516
  }
1438
1517
 
@@ -1440,7 +1519,45 @@ async function runWatchMode(args) {
1440
1519
  await new Promise(() => {});
1441
1520
  }
1442
1521
 
1522
+ function watchByPolling(target, onChange) {
1523
+ let previous = watchFingerprint(target);
1524
+ setInterval(() => {
1525
+ const current = watchFingerprint(target);
1526
+ if (current === previous) return;
1527
+ previous = current;
1528
+ onChange();
1529
+ }, 250);
1530
+ }
1531
+
1532
+ function watchFingerprint(target) {
1533
+ const pending = [target];
1534
+ const entries = [];
1535
+ while (pending.length > 0) {
1536
+ const current = pending.pop();
1537
+ let stat;
1538
+ try {
1539
+ stat = fs.statSync(current);
1540
+ } catch {
1541
+ continue;
1542
+ }
1543
+ entries.push(`${current}:${stat.mtimeMs}:${stat.size}`);
1544
+ if (!stat.isDirectory()) continue;
1545
+ try {
1546
+ for (const name of fs.readdirSync(current)) pending.push(path.join(current, name));
1547
+ } catch {
1548
+ // A concurrent delete is represented by the next fingerprint.
1549
+ }
1550
+ }
1551
+ return entries.sort().join('|');
1552
+ }
1553
+
1443
1554
  main().catch((error) => {
1444
- console.error(error instanceof Error ? error.message : String(error));
1555
+ console.error(
1556
+ process.env.ARK_DEBUG_STACK === '1' && error instanceof Error
1557
+ ? error.stack
1558
+ : error instanceof Error
1559
+ ? error.message
1560
+ : String(error)
1561
+ );
1445
1562
  process.exitCode = 2;
1446
1563
  });
package/bin/ark-mcp.mjs CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
59
59
  import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
60
60
  import { composePrepareWrite } from './lib/prepare-write.mjs';
61
+ import { loadArkConfigContract } from './lib/config-contract.mjs';
62
+ import { ARK_ANALYSIS_RESULT_SCHEMA, createAdapterResult } from './lib/adapter-contract.mjs';
61
63
 
62
64
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
63
65
 
@@ -120,6 +122,11 @@ function readJson(file, { required } = {}) {
120
122
  }
121
123
  }
122
124
 
125
+ function readArkConfig(file, { required } = {}) {
126
+ const raw = readJson(file, { required });
127
+ return raw === undefined ? undefined : loadArkConfigContract(raw, file).config;
128
+ }
129
+
123
130
  function resolveInRoot(root, maybePath) {
124
131
  if (!maybePath) return undefined;
125
132
  return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
@@ -173,6 +180,8 @@ function normalizeHookPayload(payload) {
173
180
  Edit: 'Edit',
174
181
  search_replace: 'Edit',
175
182
  MultiEdit: 'MultiEdit',
183
+ ApplyPatch: 'ApplyPatch',
184
+ apply_patch: 'ApplyPatch',
176
185
  };
177
186
  const toolName = nameMap[rawName] ?? rawName;
178
187
  const filePath =
@@ -187,6 +196,70 @@ function normalizeHookPayload(payload) {
187
196
  };
188
197
  }
189
198
 
199
+ function applyCodexUpdatePatch(current, lines) {
200
+ let source = current.split('\n');
201
+ let cursor = 0;
202
+ const hunks = [];
203
+ let hunk = [];
204
+ for (const line of lines) {
205
+ if (line.startsWith('@@')) {
206
+ if (hunk.length > 0) hunks.push(hunk);
207
+ hunk = [];
208
+ } else if (/^[ +\-]/.test(line)) {
209
+ hunk.push(line);
210
+ }
211
+ }
212
+ if (hunk.length > 0) hunks.push(hunk);
213
+ for (const entries of hunks) {
214
+ const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
215
+ const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
216
+ let found = -1;
217
+ for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
218
+ if (oldLines.every((line, index) => source[at + index] === line)) {
219
+ found = at;
220
+ break;
221
+ }
222
+ }
223
+ if (found < 0) return null;
224
+ source.splice(found, oldLines.length, ...newLines);
225
+ cursor = found + newLines.length;
226
+ }
227
+ return source.join('\n');
228
+ }
229
+
230
+ function codexPatchWrites(patch, root) {
231
+ if (typeof patch !== 'string' || !patch.includes('*** Begin Patch')) return [];
232
+ const lines = patch.split('\n');
233
+ const writes = [];
234
+ for (let index = 0; index < lines.length; index += 1) {
235
+ const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
236
+ if (!match) continue;
237
+ const [, action, relativePath] = match;
238
+ const body = [];
239
+ for (index += 1; index < lines.length && !lines[index].startsWith('*** '); index += 1) {
240
+ body.push(lines[index]);
241
+ }
242
+ index -= 1;
243
+ if (action === 'Delete') continue;
244
+ const filePath = path.resolve(root, relativePath);
245
+ let content;
246
+ if (action === 'Add') {
247
+ content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
248
+ if (body.some((line) => line.startsWith('+'))) content += '\n';
249
+ } else {
250
+ let current;
251
+ try {
252
+ current = fs.readFileSync(filePath, 'utf8');
253
+ } catch {
254
+ continue;
255
+ }
256
+ content = applyCodexUpdatePatch(current, body);
257
+ }
258
+ if (typeof content === 'string') writes.push({ filePath, content });
259
+ }
260
+ return writes;
261
+ }
262
+
190
263
  /**
191
264
  * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
192
265
  * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
@@ -231,7 +304,27 @@ function runHook(gate, config, args, ts) {
231
304
  return;
232
305
  }
233
306
 
307
+ runHookPayload(payload, gate, config, args, ts);
308
+ }
309
+
310
+ function runHookPayload(payload, gate, config, args, ts) {
234
311
  const { toolName, toolInput, grokStyle } = normalizeHookPayload(payload);
312
+ if (toolName === 'ApplyPatch') {
313
+ const patch = toolInput.patch ?? toolInput.input ?? toolInput.content;
314
+ for (const write of codexPatchWrites(patch, args.root)) {
315
+ runHookPayload(
316
+ {
317
+ tool_name: 'Write',
318
+ tool_input: { file_path: write.filePath, content: write.content },
319
+ },
320
+ gate,
321
+ config,
322
+ args,
323
+ ts
324
+ );
325
+ }
326
+ return;
327
+ }
235
328
  const filePath = toolInput.file_path;
236
329
  if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return;
237
330
  if (typeof filePath !== 'string' || !SOURCE_FILE.test(filePath) || filePath.endsWith('.d.ts')) {
@@ -292,6 +385,11 @@ function runHook(gate, config, args, ts) {
292
385
  return false;
293
386
  });
294
387
  if (newViolations.length === 0) return;
388
+ const normalizedRel = rel.split(path.sep).join('/');
389
+ const adapterResult = createAdapterResult({
390
+ valid: false,
391
+ violations: newViolations.map((violation) => ({ ...violation, file: normalizedRel })),
392
+ });
295
393
 
296
394
  const lines = newViolations.map(
297
395
  (violation) =>
@@ -333,9 +431,10 @@ function runHook(gate, config, args, ts) {
333
431
  if (repair) {
334
432
  // Structured envelope for any host that can re-inject. Never writes the file.
335
433
  const repairPayload = {
434
+ ...adapterResult,
336
435
  mode: 'repair',
337
436
  decision: 'deny',
338
- filePath: rel.split(path.sep).join('/'),
437
+ filePath: normalizedRel,
339
438
  ...(layer ? { layer } : {}),
340
439
  ...(autoPatch
341
440
  ? {
@@ -361,6 +460,7 @@ function runHook(gate, config, args, ts) {
361
460
  JSON.stringify({
362
461
  decision: 'deny',
363
462
  reason: message,
463
+ analysis: adapterResult,
364
464
  ...(repair && autoPatch ? { autoPatch } : {}),
365
465
  ...(repair ? { repair: true } : {}),
366
466
  }) + '\n'
@@ -460,11 +560,11 @@ async function main() {
460
560
  const ts = await loadOptionalTypeScript();
461
561
 
462
562
  const config =
463
- (configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
464
- include: ['src'],
465
- layers: [],
466
- rules: [],
467
- };
563
+ (configPath ? readArkConfig(configPath, { required: args.configExplicit }) : undefined) ??
564
+ loadArkConfigContract(
565
+ { include: ['src'], layers: [], rules: DEFAULT_RULES },
566
+ configPath ?? 'ark.config.json'
567
+ ).config;
468
568
  if (!config.layers || config.layers.length === 0) {
469
569
  process.stderr.write(
470
570
  '[ark-mcp] warning: no layers configured — file→layer inference from config patterns ' +
@@ -610,6 +710,7 @@ async function main() {
610
710
  },
611
711
  required: ['source'],
612
712
  },
713
+ outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
613
714
  },
614
715
  {
615
716
  name: 'ark_check',
@@ -632,6 +733,7 @@ async function main() {
632
733
  },
633
734
  },
634
735
  },
736
+ outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
635
737
  },
636
738
  {
637
739
  name: 'ark_coverage',
@@ -820,12 +922,17 @@ async function main() {
820
922
  validate: validateOnce,
821
923
  resolveTargetAbs: resolveImportFileAbs,
822
924
  });
925
+ const adapterResult = createAdapterResult({
926
+ valid: result.valid,
927
+ violations: result.violations,
928
+ });
823
929
  return {
824
930
  content: [
825
931
  {
826
932
  type: 'text',
827
933
  text: JSON.stringify(
828
934
  {
935
+ ...adapterResult,
829
936
  valid: result.valid,
830
937
  violations: result.violations,
831
938
  ...(result.autoPatch ? { autoPatch: result.autoPatch } : {}),
@@ -836,6 +943,7 @@ async function main() {
836
943
  ),
837
944
  },
838
945
  ],
946
+ structuredContent: adapterResult,
839
947
  isError: !result.valid,
840
948
  };
841
949
  }
@@ -862,6 +970,11 @@ async function main() {
862
970
  }
863
971
  return {
864
972
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
973
+ structuredContent: {
974
+ schemaVersion: data.schemaVersion,
975
+ valid: data.valid,
976
+ diagnostics: data.diagnostics,
977
+ },
865
978
  isError: data.ok === false,
866
979
  };
867
980
  }