arkgate 4.8.22 → 4.8.24

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 (136) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +27 -78
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +1117 -38
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -2
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +45 -6
  35. package/bin/lib/doctor-advisories.mjs +84 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +7 -9
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-honesty.mjs +22 -0
  40. package/bin/lib/enforcement-profiles.mjs +40 -4
  41. package/bin/lib/first-run-help.mjs +3 -2
  42. package/bin/lib/flat-parent-importers.mjs +137 -0
  43. package/bin/lib/flat-parent-pilot.mjs +192 -0
  44. package/bin/lib/gate-files.mjs +29 -2
  45. package/bin/lib/git-change-scope.mjs +109 -0
  46. package/bin/lib/github-enforcement.mjs +1 -1
  47. package/bin/lib/hook-templates.mjs +18 -16
  48. package/bin/lib/host-support-matrix.mjs +3 -1
  49. package/bin/lib/html-report-advisories.mjs +2 -0
  50. package/bin/lib/html-report-depth.mjs +3 -1
  51. package/bin/lib/import-resolve.mjs +13 -1
  52. package/bin/lib/install-migrate.mjs +50 -73
  53. package/bin/lib/invariant-coverage.mjs +59 -11
  54. package/bin/lib/mcp-adoption.mjs +9 -1
  55. package/bin/lib/mcp-hook-payload.mjs +401 -61
  56. package/bin/lib/package-manager.mjs +59 -1
  57. package/bin/lib/passthrough-commands.mjs +83 -0
  58. package/bin/lib/persistence-write-hint.mjs +81 -0
  59. package/bin/lib/physical-cohesion.mjs +16 -18
  60. package/bin/lib/pilot-loop.mjs +21 -8
  61. package/bin/lib/pin-dependent-gates.mjs +117 -0
  62. package/bin/lib/policy-delta-io.mjs +168 -10
  63. package/bin/lib/prepare-change.mjs +120 -6
  64. package/bin/lib/prepare-write.mjs +1 -0
  65. package/bin/lib/presets.mjs +3 -5
  66. package/bin/lib/remediation.mjs +23 -4
  67. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  68. package/bin/lib/rules-inventory.mjs +22 -3
  69. package/bin/lib/rules-under-contract.mjs +179 -5
  70. package/bin/lib/scan-files.mjs +32 -0
  71. package/bin/lib/snippet-analysis.mjs +140 -6
  72. package/bin/lib/source-policy.mjs +34 -0
  73. package/bin/lib/stable-hash.mjs +90 -0
  74. package/bin/lib/start-install-recovery.mjs +71 -1
  75. package/bin/lib/start-preview.mjs +30 -0
  76. package/bin/lib/status-command.mjs +33 -4
  77. package/bin/lib/status-manifest.mjs +32 -4
  78. package/bin/lib/team-parliament-io.mjs +79 -75
  79. package/bin/lib/team-parliament.mjs +6 -1
  80. package/bin/lib/write-path-capabilities.mjs +4 -2
  81. package/dist/chunk-A5EIY3HI.cjs +5 -0
  82. package/dist/chunk-MBXCFLO4.js +16 -0
  83. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  84. package/dist/chunk-SVSQVGFJ.js +5 -0
  85. package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
  86. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  87. package/dist/eslint/index.cjs +17 -4
  88. package/dist/eslint/index.d.cts +359 -0
  89. package/dist/eslint/index.d.ts +178 -158
  90. package/dist/eslint/index.js +10 -4
  91. package/dist/index.cjs +38 -37
  92. package/dist/index.d.cts +5475 -0
  93. package/dist/index.d.ts +3408 -296
  94. package/dist/index.js +38 -37
  95. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  96. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  97. package/dist/nestjs/index.cjs +1 -18
  98. package/dist/nestjs/index.d.cts +22 -0
  99. package/dist/nestjs/index.d.ts +1 -3
  100. package/dist/nestjs/index.js +1 -18
  101. package/dist/order/index.cjs +1 -1
  102. package/dist/order/index.d.cts +155 -0
  103. package/dist/order/index.d.ts +15 -3
  104. package/dist/order/index.js +1 -1
  105. package/dist/runtime/index.cjs +13 -30
  106. package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
  107. package/dist/runtime/index.d.ts +2678 -108
  108. package/dist/runtime/index.js +13 -30
  109. package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
  110. package/dist/types-DRinG-vT.d.ts +1792 -0
  111. package/docs/README.md +3 -1
  112. package/docs/agent-guide.md +92 -15
  113. package/docs/ai-gates.md +160 -26
  114. package/docs/arkorder.md +32 -15
  115. package/docs/brownfield-adoption.md +5 -1
  116. package/docs/configuration.md +273 -12
  117. package/docs/develop.md +2 -1
  118. package/docs/diagnostics.md +122 -13
  119. package/docs/package-surface.md +31 -8
  120. package/docs/product-voice.md +1 -1
  121. package/package.json +59 -18
  122. package/schemas/ark.analysis-result.schema.json +15 -0
  123. package/schemas/ark.config.schema.json +163 -4
  124. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  125. package/schemas/ark.status-manifest.schema.json +22 -0
  126. package/server.json +3 -9
  127. package/templates/agent-skills/README.md +1 -1
  128. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  129. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  130. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  131. package/templates/arkrules/PersistenceAdapters.json +1 -1
  132. package/templates/arkrules/PresentationAdapters.json +1 -1
  133. package/templates/skills/ark-adopt.md +4 -1
  134. package/templates/skills/ark-order.md +4 -2
  135. package/templates/skills/ark-runtime.md +6 -1
  136. package/dist/types-fQ9-KrBj.d.ts +0 -183
@@ -30,10 +30,8 @@ import { composePrepareWrite } from './lib/prepare-write.mjs';
30
30
  import { placementDescriptionFields } from './lib/layer-description.mjs';
31
31
  import { loadArkConfigContract } from './lib/config-contract.mjs';
32
32
  import { loadEffectiveArkRulesFromDisk } from './lib/effective-contract-load.mjs';
33
- import {
34
- buildRulesInventory,
35
- inventoryToExtractionCard,
36
- } from './lib/rules-inventory.mjs';
33
+ import { loadArkRulesContract } from './lib/arkrules-contract.mjs';
34
+ import { buildRulesInventoryPayload } from './lib/rules-under-contract.mjs';
37
35
  import { ARK_ANALYSIS_RESULT_SCHEMA, createAdapterResult } from './lib/adapter-contract.mjs';
38
36
  import {
39
37
  ARK_PROJECT_IDENTITY_SCHEMA,
@@ -63,8 +61,18 @@ function arkRulesCatalogForManifest(snapshot) {
63
61
  sourceFile: r.provenance?.sourceFile,
64
62
  coverage: r.coverage,
65
63
  }));
66
- if (structure.length === 0 && invariants.length === 0) return {};
67
- return { arkRulesCatalog: { structure, invariants } };
64
+ // ADR 0012 D2: an unreferenced arkrules/*.json is visible drift (advisory).
65
+ const unreferencedFiles = (snapshot.warnings ?? []).map((w) => w.path).filter(Boolean);
66
+ if (structure.length === 0 && invariants.length === 0 && unreferencedFiles.length === 0) {
67
+ return {};
68
+ }
69
+ return {
70
+ arkRulesCatalog: {
71
+ structure,
72
+ invariants,
73
+ ...(unreferencedFiles.length > 0 ? { unreferencedFiles } : {}),
74
+ },
75
+ };
68
76
  }
69
77
  import { loadTypeScript } from './lib/typescript-host.mjs';
70
78
  import { validateSnippetAnalysis } from './lib/snippet-analysis.mjs';
@@ -86,6 +94,8 @@ import {
86
94
  formatWriteGateDeny,
87
95
  unclassifiedIncludedWriteDeny,
88
96
  requiredOwnerWriteDeny,
97
+ relativeToHookRoot,
98
+ canonicalPathLoose,
89
99
  } from './lib/mcp-hook-payload.mjs';
90
100
  import {
91
101
  canonicalizeCandidateChanges,
@@ -108,6 +118,10 @@ import {
108
118
  formatDesignDeltaBlock,
109
119
  } from './lib/design-delta.mjs';
110
120
  import { attachPolicyAdrNote } from './lib/adr-path.mjs';
121
+ import {
122
+ candidateArkRulesMatchProject,
123
+ resolvePolicyDeltaArkRules,
124
+ } from './lib/policy-delta-io.mjs';
111
125
 
112
126
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
113
127
  const arkMcpLauncher = fileURLToPath(new URL('./ark-mcp.mjs', import.meta.url));
@@ -300,8 +314,10 @@ function isResolvedAnalysisInput(relativePath, args, compilerInputs = new Set())
300
314
  const relative = String(relativePath).replace(/\\/g, '/');
301
315
  try {
302
316
  const candidate = resolvedInputIdentities(args.root, [relative]);
317
+ // Referenced ArkRules files are contract inputs too: a patch that edits one cannot
318
+ // be preflighted against the catalog loaded from disk.
303
319
  const known = resolvedInputIdentities(args.root, [
304
- args.config, args.manifest, args.tsconfig, ...compilerInputs,
320
+ args.config, args.manifest, args.tsconfig, ...compilerInputs, ...(args.arkRulesInputs ?? []),
305
321
  ]);
306
322
  if ([...candidate].some((identity) => known.has(identity))) return true;
307
323
  } catch {
@@ -345,24 +361,143 @@ function extraMergeTeethClassification(root, config) {
345
361
  };
346
362
  }
347
363
 
348
- function arkRunSnippetContext({ root, config, filePath, layer, relFile, classification }) {
349
- const extra = config?.arkRun;
350
- if (!extra) return { layer, filePath };
364
+ /**
365
+ * Load the Effective ArkRules the same way ark-check does. Errors are kept (never
366
+ * thrown) so each surface decides: write paths fail closed, manifest/inventory degrade.
367
+ */
368
+ function loadArkRulesSnapshot(root, config) {
369
+ try {
370
+ const loaded = loadEffectiveArkRulesFromDisk(root, config);
371
+ return {
372
+ arkRules: loaded.arkRules ?? null,
373
+ warnings: loaded.warnings ?? [],
374
+ errors: loaded.errors ?? [],
375
+ };
376
+ } catch (error) {
377
+ return {
378
+ arkRules: null,
379
+ warnings: [],
380
+ errors: [error instanceof Error ? error.message : String(error)],
381
+ };
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Single-file context for validateSnippetAnalysis: layer plane plus every extra plane
387
+ * CI enforces on the same file (ArkRun, ArkRules structure, ArkOrder), with the same
388
+ * classification floor. `surface` only tunes guidance text (hook vs MCP).
389
+ */
390
+ function snippetContext({
391
+ root,
392
+ config,
393
+ filePath,
394
+ layer,
395
+ relFile,
396
+ classification,
397
+ arkRulesSnapshot,
398
+ surface,
399
+ }) {
400
+ const base = { layer, filePath, ...(surface ? { surface } : {}) };
401
+ const arkRulesOn = (arkRulesSnapshot?.arkRules?.structure?.length ?? 0) > 0;
402
+ const contractErrors = arkRulesSnapshot?.errors ?? [];
403
+ if (!config?.arkRun && !config?.arkOrder && !arkRulesOn && contractErrors.length === 0) {
404
+ return base;
405
+ }
351
406
  const relative =
352
407
  relFile ||
353
408
  (typeof filePath === 'string'
354
409
  ? path.relative(root, path.resolve(root, filePath)).split(path.sep).join('/')
355
410
  : undefined);
411
+ const inRoot =
412
+ typeof relative === 'string' &&
413
+ relative !== '' &&
414
+ relative !== '..' &&
415
+ !relative.startsWith('../') &&
416
+ !path.isAbsolute(relative);
356
417
  return {
357
- layer,
358
- filePath,
418
+ ...base,
359
419
  relFile: relative,
360
- arkRun: extra,
420
+ inScope: inRoot ? isCandidateSourceInScope(config, relative) : false,
421
+ ...(config.arkRun ? { arkRun: config.arkRun } : {}),
422
+ ...(config.arkOrder ? { arkOrder: config.arkOrder } : {}),
423
+ ...(arkRulesOn ? { arkRules: arkRulesSnapshot.arkRules } : {}),
424
+ ...(contractErrors.length > 0 ? { contractErrors } : {}),
361
425
  layers: config.layers ?? [],
362
426
  classification: classification ?? extraMergeTeethClassification(root, config),
363
427
  };
364
428
  }
365
429
 
430
+ function configuredConfigPath(args) {
431
+ return path.isAbsolute(args.config) ? args.config : path.join(args.root, args.config);
432
+ }
433
+
434
+ /**
435
+ * Referenced ArkRules files (config.arkRules: layer -> project-relative path), keyed by
436
+ * canonical absolute path. These are law files exactly like ark.config.json.
437
+ */
438
+ function referencedArkRulesFiles(args, config) {
439
+ const refs = config?.arkRules;
440
+ const out = new Map();
441
+ if (!refs || typeof refs !== 'object') return out;
442
+ for (const [layer, rel] of Object.entries(refs)) {
443
+ if (typeof rel !== 'string' || rel.length === 0) continue;
444
+ out.set(canonicalPathLoose(path.resolve(args.root, rel)), { layer, rel });
445
+ }
446
+ return out;
447
+ }
448
+
449
+ function lawFileLabel(args, absolute) {
450
+ return (relativeToHookRoot(args.root, absolute) ?? path.basename(absolute))
451
+ .split(path.sep)
452
+ .join('/');
453
+ }
454
+
455
+ /**
456
+ * Law-file guard: a write that replaces the configured ark.config.json, or an ArkRules
457
+ * file it references, with content the contract loader rejects (or deletes it) would
458
+ * switch enforcement off for every later write. Deny it up front with the loader's
459
+ * reason. `content: null` / `delete: true` is a deletion.
460
+ */
461
+ function lawFileWriteDeny(args, config, writes) {
462
+ const configPath = canonicalPathLoose(configuredConfigPath(args));
463
+ const arkRulesFiles = referencedArkRulesFiles(args, config);
464
+ for (const write of writes) {
465
+ if (typeof write?.filePath !== 'string') continue;
466
+ const target = canonicalPathLoose(path.resolve(args.root, write.filePath));
467
+ const isConfig = target === configPath;
468
+ const arkRulesRef = isConfig ? undefined : arkRulesFiles.get(target);
469
+ if (!isConfig && !arkRulesRef) continue;
470
+ const deleting = write.delete === true || write.content === null;
471
+ if (!deleting && typeof write.content !== 'string') continue;
472
+ let reason = null;
473
+ if (deleting) {
474
+ reason = 'the file would be deleted';
475
+ } else {
476
+ try {
477
+ const parsed = JSON.parse(write.content);
478
+ if (isConfig) loadArkConfigContract(parsed, configPath);
479
+ else loadArkRulesContract(parsed, arkRulesRef.rel, arkRulesRef.layer);
480
+ } catch (error) {
481
+ reason = error instanceof Error ? error.message : String(error);
482
+ }
483
+ }
484
+ if (reason === null) continue;
485
+ const file = isConfig ? lawFileLabel(args, configPath) : arkRulesRef.rel;
486
+ return {
487
+ file,
488
+ message: formatWriteGateDeny({
489
+ file,
490
+ reason: `this write would leave ${file} unloadable, which switches the write gate off: ${reason}`,
491
+ ruleId: 'WRITE_GATE_UNAVAILABLE',
492
+ nextAction: isConfig
493
+ ? 'Write a complete, valid contract (same schema ark-check --strict-config reads); do not remove or empty ark.config.json.'
494
+ : `Write a complete, valid ArkRules file for layer ${arkRulesRef.layer} (same schema ark-check loads); to drop it, remove its arkRules reference from ark.config.json in a law change.`,
495
+ }),
496
+ };
497
+ }
498
+ return null;
499
+ }
500
+
366
501
  function designDeltaViolations(delta) {
367
502
  return (delta?.changes ?? []).map((change) => ({
368
503
  ruleId: 'DESIGN_SMELL_REGRESSION',
@@ -418,10 +553,30 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
418
553
  // Codex ApplyPatch is only preflighted when Ark can reconstruct every file operation.
419
554
  // An incomplete reconstruction must not be mislabeled as atomic or hard enforcement.
420
555
  if (!parsedPatch.complete) {
556
+ // Documented residual: a patch Ark cannot reconstruct stays CI-backed. Say so
557
+ // instead of allowing silently.
558
+ output.stderr(
559
+ 'Ark architecture gate: this apply_patch could not be fully reconstructed, so it was ' +
560
+ 'not preflighted here; CI (--strict-merge) still checks it.\n'
561
+ );
421
562
  emitHostAllow(output, { antigravityStyle, cursorStyle });
422
563
  return;
423
564
  }
424
565
  const patchWrites = parsedPatch.writes;
566
+ const lawDeny = lawFileWriteDeny(
567
+ args,
568
+ config,
569
+ patchWrites.map((change) => ({
570
+ filePath: change.filePath,
571
+ content: change.delete === true ? null : change.content,
572
+ delete: change.delete === true,
573
+ }))
574
+ );
575
+ if (lawDeny) {
576
+ emitHostDeny(output, { antigravityStyle, cursorStyle, grokStyle, ...lawDeny });
577
+ output.status(2);
578
+ return;
579
+ }
425
580
  const patchChangeSet = classifyChangeSet(patchWrites.map((change) => String(change.path)));
426
581
  const patchLawGate = evaluateTeamGate({
427
582
  changeSet: patchChangeSet,
@@ -608,14 +763,17 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
608
763
  emitHostAllow(output, { antigravityStyle, cursorStyle });
609
764
  return;
610
765
  }
611
- const first = result.diagnostics[0];
766
+ // Pre-existing (ratcheted) findings ride as warnings; the deny names what blocks.
767
+ const blocking = result.diagnostics.filter((d) => d.severity === 'error');
768
+ const shown = blocking.length > 0 ? blocking : result.diagnostics;
769
+ const first = shown[0];
612
770
  const message = formatWriteGateDeny({
613
771
  file: `${changes.length} file(s)`,
614
772
  reason: first?.message || `this ${toolName} breaks the architecture layers`,
615
773
  ruleId: first?.ruleId,
616
774
  nextAction: first?.nextAction,
617
775
  extraLines: [
618
- ...result.diagnostics.slice(1).map((d) => `[${d.ruleId}] ${d.message}`),
776
+ ...shown.slice(1).map((d) => `[${d.ruleId}] ${d.message}`),
619
777
  ...(designDelta && !designDelta.valid
620
778
  ? formatDesignDeltaBlock(designDelta).split('\n').slice(1)
621
779
  : []),
@@ -639,19 +797,46 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
639
797
  output.status(2);
640
798
  return;
641
799
  }
642
- const filePath = toolInput.file_path;
800
+ const rawFilePath = toolInput.file_path;
643
801
  if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) {
644
802
  // Non-file tools: fail-open. Antigravity still needs an explicit allow decision.
645
803
  emitHostAllow(output, { antigravityStyle, cursorStyle });
646
804
  return;
647
805
  }
648
- if (typeof filePath !== 'string' || !SOURCE_FILE.test(filePath) || filePath.endsWith('.d.ts')) {
806
+ if (typeof rawFilePath === 'string' && !SOURCE_FILE.test(rawFilePath)) {
807
+ // Law files are not source, but a write that leaves ark.config.json or a referenced
808
+ // ArkRules file unloadable would silently switch the gate off for every later write.
809
+ const source = proposedSource(toolName, toolInput);
810
+ const lawDeny =
811
+ typeof source === 'string'
812
+ ? lawFileWriteDeny(args, config, [{ filePath: rawFilePath, content: source }])
813
+ : null;
814
+ if (lawDeny) {
815
+ emitHostDeny(output, { antigravityStyle, cursorStyle, grokStyle, ...lawDeny });
816
+ output.status(2);
817
+ return;
818
+ }
819
+ }
820
+ if (
821
+ typeof rawFilePath !== 'string' ||
822
+ !SOURCE_FILE.test(rawFilePath) ||
823
+ rawFilePath.endsWith('.d.ts')
824
+ ) {
649
825
  emitHostAllow(output, { antigravityStyle, cursorStyle });
650
826
  return;
651
827
  }
652
- const rel = path.relative(args.root, path.resolve(filePath));
828
+ // Containment compares the caller's spelling first, then the canonical (realpath)
829
+ // spelling: /tmp vs /private/tmp or a symlinked checkout is the same workspace.
830
+ // Every downstream consumer then sees the path re-spelled under args.root.
831
+ const rootRelative = relativeToHookRoot(args.root, path.resolve(rawFilePath));
832
+ if (rootRelative === null) {
833
+ emitHostAllow(output, { antigravityStyle, cursorStyle });
834
+ return;
835
+ }
836
+ const rel = rootRelative;
837
+ const filePath = path.join(args.root, rel);
653
838
  const segments = rel.split(path.sep);
654
- if (segments[0] === '..' || segments.includes('node_modules')) {
839
+ if (segments.includes('node_modules')) {
655
840
  emitHostAllow(output, { antigravityStyle, cursorStyle });
656
841
  return;
657
842
  }
@@ -708,19 +893,17 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
708
893
  output.status(2);
709
894
  return;
710
895
  }
896
+ const context = snippetContext({
897
+ root: args.root,
898
+ config,
899
+ filePath,
900
+ layer,
901
+ relFile: normalizedRel,
902
+ arkRulesSnapshot: args.arkRulesSnapshot,
903
+ surface: 'hook',
904
+ });
711
905
  const validateOnce = (src) =>
712
- validateSnippetAnalysis({
713
- gate,
714
- ts,
715
- source: src,
716
- context: arkRunSnippetContext({
717
- root: args.root,
718
- config,
719
- filePath,
720
- layer,
721
- relFile: normalizedRel,
722
- }),
723
- });
906
+ validateSnippetAnalysis({ gate, ts, source: src, context });
724
907
  // W1: one validation pass (+ optional autoPatch). Original write still blocked when
725
908
  // invalid; hosts must apply autoPatch explicitly (never silent write).
726
909
  const result = ts
@@ -761,12 +944,18 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
761
944
  // every subsequent edit to that file un-writable while CI passes. Same-file keys ignore
762
945
  // line numbers (edits shift them); simpler than full baselineKey (no file/layer fields
763
946
  // needed — this file is fixed).
947
+ // Contract-level failures (WRITE_GATE_UNAVAILABLE: a referenced ArkRules file missing
948
+ // or invalid) say nothing about this file's content. The on-disk version reports the
949
+ // same one, so ratcheting it would cancel it and let every edit to an existing file
950
+ // skip the sensors that could not load. They are never pre-existing: always deny.
951
+ const ratchetExempt = (rule) =>
952
+ rule.startsWith('ANALYSIS_') || rule === 'WRITE_GATE_UNAVAILABLE';
764
953
  const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
765
954
  let existingCounts = new Map();
766
955
  try {
767
956
  const current = fs.readFileSync(filePath, 'utf8');
768
957
  for (const violation of validateOnce(current).violations) {
769
- if (String(violation.ruleId ?? violation.code).startsWith('ANALYSIS_')) continue;
958
+ if (ratchetExempt(String(violation.ruleId ?? violation.code))) continue;
770
959
  const key = violationKey(violation);
771
960
  existingCounts.set(key, (existingCounts.get(key) ?? 0) + 1);
772
961
  }
@@ -777,7 +966,7 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
777
966
  const rule = String(violation.ruleId ?? violation.code);
778
967
  // Incremental mid-edit parse errors are normal for agents — do not deny solely on them.
779
968
  if (rule === 'ANALYSIS_PARSE_INCOMPLETE') return false;
780
- if (rule.startsWith('ANALYSIS_')) return true;
969
+ if (ratchetExempt(rule)) return true;
781
970
  const key = violationKey(violation);
782
971
  const remaining = existingCounts.get(key) ?? 0;
783
972
  if (remaining === 0) return true;
@@ -961,7 +1150,7 @@ function residentCompilerInputs(ts, args) {
961
1150
  return [...inputs];
962
1151
  }
963
1152
 
964
- function residentHookInputs(ts, args) {
1153
+ function residentHookInputs(ts, args, config) {
965
1154
  const configPath = path.isAbsolute(args.config)
966
1155
  ? args.config
967
1156
  : path.join(args.root, args.config);
@@ -973,6 +1162,7 @@ function residentHookInputs(ts, args) {
973
1162
  return [
974
1163
  configPath,
975
1164
  ...(manifestPath ? [manifestPath] : []),
1165
+ ...arkRulesInputPaths(args.root, config),
976
1166
  ...residentCompilerInputs(ts, args),
977
1167
  ...[
978
1168
  'package.json',
@@ -985,6 +1175,15 @@ function residentHookInputs(ts, args) {
985
1175
  ];
986
1176
  }
987
1177
 
1178
+ /** Referenced ArkRules files (existing or not): editing/deleting one invalidates caches. */
1179
+ function arkRulesInputPaths(root, config) {
1180
+ const refs = config?.arkRules;
1181
+ if (!refs || typeof refs !== 'object') return [];
1182
+ return Object.values(refs)
1183
+ .filter((value) => typeof value === 'string' && value.length > 0)
1184
+ .map((value) => path.resolve(root, value));
1185
+ }
1186
+
988
1187
  function captureResidentHook(payload, gate, config, args, ts, request) {
989
1188
  let stdout = '';
990
1189
  let stderr = '';
@@ -1121,7 +1320,7 @@ async function startResidentHookControl({ args, gate, config, ts, loadedTypeScri
1121
1320
  launcher: arkMcpLauncher,
1122
1321
  });
1123
1322
  const identityPaths = [
1124
- ...residentHookInputs(ts, args),
1323
+ ...residentHookInputs(ts, args, config),
1125
1324
  ...(loadedTypeScript?.resolvedPath ? [loadedTypeScript.resolvedPath] : []),
1126
1325
  ];
1127
1326
  const identityTokens = [
@@ -1289,10 +1488,12 @@ function printSessionContext(config, profile, forbiddenGlobals, args, configPath
1289
1488
  process.stdout.write(`${lines.join('\n')}\n`);
1290
1489
  }
1291
1490
 
1292
- export async function runArkMcp({ hookInput } = {}) {
1491
+ export async function runArkMcp({ hookInput, hookRoot } = {}) {
1293
1492
  const processStartedAt = new Date().toISOString();
1294
1493
  const runtimeId = randomUUID();
1295
1494
  const args = parseArgs(process.argv);
1495
+ // The launcher already resolved the hook's project root (walk-up from the payload).
1496
+ if (args.hook && typeof hookRoot === 'string' && hookRoot) args.root = hookRoot;
1296
1497
  const requestedRoot = args.root;
1297
1498
  const resolvedRoot = canonicalPathIncludingMissing(requestedRoot);
1298
1499
  // MCP identity and file containment use the canonical workspace. Hook payloads,
@@ -1437,6 +1638,12 @@ export async function runArkMcp({ hookInput } = {}) {
1437
1638
  },
1438
1639
  });
1439
1640
 
1641
+ // Effective ArkRules load once, before the hook: write paths run the same
1642
+ // structure sensors CI runs and fail closed when a referenced file is broken.
1643
+ const effectiveArkRulesSnapshot = loadArkRulesSnapshot(args.root, config);
1644
+ args.arkRulesSnapshot = effectiveArkRulesSnapshot;
1645
+ args.arkRulesInputs = arkRulesInputPaths(args.root, config);
1646
+
1440
1647
  if (args.hook) {
1441
1648
  runHook(gate, config, args, ts, hookInput);
1442
1649
  return;
@@ -1447,23 +1654,6 @@ export async function runArkMcp({ hookInput } = {}) {
1447
1654
  return;
1448
1655
  }
1449
1656
 
1450
- const effectiveArkRulesSnapshot = (() => {
1451
- try {
1452
- const loaded = loadEffectiveArkRulesFromDisk(args.root, config);
1453
- return {
1454
- arkRules: loaded.arkRules ?? null,
1455
- warnings: loaded.warnings ?? [],
1456
- errors: loaded.errors ?? [],
1457
- };
1458
- } catch (error) {
1459
- return {
1460
- arkRules: null,
1461
- warnings: [],
1462
- errors: [error instanceof Error ? error.message : String(error)],
1463
- };
1464
- }
1465
- })();
1466
-
1467
1657
  const residentHookControl = await startResidentHookControl({
1468
1658
  args,
1469
1659
  gate,
@@ -1474,6 +1664,13 @@ export async function runArkMcp({ hookInput } = {}) {
1474
1664
  });
1475
1665
  if (residentHookControl) process.once('exit', residentHookControl.cleanup);
1476
1666
 
1667
+ const contractInputPaths = [
1668
+ configPath,
1669
+ ...(manifestPath ? [manifestPath] : []),
1670
+ ...arkRulesInputPaths(args.root, config),
1671
+ ].filter(Boolean);
1672
+ const startupContractIdentity = residentEnvironmentIdentity(contractInputPaths);
1673
+
1477
1674
  const SERVER_INFO = { name: 'arkgate', version: ark.version };
1478
1675
  const DEFAULT_PROTOCOL = '2024-11-05';
1479
1676
  const projectIdentity = createProjectIdentity({
@@ -1569,6 +1766,31 @@ export async function runArkMcp({ hookInput } = {}) {
1569
1766
  overallOk: { type: 'boolean' },
1570
1767
  },
1571
1768
  };
1769
+ // withProjectContext always spreads processPackage (FX06) into structuredContent;
1770
+ // both oneOf branches are additionalProperties:false, so it must be declared.
1771
+ const processPackageOutputSchema = {
1772
+ type: 'object',
1773
+ additionalProperties: false,
1774
+ required: [
1775
+ 'schemaVersion',
1776
+ 'notAScore',
1777
+ 'processArkgateVersion',
1778
+ 'projectInstalledVersion',
1779
+ 'processPackageMismatch',
1780
+ 'processStale',
1781
+ 'nextAction',
1782
+ ],
1783
+ properties: {
1784
+ schemaVersion: { const: '1.0' },
1785
+ notAScore: { const: true },
1786
+ processArkgateVersion: { type: ['string', 'null'] },
1787
+ projectInstalledVersion: { type: ['string', 'null'] },
1788
+ processPackageMismatch: { type: 'boolean' },
1789
+ processStale: { type: 'boolean' },
1790
+ nextAction: { type: 'string', minLength: 1 },
1791
+ },
1792
+ };
1793
+ const contractStaleOutputSchema = { type: 'boolean' };
1572
1794
  const analysisResultWithProjectSchema = {
1573
1795
  type: ARK_ANALYSIS_RESULT_SCHEMA.type,
1574
1796
  additionalProperties: ARK_ANALYSIS_RESULT_SCHEMA.additionalProperties,
@@ -1578,19 +1800,23 @@ export async function runArkMcp({ hookInput } = {}) {
1578
1800
  'projectIdentity',
1579
1801
  'binding',
1580
1802
  'authoritative',
1803
+ 'processPackage',
1581
1804
  ],
1582
1805
  properties: {
1583
1806
  ...ARK_ANALYSIS_RESULT_SCHEMA.properties,
1584
1807
  projectIdentity: projectIdentityOutputSchema,
1585
1808
  binding: PROJECT_BINDING_SCHEMA,
1586
1809
  authoritative: { type: 'boolean' },
1810
+ processPackage: processPackageOutputSchema,
1811
+ contractStale: contractStaleOutputSchema,
1812
+ lexicalValid: { type: 'boolean' },
1587
1813
  verdict: checkVerdictOutputSchema,
1588
1814
  },
1589
1815
  };
1590
1816
  const projectBindingErrorSchema = {
1591
1817
  type: 'object',
1592
1818
  additionalProperties: false,
1593
- required: ['ok', 'error', 'projectIdentity', 'binding', 'authoritative'],
1819
+ required: ['ok', 'error', 'projectIdentity', 'binding', 'authoritative', 'processPackage'],
1594
1820
  properties: {
1595
1821
  ok: { const: false },
1596
1822
  error: {
@@ -1605,9 +1831,13 @@ export async function runArkMcp({ hookInput } = {}) {
1605
1831
  projectIdentity: projectIdentityOutputSchema,
1606
1832
  binding: PROJECT_BINDING_SCHEMA,
1607
1833
  authoritative: { type: 'boolean' },
1834
+ processPackage: processPackageOutputSchema,
1835
+ contractStale: contractStaleOutputSchema,
1608
1836
  },
1609
1837
  };
1838
+ // MCP (and the TypeScript SDK) require outputSchema.type === 'object' at the top level.
1610
1839
  const projectAwareAnalysisResultSchema = {
1840
+ type: 'object',
1611
1841
  oneOf: [analysisResultWithProjectSchema, projectBindingErrorSchema],
1612
1842
  };
1613
1843
 
@@ -1812,15 +2042,28 @@ export async function runArkMcp({ hookInput } = {}) {
1812
2042
  });
1813
2043
  }
1814
2044
 
2045
+ /**
2046
+ * Contract inputs (config, manifest, referenced ArkRules) are loaded once at startup.
2047
+ * An edit afterwards makes this process's gate, manifest, and contractHash stale:
2048
+ * report it and fail project tools closed (CONTRACT_STALE) instead of answering
2049
+ * authoritatively from the old contract while ark_check reads the new one.
2050
+ */
2051
+ function contractStale() {
2052
+ return residentEnvironmentIdentity(contractInputPaths) !== startupContractIdentity;
2053
+ }
2054
+
1815
2055
  function contextFor(binding) {
1816
2056
  const processPackage = processPackageHonesty();
2057
+ const stale = contractStale();
1817
2058
  return {
1818
2059
  projectIdentity,
1819
2060
  binding,
1820
2061
  // A correctly bound project is still non-authoritative when this long-lived
1821
- // process loaded a different package version than the project now resolves.
1822
- authoritative: binding.authoritative && !processPackage.processStale,
2062
+ // process loaded a different package version (or contract) than the project
2063
+ // now has on disk.
2064
+ authoritative: binding.authoritative && !processPackage.processStale && !stale,
1823
2065
  processPackage,
2066
+ ...(stale ? { contractStale: true } : {}),
1824
2067
  };
1825
2068
  }
1826
2069
 
@@ -1897,6 +2140,31 @@ export async function runArkMcp({ hookInput } = {}) {
1897
2140
  );
1898
2141
  }
1899
2142
 
2143
+ function staleContractFailureResult(binding) {
2144
+ return withProjectContext(
2145
+ {
2146
+ content: [
2147
+ {
2148
+ type: 'text',
2149
+ text: JSON.stringify({
2150
+ ok: false,
2151
+ error: {
2152
+ code: 'CONTRACT_STALE',
2153
+ message:
2154
+ `ark.config.json, the project manifest, or a referenced ArkRules file changed ` +
2155
+ `since this MCP process started (contractHash ${projectIdentity.contractHash}). ` +
2156
+ 'Restart the MCP server, then re-run ark_identity. Until then use the ' +
2157
+ 'project-local CLI (npx arkgate-check), which reads the current contract.',
2158
+ },
2159
+ }),
2160
+ },
2161
+ ],
2162
+ isError: true,
2163
+ },
2164
+ binding
2165
+ );
2166
+ }
2167
+
1900
2168
  function staleProcessFailureResult(binding) {
1901
2169
  const processPackage = processPackageHonesty();
1902
2170
  return withProjectContext(
@@ -1941,12 +2209,16 @@ export async function runArkMcp({ hookInput } = {}) {
1941
2209
  {
1942
2210
  name: 'validate_code',
1943
2211
  description:
1944
- "Validate a source snippet about to be written against Ark's architecture " +
1945
- '(forbidden infra imports, unknown intents, and layer-reference violations). ' +
1946
- 'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
1947
- 'Returns { valid, violations, autoPatch? }. autoPatch (when present) is a ' +
1948
- 'mechanical-safe rewrite of the source (import type conversion) that re-validates green; ' +
1949
- 'hosts may apply it instead of re-drafting. isError is true when valid is false.',
2212
+ "Single-file lexical check of a source snippet against Ark's architecture " +
2213
+ '(forbidden infra imports, unknown intents, layer-reference violations, and the enforced ' +
2214
+ 'ArkRun / ArkRules structure / ArkOrder sensors CI runs on that file). One file can never ' +
2215
+ 'prove the whole candidate, so the result is always partial: valid:false and isError:true ' +
2216
+ 'until complete-candidate preflight. Read lexicalValid and violations for the snippet ' +
2217
+ 'verdict; use ark_prepare_change (or arkgate-check) for a green result. For hard ' +
2218
+ 'write-path blocking use the `arkgate-mcp --hook` PreToolUse hook, not this tool. ' +
2219
+ 'Returns { valid, lexicalValid, completeness, violations, autoPatch? }. autoPatch (when ' +
2220
+ 'present) is a mechanical-safe rewrite of the source (import type conversion); hosts may ' +
2221
+ 'apply it instead of re-drafting.',
1950
2222
  inputSchema: {
1951
2223
  type: 'object',
1952
2224
  properties: {
@@ -2009,6 +2281,20 @@ export async function runArkMcp({ hookInput } = {}) {
2009
2281
  type: 'object',
2010
2282
  description: 'Candidate complete config; defaults to the current project contract.',
2011
2283
  },
2284
+ baseArkRuleFiles: {
2285
+ type: 'object',
2286
+ description:
2287
+ 'Base ArkRules catalog as data: { "<path from baseConfig.arkRules>": <ArkRules file JSON> }. ' +
2288
+ 'Required when baseConfig maps arkRules; otherwise the call is refused rather than ' +
2289
+ 'classifying ArkRule demotions/deletions as neutral.',
2290
+ },
2291
+ candidateArkRuleFiles: {
2292
+ type: 'object',
2293
+ description:
2294
+ 'Candidate ArkRules catalog as data, same shape. Defaults to the files on disk when ' +
2295
+ 'candidateConfig is omitted or maps the same arkRules files as the project contract ' +
2296
+ '(the project ark.config.json passed verbatim counts).',
2297
+ },
2012
2298
  acknowledgement: {
2013
2299
  type: 'object',
2014
2300
  description:
@@ -2052,12 +2338,13 @@ export async function runArkMcp({ hookInput } = {}) {
2052
2338
  'What you are building. Does not invent a path — pass filePath. Without filePath the tool fail-closes.',
2053
2339
  },
2054
2340
  },
2341
+ required: ['filePath'],
2055
2342
  },
2056
2343
  },
2057
2344
  {
2058
2345
  name: 'ark_prepare_write',
2059
2346
  description:
2060
- 'Prepare a write against the architecture contract: place (filePath and/or description) + ' +
2347
+ 'Prepare a write against the architecture contract: place (filePath required; fail-closed without it — never invents a path) + ' +
2061
2348
  'constrain (layer, mayImport, mustNotImport, forbiddenGlobals) + validate source + optional ' +
2062
2349
  'mechanical-safe autoPatch + judgmentBrief when judgment is needed + contentHash for host commit. ' +
2063
2350
  'Also returns the versioned new/worsened designDelta for the proposed full file. ' +
@@ -2065,7 +2352,8 @@ export async function runArkMcp({ hookInput } = {}) {
2065
2352
  'When the matched layer has layers[].description, the JSON includes description; the field is omitted when absent. ' +
2066
2353
  'When the matched layer has layers[].trustBoundary (public|auth|admin|internal), the JSON includes trustBoundary; omitted when absent. ' +
2067
2354
  'When the matched layer has layers[].owners, the JSON includes owners; omitted when absent. ' +
2068
- 'Returns { filePath, layer, description?, trustBoundary?, owners?, valid, violations?, autoPatch?, judgmentBrief?, contentHash, ... }.',
2355
+ 'Single-file evidence is partial, so valid stays false (isError:true) until complete-candidate preflight; read lexicalValid for the snippet verdict and use ark_prepare_change for a green result. ' +
2356
+ 'Returns { filePath, layer, description?, trustBoundary?, owners?, valid, lexicalValid, violations?, autoPatch?, judgmentBrief?, contentHash, ... }.',
2069
2357
  inputSchema: {
2070
2358
  type: 'object',
2071
2359
  properties: {
@@ -2076,14 +2364,15 @@ export async function runArkMcp({ hookInput } = {}) {
2076
2364
  },
2077
2365
  description: {
2078
2366
  type: 'string',
2079
- description: 'When filePath omitted: propose a conventional path from this description.',
2367
+ description:
2368
+ 'What you are building (hint only). Does not invent a path — pass filePath. Without filePath the tool fail-closes.',
2080
2369
  },
2081
2370
  layer: {
2082
2371
  type: 'string',
2083
2372
  description: 'Optional explicit layer override (otherwise inferred from filePath).',
2084
2373
  },
2085
2374
  },
2086
- required: ['source'],
2375
+ required: ['source', 'filePath'],
2087
2376
  },
2088
2377
  },
2089
2378
  {
@@ -2159,6 +2448,8 @@ export async function runArkMcp({ hookInput } = {}) {
2159
2448
  projectIdentity: projectIdentityOutputSchema,
2160
2449
  binding: PROJECT_BINDING_SCHEMA,
2161
2450
  authoritative: { type: 'boolean' },
2451
+ processPackage: processPackageOutputSchema,
2452
+ contractStale: contractStaleOutputSchema,
2162
2453
  },
2163
2454
  },
2164
2455
  },
@@ -2304,18 +2595,15 @@ export async function runArkMcp({ hookInput } = {}) {
2304
2595
  }
2305
2596
  const filePath = params.arguments.filePath;
2306
2597
  const layer = params.arguments.layer ?? inferLayer(filePath, config, args.root);
2307
- const validateOnce = (src) =>
2308
- validateSnippetAnalysis({
2309
- gate,
2310
- ts,
2311
- source: src,
2312
- context: arkRunSnippetContext({
2313
- root: args.root,
2314
- config,
2315
- filePath,
2316
- layer,
2317
- }),
2318
- });
2598
+ const context = snippetContext({
2599
+ root: args.root,
2600
+ config,
2601
+ filePath,
2602
+ layer,
2603
+ arkRulesSnapshot: effectiveArkRulesSnapshot,
2604
+ surface: 'mcp',
2605
+ });
2606
+ const validateOnce = (src) => validateSnippetAnalysis({ gate, ts, source: src, context });
2319
2607
  // W1: attempt mechanical-safe single-file autoPatch (import type), re-validate or discard.
2320
2608
  const result = validateWithAutoPatch({
2321
2609
  source,
@@ -2331,6 +2619,12 @@ export async function runArkMcp({ hookInput } = {}) {
2331
2619
  completenessReasons: result.completenessReasons,
2332
2620
  violations: result.violations,
2333
2621
  });
2622
+ // lexicalValid is the one-file verdict (valid stays false: single-file evidence
2623
+ // is partial). Same field ark_prepare_write already exposes.
2624
+ const lexicalValid =
2625
+ typeof result.lexicalValid === 'boolean'
2626
+ ? result.lexicalValid
2627
+ : (result.violations ?? []).length === 0 && result.completeness !== 'unavailable';
2334
2628
  return {
2335
2629
  content: [
2336
2630
  {
@@ -2339,6 +2633,7 @@ export async function runArkMcp({ hookInput } = {}) {
2339
2633
  {
2340
2634
  ...adapterResult,
2341
2635
  valid: adapterResult.valid,
2636
+ lexicalValid,
2342
2637
  violations: result.violations,
2343
2638
  ...(result.autoPatch ? { autoPatch: result.autoPatch } : {}),
2344
2639
  layer,
@@ -2348,7 +2643,7 @@ export async function runArkMcp({ hookInput } = {}) {
2348
2643
  ),
2349
2644
  },
2350
2645
  ],
2351
- structuredContent: adapterResult,
2646
+ structuredContent: { ...adapterResult, lexicalValid },
2352
2647
  isError: !adapterResult.valid,
2353
2648
  };
2354
2649
  }
@@ -2472,11 +2767,24 @@ export async function runArkMcp({ hookInput } = {}) {
2472
2767
  }
2473
2768
  try {
2474
2769
  const acknowledgement = params?.arguments?.acknowledgement;
2770
+ const suppliedCandidate = params?.arguments?.candidateConfig;
2771
+ const candidateConfig = suppliedCandidate ?? config;
2772
+ // ArkRules are part of the policy: classify their transition too, or refuse.
2773
+ const { baseArkRules, candidateArkRules } = resolvePolicyDeltaArkRules({
2774
+ root: args.root,
2775
+ baseConfig,
2776
+ candidateConfig,
2777
+ candidateIsProjectConfig: candidateArkRulesMatchProject(suppliedCandidate, config),
2778
+ baseArkRuleFiles: params?.arguments?.baseArkRuleFiles,
2779
+ candidateArkRuleFiles: params?.arguments?.candidateArkRuleFiles,
2780
+ });
2475
2781
  const result = attachPolicyAdrNote(
2476
2782
  ark.analyzePolicyDelta({
2477
2783
  baseConfig,
2478
- candidateConfig: params?.arguments?.candidateConfig ?? config,
2784
+ candidateConfig,
2479
2785
  acknowledgement,
2786
+ baseArkRules,
2787
+ candidateArkRules,
2480
2788
  }),
2481
2789
  { root: args.root, acknowledgement, failClosed: true }
2482
2790
  );
@@ -2593,7 +2901,7 @@ export async function runArkMcp({ hookInput } = {}) {
2593
2901
  content: [
2594
2902
  {
2595
2903
  type: 'text',
2596
- text: 'ark_prepare_write requires "source" (string). Optional: filePath, description.',
2904
+ text: 'ark_prepare_write requires "source" (string) and "filePath". Optional: description, layer.',
2597
2905
  },
2598
2906
  ],
2599
2907
  isError: true,
@@ -2610,18 +2918,15 @@ export async function runArkMcp({ hookInput } = {}) {
2610
2918
  placement.layer ||
2611
2919
  params?.arguments?.layer ||
2612
2920
  inferLayer(placement.filePath, config, args.root);
2613
- const validateOnce = (src) =>
2614
- validateSnippetAnalysis({
2615
- gate,
2616
- ts,
2617
- source: src,
2618
- context: arkRunSnippetContext({
2619
- root: args.root,
2620
- config,
2621
- filePath: placement.filePath,
2622
- layer,
2623
- }),
2624
- });
2921
+ const context = snippetContext({
2922
+ root: args.root,
2923
+ config,
2924
+ filePath: placement.filePath,
2925
+ layer,
2926
+ arkRulesSnapshot: effectiveArkRulesSnapshot,
2927
+ surface: 'mcp',
2928
+ });
2929
+ const validateOnce = (src) => validateSnippetAnalysis({ gate, ts, source: src, context });
2625
2930
  const result = composePrepareWrite({
2626
2931
  source,
2627
2932
  placement: { ...placement, layer },
@@ -2690,7 +2995,9 @@ export async function runArkMcp({ hookInput } = {}) {
2690
2995
  try {
2691
2996
  const status = buildProjectStatusManifest({
2692
2997
  root: args.root,
2693
- config: path.basename(configPath) === 'ark.config.json' ? 'ark.config.json' : configPath,
2998
+ // Pass the server's own resolved contract through (a --config in a subdirectory
2999
+ // must not collapse to <root>/ark.config.json); keep upward discovery otherwise.
3000
+ config: args.configExplicit ? configPath : 'ark.config.json',
2694
3001
  expectedRoot: binding?.expectedRoot ?? _params?.arguments?.project?.expectedRoot,
2695
3002
  expectedProjectId:
2696
3003
  binding?.expectedProjectId ?? _params?.arguments?.project?.expectedProjectId,
@@ -2730,50 +3037,13 @@ export async function runArkMcp({ hookInput } = {}) {
2730
3037
 
2731
3038
  function runRulesInventoryTool() {
2732
3039
  try {
2733
- const governed = collectGovernedFiles(args.root, config);
2734
- const fileContents = {};
2735
- const fileLayers = {};
2736
- for (const file of governed.slice(0, 400)) {
2737
- const rel = path.relative(args.root, file).split(path.sep).join('/');
2738
- try {
2739
- fileContents[rel] = fs.readFileSync(file, 'utf8');
2740
- const layer = layerForFile(args.root, file, config.layers);
2741
- if (layer) fileLayers[rel] = layer;
2742
- } catch {
2743
- /* skip */
2744
- }
2745
- }
2746
- const contracted = [];
2747
- for (const rule of effectiveArkRulesSnapshot.arkRules?.structure ?? []) {
2748
- contracted.push(rule.id);
2749
- }
2750
- for (const inv of effectiveArkRulesSnapshot.arkRules?.invariants ?? []) {
2751
- contracted.push(inv.id);
2752
- }
2753
- const inventory = buildRulesInventory({
2754
- fileContents,
2755
- fileLayers,
2756
- layerContexts: (config.layers ?? []).map((layer) => ({
2757
- name: layer.name,
2758
- intentPrefixes: layer.intentPrefixes ?? [],
2759
- })),
2760
- contractedRuleIds: contracted,
2761
- });
2762
- const nextPilot =
2763
- inventory.candidates[0] != null
2764
- ? inventoryToExtractionCard(inventory.candidates[0])
2765
- : null;
2766
- const payload = {
2767
- ok: true,
2768
- rulesInventory: inventory,
2769
- rulesMigration: {
2770
- inventoried: inventory.inventoried,
2771
- underContract: inventory.underContract,
2772
- frozen: inventory.frozen,
2773
- notAScore: true,
2774
- },
2775
- nextPilot,
2776
- };
3040
+ // Same helper and keys as `ark-check --rules-inventory --json` (incl. coverageEvidence).
3041
+ const { payload: inventoryPayload } = buildRulesInventoryPayload(
3042
+ args.root,
3043
+ config,
3044
+ collectGovernedFiles(args.root, config)
3045
+ );
3046
+ const payload = { ok: true, ...inventoryPayload };
2777
3047
  return {
2778
3048
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
2779
3049
  structuredContent: payload,
@@ -2901,6 +3171,10 @@ export async function runArkMcp({ hookInput } = {}) {
2901
3171
  reply(id, staleProcessFailureResult(binding));
2902
3172
  return;
2903
3173
  }
3174
+ if (params?.name !== 'ark_identity' && contractStale()) {
3175
+ reply(id, staleContractFailureResult(binding));
3176
+ return;
3177
+ }
2904
3178
  try {
2905
3179
  reply(id, withProjectContext(handler(params, binding), binding));
2906
3180
  } catch (error) {