arkgate 4.1.1 → 4.2.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 (58) hide show
  1. package/CHANGELOG.md +107 -3
  2. package/README.md +16 -4
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/ark-shared.mjs +16 -4
  6. package/bin/lib/agent-gates.mjs +1 -0
  7. package/bin/lib/ci-and-commands.mjs +16 -7
  8. package/bin/lib/codex-home.mjs +90 -8
  9. package/bin/lib/design-smells.mjs +71 -9
  10. package/bin/lib/doctor-plan.mjs +36 -36
  11. package/bin/lib/effective-contract-load.mjs +73 -9
  12. package/bin/lib/enforcement-state.mjs +1 -1
  13. package/bin/lib/gate-files.mjs +441 -9
  14. package/bin/lib/github-enforcement.mjs +16 -3
  15. package/bin/lib/hook-templates.mjs +12 -11
  16. package/bin/lib/html-report-evolution.mjs +114 -0
  17. package/bin/lib/html-report.mjs +11 -89
  18. package/bin/lib/import-resolve.mjs +33 -11
  19. package/bin/lib/install-activation.mjs +87 -0
  20. package/bin/lib/install-migrate.mjs +66 -50
  21. package/bin/lib/managed-upgrade.mjs +10 -41
  22. package/bin/lib/mcp-adoption.mjs +15 -5
  23. package/bin/lib/physical-cohesion.mjs +2 -1
  24. package/bin/lib/pilot-loop.mjs +25 -8
  25. package/bin/lib/project-identity.mjs +103 -0
  26. package/bin/lib/report-snapshot-context.mjs +28 -0
  27. package/bin/lib/resident-hook.mjs +33 -9
  28. package/bin/lib/rules-inventory.mjs +100 -8
  29. package/bin/lib/skill-install.mjs +272 -22
  30. package/bin/lib/skill-write.mjs +899 -0
  31. package/bin/lib/start-preview.mjs +84 -1
  32. package/bin/lib/upgrade-command.mjs +2 -5
  33. package/dist/index.cjs +13 -13
  34. package/dist/index.d.ts +194 -2
  35. package/dist/index.js +13 -13
  36. package/docs/README.md +5 -3
  37. package/docs/agent-guide.md +110 -14
  38. package/docs/ai-gates.md +103 -18
  39. package/docs/assets/ark-write-gate.svg +2 -2
  40. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  41. package/docs/package-surface.md +15 -9
  42. package/docs/product-voice.md +13 -1
  43. package/package.json +7 -1
  44. package/schemas/ark.project-identity.schema.json +116 -0
  45. package/server.json +2 -2
  46. package/templates/skills/ark-adopt.md +9 -0
  47. package/templates/skills/ark-architect.md +12 -2
  48. package/templates/skills/ark-autopilot.md +9 -0
  49. package/templates/skills/ark-contract.md +11 -1
  50. package/templates/skills/ark-coverage.md +9 -0
  51. package/templates/skills/ark-explain.md +13 -1
  52. package/templates/skills/ark-explore.md +9 -0
  53. package/templates/skills/ark-fix.md +10 -1
  54. package/templates/skills/ark-loop.md +11 -2
  55. package/templates/skills/ark-place.md +17 -6
  56. package/templates/skills/ark-runtime.md +8 -0
  57. package/templates/skills/ark-think.md +14 -2
  58. package/templates/skills/ark-upgrade.md +9 -0
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
+ import { createHash, randomUUID } from 'node:crypto';
6
7
  import { spawnSync } from 'node:child_process';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import {
@@ -17,7 +18,7 @@ import {
17
18
  detectTsPackageRoots,
18
19
  resolveIncludeRoots,
19
20
  } from './ark-shared.mjs';
20
- import { effectiveCapabilityDeny } from './lib/analysis-engine.mjs';
21
+ import { effectiveCapabilityDeny, stableSerialize } from './lib/analysis-engine.mjs';
21
22
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
22
23
  import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
23
24
  import { composePrepareWrite } from './lib/prepare-write.mjs';
@@ -28,33 +29,34 @@ import {
28
29
  inventoryToExtractionCard,
29
30
  } from './lib/rules-inventory.mjs';
30
31
  import { ARK_ANALYSIS_RESULT_SCHEMA, createAdapterResult } from './lib/adapter-contract.mjs';
31
-
32
- function arkRulesCatalogForManifest(root, config) {
33
- if (!config?.arkRules || typeof config.arkRules !== 'object') return {};
34
- try {
35
- const loaded = loadEffectiveArkRulesFromDisk(root, config);
36
- if (loaded.errors?.length || !loaded.arkRules) return {};
37
- const structure = (loaded.arkRules.structure ?? []).map((r) => ({
38
- id: r.id,
39
- sensor: r.sensor,
40
- mode: r.mode,
41
- layer: r.provenance?.layer,
42
- sourceFile: r.provenance?.sourceFile,
43
- }));
44
- const invariants = (loaded.arkRules.invariants ?? []).map((r) => ({
45
- id: r.id,
46
- description: r.description,
47
- aggregate: r.aggregate,
48
- mode: r.mode,
49
- layer: r.provenance?.layer,
50
- sourceFile: r.provenance?.sourceFile,
51
- coverage: r.coverage,
52
- }));
53
- if (structure.length === 0 && invariants.length === 0) return {};
54
- return { arkRulesCatalog: { structure, invariants } };
55
- } catch {
56
- return {};
57
- }
32
+ import {
33
+ ARK_PROJECT_IDENTITY_SCHEMA,
34
+ PROJECT_BINDING_SCHEMA,
35
+ PROJECT_EXPECTATION_SCHEMA,
36
+ createProjectId,
37
+ createProjectIdentity,
38
+ } from './lib/project-identity.mjs';
39
+
40
+ function arkRulesCatalogForManifest(snapshot) {
41
+ if (snapshot?.errors?.length || !snapshot?.arkRules) return {};
42
+ const structure = (snapshot.arkRules.structure ?? []).map((r) => ({
43
+ id: r.id,
44
+ sensor: r.sensor,
45
+ mode: r.mode,
46
+ layer: r.provenance?.layer,
47
+ sourceFile: r.provenance?.sourceFile,
48
+ }));
49
+ const invariants = (snapshot.arkRules.invariants ?? []).map((r) => ({
50
+ id: r.id,
51
+ description: r.description,
52
+ aggregate: r.aggregate,
53
+ mode: r.mode,
54
+ layer: r.provenance?.layer,
55
+ sourceFile: r.provenance?.sourceFile,
56
+ coverage: r.coverage,
57
+ }));
58
+ if (structure.length === 0 && invariants.length === 0) return {};
59
+ return { arkRulesCatalog: { structure, invariants } };
58
60
  }
59
61
  import { loadTypeScript } from './lib/typescript-host.mjs';
60
62
  import { validateSnippetAnalysis } from './lib/snippet-analysis.mjs';
@@ -76,6 +78,7 @@ import {
76
78
  residentDoctorEnvironment,
77
79
  residentEnvironmentIdentity,
78
80
  residentHookEndpoint,
81
+ residentInvocationIdentity,
79
82
  startResidentHookServer,
80
83
  } from './lib/resident-hook.mjs';
81
84
  import { resolveArchitectureSnapshot } from './lib/architecture-scan.mjs';
@@ -110,6 +113,7 @@ function parseArgs(argv) {
110
113
  hookRepair: false,
111
114
  failOnNewSmells: false,
112
115
  sessionContext: false,
116
+ rootEnv: [],
113
117
  };
114
118
  for (let i = 2; i < argv.length; i += 1) {
115
119
  const a = argv[i];
@@ -120,7 +124,13 @@ function parseArgs(argv) {
120
124
  } else if (a === '--session-context') args.sessionContext = true;
121
125
  else if (a === '--fail-on-new-smells') args.failOnNewSmells = true;
122
126
  else if (a === '--root') args.root = path.resolve(argv[++i]);
123
- else if (a === '--config') {
127
+ else if (a === '--root-env') {
128
+ const names = String(argv[++i] ?? '')
129
+ .split(',')
130
+ .map((name) => name.trim())
131
+ .filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name));
132
+ args.rootEnv.push(...names);
133
+ } else if (a === '--config') {
124
134
  args.config = argv[++i];
125
135
  args.configExplicit = true;
126
136
  } else if (a === '--manifest') args.manifest = argv[++i];
@@ -131,6 +141,13 @@ function parseArgs(argv) {
131
141
  args.hookRepair = true;
132
142
  }
133
143
  if (envTruthy('ARK_FAIL_ON_NEW_SMELLS')) args.failOnNewSmells = true;
144
+ for (const name of args.rootEnv) {
145
+ const value = process.env[name];
146
+ if (typeof value === 'string' && value.trim() !== '') {
147
+ args.root = path.resolve(value.trim());
148
+ break;
149
+ }
150
+ }
134
151
  return args;
135
152
  }
136
153
 
@@ -156,9 +173,66 @@ function readArkConfig(file, { required } = {}) {
156
173
  return raw === undefined ? undefined : loadArkConfigContract(raw, file).config;
157
174
  }
158
175
 
159
- function resolveInRoot(root, maybePath) {
176
+ function sha256Hex(value) {
177
+ return createHash('sha256').update(value, 'utf8').digest('hex');
178
+ }
179
+
180
+ function normalizeWindowsDriveLetter(candidate) {
181
+ if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/]/.test(candidate)) {
182
+ return candidate;
183
+ }
184
+ return `${candidate[0].toUpperCase()}${candidate.slice(1)}`;
185
+ }
186
+
187
+ function canonicalPathIncludingMissing(candidate) {
188
+ const absolute = normalizeWindowsDriveLetter(path.resolve(candidate));
189
+ let existing = absolute;
190
+ const missing = [];
191
+ while (!fs.existsSync(existing)) {
192
+ try {
193
+ if (fs.lstatSync(existing).isSymbolicLink()) {
194
+ const error = new Error(
195
+ `PROJECT_ROOT_MISMATCH: cannot canonicalize dangling symlink ${existing}.`
196
+ );
197
+ error.code = 'PROJECT_ROOT_MISMATCH';
198
+ throw error;
199
+ }
200
+ } catch (error) {
201
+ if (error?.code === 'PROJECT_ROOT_MISMATCH') throw error;
202
+ }
203
+ const parent = path.dirname(existing);
204
+ if (parent === existing) return absolute;
205
+ missing.unshift(path.basename(existing));
206
+ existing = parent;
207
+ }
208
+ return normalizeWindowsDriveLetter(path.join(fs.realpathSync(existing), ...missing));
209
+ }
210
+
211
+ function pathIsWithin(root, candidate) {
212
+ const relative = path.relative(root, candidate);
213
+ return (
214
+ relative === '' ||
215
+ (relative !== '..' &&
216
+ !relative.startsWith(`..${path.sep}`) &&
217
+ !path.isAbsolute(relative))
218
+ );
219
+ }
220
+
221
+ function resolveContainedProjectPath(root, maybePath, label) {
160
222
  if (!maybePath) return undefined;
161
- return path.isAbsolute(maybePath) ? maybePath : path.join(root, maybePath);
223
+ const requested = path.isAbsolute(maybePath)
224
+ ? maybePath
225
+ : path.resolve(root, maybePath);
226
+ const canonical = canonicalPathIncludingMissing(requested);
227
+ if (!pathIsWithin(root, canonical)) {
228
+ const error = new Error(
229
+ `PROJECT_ROOT_MISMATCH: ${label} resolves outside the configured ArkGate root ` +
230
+ `(${canonical} is not inside ${root}).`
231
+ );
232
+ error.code = 'PROJECT_ROOT_MISMATCH';
233
+ throw error;
234
+ }
235
+ return canonical;
162
236
  }
163
237
 
164
238
  function inferLayer(filePath, config, root) {
@@ -831,7 +905,7 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
831
905
  'machine-readable source (still hard-blocks; host re-injects).',
832
906
  ]
833
907
  : []),
834
- 'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
908
+ 'Fix the violations and retry. Call the project-bound ark_manifest MCP tool for the architecture contract.',
835
909
  ].join('\n');
836
910
  output.stderr(message + '\n');
837
911
 
@@ -963,12 +1037,19 @@ function captureResidentHook(payload, gate, config, args, ts, request) {
963
1037
  let stdout = '';
964
1038
  let stderr = '';
965
1039
  let status = 0;
1040
+ const requestArgs = {
1041
+ ...args,
1042
+ root: request.root,
1043
+ config: request.config,
1044
+ manifest: request.manifest ?? undefined,
1045
+ tsconfig: request.tsconfig ?? undefined,
1046
+ };
966
1047
  runHookPayload(
967
1048
  payload,
968
1049
  gate,
969
1050
  config,
970
1051
  {
971
- ...args,
1052
+ ...requestArgs,
972
1053
  hookRepair: request.hookRepair === true,
973
1054
  failOnNewSmells: request.failOnNewSmells === true,
974
1055
  },
@@ -994,10 +1075,8 @@ function sameResidentInvocation(request, args, kind) {
994
1075
  request?.protocolVersion === RESIDENT_HOOK_PROTOCOL_VERSION &&
995
1076
  request?.kind === kind &&
996
1077
  typeof request.root === 'string' &&
997
- path.resolve(request.root) === path.resolve(args.root) &&
998
- request.config === args.config &&
999
- (request.manifest ?? null) === (args.manifest ?? null) &&
1000
- (request.tsconfig ?? null) === (args.tsconfig ?? null)
1078
+ JSON.stringify(residentInvocationIdentity(request)) ===
1079
+ JSON.stringify(residentInvocationIdentity(args))
1001
1080
  );
1002
1081
  }
1003
1082
 
@@ -1034,7 +1113,7 @@ function createResidentDoctorSession(args, config, ts) {
1034
1113
  const resolutionInputs = snapshotInputPaths(snapshot.inputs);
1035
1114
  const ledger = createResidentInputLedger([...after.paths, ...resolutionInputs]);
1036
1115
  if (!ledger.matches([...after.paths, ...resolutionInputs])) return null;
1037
- return { files: after.files, ledger, resolutionInputs, rules, snapshot };
1116
+ return { root: args.root, files: after.files, ledger, resolutionInputs, rules, snapshot };
1038
1117
  }
1039
1118
 
1040
1119
  function verifyResidentDoctorSession(session) {
@@ -1043,7 +1122,10 @@ function verifyResidentDoctorSession(session) {
1043
1122
 
1044
1123
  function renderResidentDoctor(session, args, config, ts) {
1045
1124
  let stdout = '';
1046
- runDoctor(args.root, config, session.files, session.rules, session.snapshot.result.violations, true, {
1125
+ const files = session.files.map((file) =>
1126
+ path.resolve(args.root, path.relative(session.root, file))
1127
+ );
1128
+ runDoctor(args.root, config, files, session.rules, session.snapshot.result.violations, true, {
1047
1129
  configPath: path.isAbsolute(args.config) ? args.config : path.join(args.root, args.config),
1048
1130
  configMissing: !fs.existsSync(
1049
1131
  path.isAbsolute(args.config) ? args.config : path.join(args.root, args.config)
@@ -1145,7 +1227,18 @@ async function startResidentHookControl({ args, gate, config, ts, loadedTypeScri
1145
1227
  if (!doctorSession || !verified) {
1146
1228
  return { protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION, fallback: true };
1147
1229
  }
1148
- const response = renderResidentDoctor(doctorSession, args, config, ts);
1230
+ const response = renderResidentDoctor(
1231
+ doctorSession,
1232
+ {
1233
+ ...args,
1234
+ root: request.root,
1235
+ config: request.config,
1236
+ manifest: request.manifest ?? undefined,
1237
+ tsconfig: request.tsconfig ?? undefined,
1238
+ },
1239
+ config,
1240
+ ts
1241
+ );
1149
1242
  if (!verifyResidentDoctorSession(doctorSession)) {
1150
1243
  doctorSession = undefined;
1151
1244
  return { protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION, fallback: true };
@@ -1211,7 +1304,7 @@ function printSessionContext(config, profile, forbiddenGlobals, args, configPath
1211
1304
 
1212
1305
  const denied = (profile.rules ?? []).filter((rule) => !rule.allowed).length;
1213
1306
  lines.push(
1214
- `Rules: ${denied} denied layer edge(s). Full contract: ark://manifest MCP resource.`
1307
+ `Rules: ${denied} denied layer edge(s). Full contract: project-bound ark_manifest MCP tool.`
1215
1308
  );
1216
1309
 
1217
1310
  // Advisory output: a malformed baseline must not abort the summary.
@@ -1243,8 +1336,17 @@ function printSessionContext(config, profile, forbiddenGlobals, args, configPath
1243
1336
  }
1244
1337
 
1245
1338
  export async function runArkMcp({ hookInput } = {}) {
1339
+ const processStartedAt = new Date().toISOString();
1340
+ const runtimeId = randomUUID();
1246
1341
  const args = parseArgs(process.argv);
1247
- const configPath = resolveInRoot(args.root, args.config);
1342
+ const requestedRoot = args.root;
1343
+ const resolvedRoot = canonicalPathIncludingMissing(requestedRoot);
1344
+ // MCP identity and file containment use the canonical workspace. Hook payloads,
1345
+ // however, carry paths in the caller's spelling (macOS commonly aliases /var to
1346
+ // /private/var); keep that spelling so same-workspace writes are not mistaken for
1347
+ // out-of-root paths.
1348
+ if (!args.hook && !args.sessionContext) args.root = resolvedRoot;
1349
+ const configPath = resolveContainedProjectPath(resolvedRoot, args.config, 'ark.config.json');
1248
1350
 
1249
1351
  // SessionStart contract injection is only meaningful in Ark-governed projects. Bail
1250
1352
  // out silently (before loading dist) when there is no config, so the hook is safe
@@ -1271,8 +1373,11 @@ export async function runArkMcp({ hookInput } = {}) {
1271
1373
  );
1272
1374
  }
1273
1375
 
1274
- const manifestPath = resolveInRoot(args.root, args.manifest);
1376
+ const manifestPath = resolveContainedProjectPath(resolvedRoot, args.manifest, 'project manifest');
1275
1377
  const projectManifest = manifestPath ? readJson(manifestPath, { required: true }) : undefined;
1378
+ if (args.tsconfig) {
1379
+ args.tsconfig = resolveContainedProjectPath(resolvedRoot, args.tsconfig, 'TypeScript config');
1380
+ }
1276
1381
  args.projectManifest = projectManifest;
1277
1382
 
1278
1383
  const intents = Array.isArray(projectManifest?.intents)
@@ -1388,6 +1493,23 @@ export async function runArkMcp({ hookInput } = {}) {
1388
1493
  return;
1389
1494
  }
1390
1495
 
1496
+ const effectiveArkRulesSnapshot = (() => {
1497
+ try {
1498
+ const loaded = loadEffectiveArkRulesFromDisk(args.root, config);
1499
+ return {
1500
+ arkRules: loaded.arkRules ?? null,
1501
+ warnings: loaded.warnings ?? [],
1502
+ errors: loaded.errors ?? [],
1503
+ };
1504
+ } catch (error) {
1505
+ return {
1506
+ arkRules: null,
1507
+ warnings: [],
1508
+ errors: [error instanceof Error ? error.message : String(error)],
1509
+ };
1510
+ }
1511
+ })();
1512
+
1391
1513
  const residentHookControl = await startResidentHookControl({
1392
1514
  args,
1393
1515
  gate,
@@ -1400,8 +1522,428 @@ export async function runArkMcp({ hookInput } = {}) {
1400
1522
 
1401
1523
  const SERVER_INFO = { name: 'arkgate', version: ark.version };
1402
1524
  const DEFAULT_PROTOCOL = '2024-11-05';
1525
+ const projectIdentity = createProjectIdentity({
1526
+ projectId: createProjectId(resolvedRoot, configPath, sha256Hex),
1527
+ resolvedRoot,
1528
+ resolvedConfigPath: configPath,
1529
+ arkgateVersion: ark.version,
1530
+ contractHash: `sha256:${sha256Hex(
1531
+ stableSerialize({
1532
+ config,
1533
+ projectManifest: projectManifest ?? null,
1534
+ arkRules: effectiveArkRulesSnapshot,
1535
+ })
1536
+ )}`,
1537
+ contractSource: projectManifest
1538
+ ? 'manifest'
1539
+ : fs.existsSync(configPath)
1540
+ ? 'project'
1541
+ : 'default-profile',
1542
+ runtimeId,
1543
+ processStartedAt,
1544
+ });
1545
+ const projectIdentityOutputSchema = {
1546
+ type: ARK_PROJECT_IDENTITY_SCHEMA.type,
1547
+ additionalProperties: ARK_PROJECT_IDENTITY_SCHEMA.additionalProperties,
1548
+ required: ARK_PROJECT_IDENTITY_SCHEMA.required,
1549
+ properties: ARK_PROJECT_IDENTITY_SCHEMA.properties,
1550
+ };
1551
+ const verificationOutputSchema = {
1552
+ anyOf: [{ type: 'boolean' }, { const: 'unverified' }],
1553
+ };
1554
+ const checkVerdictOutputSchema = {
1555
+ type: 'object',
1556
+ additionalProperties: false,
1557
+ required: ['identity', 'completeness', 'graph', 'coverage', 'gates', 'overallOk'],
1558
+ properties: {
1559
+ identity: {
1560
+ type: 'object',
1561
+ additionalProperties: false,
1562
+ required: ['status', 'ok'],
1563
+ properties: {
1564
+ status: { enum: ['matched', 'unverified', 'mismatch'] },
1565
+ ok: { type: 'boolean' },
1566
+ },
1567
+ },
1568
+ completeness: {
1569
+ type: 'object',
1570
+ additionalProperties: false,
1571
+ required: ['status', 'ok'],
1572
+ properties: {
1573
+ status: { type: 'string', minLength: 1 },
1574
+ ok: { type: 'boolean' },
1575
+ },
1576
+ },
1577
+ graph: {
1578
+ type: 'object',
1579
+ additionalProperties: false,
1580
+ required: ['ok', 'violations'],
1581
+ properties: {
1582
+ ok: { type: 'boolean' },
1583
+ violations: { type: ['integer', 'null'], minimum: 0 },
1584
+ },
1585
+ },
1586
+ coverage: {
1587
+ type: 'object',
1588
+ additionalProperties: false,
1589
+ required: ['ok', 'governedPercent', 'unclassified', 'emptyScope'],
1590
+ properties: {
1591
+ ok: { type: 'boolean' },
1592
+ governedPercent: { type: ['number', 'null'], minimum: 0, maximum: 100 },
1593
+ unclassified: { type: ['integer', 'null'], minimum: 0 },
1594
+ emptyScope: { type: ['boolean', 'null'] },
1595
+ },
1596
+ },
1597
+ gates: {
1598
+ type: 'object',
1599
+ additionalProperties: false,
1600
+ required: [
1601
+ 'ok',
1602
+ 'localWriteActive',
1603
+ 'advisoryMcpActive',
1604
+ 'advisoryMcpRuntimeObserved',
1605
+ 'ciMergeActive',
1606
+ ],
1607
+ properties: {
1608
+ ok: { type: 'boolean' },
1609
+ localWriteActive: verificationOutputSchema,
1610
+ advisoryMcpActive: { const: true },
1611
+ advisoryMcpRuntimeObserved: { const: true },
1612
+ ciMergeActive: verificationOutputSchema,
1613
+ },
1614
+ },
1615
+ overallOk: { type: 'boolean' },
1616
+ },
1617
+ };
1618
+ const analysisResultWithProjectSchema = {
1619
+ type: ARK_ANALYSIS_RESULT_SCHEMA.type,
1620
+ additionalProperties: ARK_ANALYSIS_RESULT_SCHEMA.additionalProperties,
1621
+ allOf: ARK_ANALYSIS_RESULT_SCHEMA.allOf,
1622
+ required: [
1623
+ ...(ARK_ANALYSIS_RESULT_SCHEMA.required ?? []),
1624
+ 'projectIdentity',
1625
+ 'binding',
1626
+ 'authoritative',
1627
+ ],
1628
+ properties: {
1629
+ ...ARK_ANALYSIS_RESULT_SCHEMA.properties,
1630
+ projectIdentity: projectIdentityOutputSchema,
1631
+ binding: PROJECT_BINDING_SCHEMA,
1632
+ authoritative: { type: 'boolean' },
1633
+ verdict: checkVerdictOutputSchema,
1634
+ },
1635
+ };
1636
+ const projectBindingErrorSchema = {
1637
+ type: 'object',
1638
+ additionalProperties: false,
1639
+ required: ['ok', 'error', 'projectIdentity', 'binding', 'authoritative'],
1640
+ properties: {
1641
+ ok: { const: false },
1642
+ error: {
1643
+ type: 'object',
1644
+ additionalProperties: false,
1645
+ required: ['code', 'message'],
1646
+ properties: {
1647
+ code: { type: 'string', minLength: 1 },
1648
+ message: { type: 'string', minLength: 1 },
1649
+ },
1650
+ },
1651
+ projectIdentity: projectIdentityOutputSchema,
1652
+ binding: PROJECT_BINDING_SCHEMA,
1653
+ authoritative: { type: 'boolean' },
1654
+ },
1655
+ };
1656
+ const projectAwareAnalysisResultSchema = {
1657
+ oneOf: [analysisResultWithProjectSchema, projectBindingErrorSchema],
1658
+ };
1659
+
1660
+ function unverifiedBinding() {
1661
+ return {
1662
+ status: 'unverified',
1663
+ authoritative: false,
1664
+ message:
1665
+ 'No project expectation was supplied. Call ark_identity with project.expectedRoot ' +
1666
+ 'before treating MCP evidence as authoritative.',
1667
+ };
1668
+ }
1669
+
1670
+ function mismatchBinding(code, message, expectation = {}) {
1671
+ return {
1672
+ status: 'mismatch',
1673
+ authoritative: false,
1674
+ ...(expectation.expectedRoot ? { expectedRoot: expectation.expectedRoot } : {}),
1675
+ ...(expectation.expectedProjectId
1676
+ ? { expectedProjectId: expectation.expectedProjectId }
1677
+ : {}),
1678
+ code,
1679
+ message,
1680
+ };
1681
+ }
1682
+
1683
+ function nestedProjectConfig(expectedRoot) {
1684
+ let current = expectedRoot;
1685
+ while (pathIsWithin(resolvedRoot, current) && current !== resolvedRoot) {
1686
+ const candidate = path.join(current, 'ark.config.json');
1687
+ if (fs.existsSync(candidate)) return fs.realpathSync(candidate);
1688
+ const parent = path.dirname(current);
1689
+ if (parent === current) break;
1690
+ current = parent;
1691
+ }
1692
+ return undefined;
1693
+ }
1694
+
1695
+ function bindingForExpectation(expectation) {
1696
+ if (expectation === undefined) return unverifiedBinding();
1697
+ if (!expectation || typeof expectation !== 'object' || Array.isArray(expectation)) {
1698
+ return mismatchBinding(
1699
+ 'INVALID_PROJECT_EXPECTATION',
1700
+ 'project must be an object containing expectedRoot and/or expectedProjectId.'
1701
+ );
1702
+ }
1703
+ const rawExpectedRoot = expectation.expectedRoot;
1704
+ const rawExpectedProjectId = expectation.expectedProjectId;
1705
+ if (
1706
+ rawExpectedRoot !== undefined &&
1707
+ (typeof rawExpectedRoot !== 'string' ||
1708
+ rawExpectedRoot.trim() === '' ||
1709
+ !path.isAbsolute(rawExpectedRoot))
1710
+ ) {
1711
+ return mismatchBinding(
1712
+ 'INVALID_PROJECT_EXPECTATION',
1713
+ 'project.expectedRoot must be a non-empty absolute path.'
1714
+ );
1715
+ }
1716
+ if (
1717
+ rawExpectedProjectId !== undefined &&
1718
+ (typeof rawExpectedProjectId !== 'string' ||
1719
+ !/^sha256:[a-f0-9]{64}$/.test(rawExpectedProjectId))
1720
+ ) {
1721
+ return mismatchBinding(
1722
+ 'INVALID_PROJECT_EXPECTATION',
1723
+ 'project.expectedProjectId must be a sha256:<64 lowercase hex> identity.'
1724
+ );
1725
+ }
1726
+ if (rawExpectedRoot === undefined && rawExpectedProjectId === undefined) {
1727
+ return unverifiedBinding();
1728
+ }
1729
+ if (rawExpectedRoot === undefined) {
1730
+ if (rawExpectedProjectId !== projectIdentity.projectId) {
1731
+ return mismatchBinding(
1732
+ 'PROJECT_ID_MISMATCH',
1733
+ `Expected project id ${rawExpectedProjectId}, but this MCP is bound to ` +
1734
+ `${projectIdentity.projectId}.`,
1735
+ { expectedProjectId: rawExpectedProjectId }
1736
+ );
1737
+ }
1738
+ return {
1739
+ status: 'unverified',
1740
+ authoritative: false,
1741
+ expectedProjectId: rawExpectedProjectId,
1742
+ message:
1743
+ 'project.expectedProjectId matched, but expectedRoot is required for an ' +
1744
+ 'authoritative workspace binding.',
1745
+ };
1746
+ }
1747
+
1748
+ let expectedRoot;
1749
+ try {
1750
+ expectedRoot = canonicalPathIncludingMissing(rawExpectedRoot);
1751
+ } catch (error) {
1752
+ return mismatchBinding(
1753
+ 'PROJECT_ROOT_MISMATCH',
1754
+ error instanceof Error ? error.message : String(error),
1755
+ { expectedRoot: rawExpectedRoot, expectedProjectId: rawExpectedProjectId }
1756
+ );
1757
+ }
1758
+ if (!pathIsWithin(resolvedRoot, expectedRoot)) {
1759
+ return mismatchBinding(
1760
+ 'PROJECT_ROOT_MISMATCH',
1761
+ `Expected workspace ${expectedRoot}, but this ArkGate MCP is bound to ${resolvedRoot}.`,
1762
+ { expectedRoot, expectedProjectId: rawExpectedProjectId }
1763
+ );
1764
+ }
1765
+ const nestedConfig = nestedProjectConfig(expectedRoot);
1766
+ if (nestedConfig && nestedConfig !== configPath) {
1767
+ return mismatchBinding(
1768
+ 'PROJECT_ROOT_MISMATCH',
1769
+ `Expected workspace ${expectedRoot} belongs to a nested ArkGate project at ` +
1770
+ `${nestedConfig}, but this MCP is bound to ${configPath}.`,
1771
+ { expectedRoot, expectedProjectId: rawExpectedProjectId }
1772
+ );
1773
+ }
1774
+
1775
+ if (
1776
+ rawExpectedProjectId !== undefined &&
1777
+ rawExpectedProjectId !== projectIdentity.projectId
1778
+ ) {
1779
+ return mismatchBinding(
1780
+ 'PROJECT_ID_MISMATCH',
1781
+ `Expected project id ${rawExpectedProjectId}, but this MCP is bound to ` +
1782
+ `${projectIdentity.projectId}.`,
1783
+ { expectedRoot, expectedProjectId: rawExpectedProjectId }
1784
+ );
1785
+ }
1786
+ if (expectedRoot !== resolvedRoot && rawExpectedProjectId === undefined) {
1787
+ return {
1788
+ status: 'unverified',
1789
+ authoritative: false,
1790
+ expectedRoot,
1791
+ message:
1792
+ `Expected workspace ${expectedRoot} is inside this MCP project, but an exact project ` +
1793
+ 'root is required for the initial authoritative handshake. Call ark_identity at the ' +
1794
+ `project root ${resolvedRoot}, then reuse its projectIdentity.projectId for descendant calls.`,
1795
+ };
1796
+ }
1797
+ return {
1798
+ status: 'matched',
1799
+ authoritative: true,
1800
+ ...(expectedRoot ? { expectedRoot } : {}),
1801
+ ...(rawExpectedProjectId ? { expectedProjectId: rawExpectedProjectId } : {}),
1802
+ };
1803
+ }
1804
+
1805
+ function bindingForToolPaths(toolName, toolArguments, currentBinding) {
1806
+ if (currentBinding.status === 'mismatch') return currentBinding;
1807
+ const candidates = [];
1808
+ if (['validate_code', 'ark_place', 'ark_prepare_write'].includes(toolName)) {
1809
+ candidates.push(toolArguments?.filePath);
1810
+ }
1811
+ if (toolName === 'ark_prepare_change') {
1812
+ for (const change of toolArguments?.changes ?? []) candidates.push(change?.path);
1813
+ for (const file of toolArguments?.changeMap?.files ?? []) candidates.push(file?.path);
1814
+ }
1815
+ for (const candidate of candidates) {
1816
+ if (typeof candidate !== 'string' || candidate === '') continue;
1817
+ const absolute = path.isAbsolute(candidate)
1818
+ ? candidate
1819
+ : path.resolve(resolvedRoot, candidate);
1820
+ let canonical;
1821
+ try {
1822
+ canonical = canonicalPathIncludingMissing(absolute);
1823
+ } catch (error) {
1824
+ return mismatchBinding(
1825
+ 'PROJECT_ROOT_MISMATCH',
1826
+ error instanceof Error ? error.message : String(error),
1827
+ {
1828
+ expectedRoot: currentBinding.expectedRoot,
1829
+ expectedProjectId: currentBinding.expectedProjectId,
1830
+ }
1831
+ );
1832
+ }
1833
+ if (!pathIsWithin(resolvedRoot, canonical)) {
1834
+ return mismatchBinding(
1835
+ 'PROJECT_ROOT_MISMATCH',
1836
+ `Tool ${toolName} received ${canonical}, which is outside the MCP project root ` +
1837
+ `${resolvedRoot}.`,
1838
+ {
1839
+ expectedRoot: currentBinding.expectedRoot,
1840
+ expectedProjectId: currentBinding.expectedProjectId,
1841
+ }
1842
+ );
1843
+ }
1844
+ }
1845
+ return currentBinding;
1846
+ }
1847
+
1848
+ function contextFor(binding) {
1849
+ return {
1850
+ projectIdentity,
1851
+ binding,
1852
+ authoritative: binding.authoritative,
1853
+ };
1854
+ }
1855
+
1856
+ function withProjectContext(result, binding) {
1857
+ const context = contextFor(binding);
1858
+ const content = Array.isArray(result?.content)
1859
+ ? result.content.map((block) => {
1860
+ if (block?.type !== 'text' || typeof block.text !== 'string') return block;
1861
+ let body;
1862
+ try {
1863
+ body = JSON.parse(block.text);
1864
+ } catch {
1865
+ body = result?.isError
1866
+ ? {
1867
+ ok: false,
1868
+ error: {
1869
+ code: 'ARK_TOOL_ERROR',
1870
+ message: block.text,
1871
+ },
1872
+ }
1873
+ : { ok: true, result: block.text };
1874
+ }
1875
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
1876
+ body = { ok: !result?.isError, result: body };
1877
+ }
1878
+ return {
1879
+ ...block,
1880
+ text: JSON.stringify({ ...body, ...context }, null, 2),
1881
+ };
1882
+ })
1883
+ : [{ type: 'text', text: JSON.stringify(context, null, 2) }];
1884
+ let structuredContent =
1885
+ result?.structuredContent &&
1886
+ typeof result.structuredContent === 'object' &&
1887
+ !Array.isArray(result.structuredContent)
1888
+ ? { ...result.structuredContent, ...context }
1889
+ : undefined;
1890
+ if (!structuredContent && content[0]?.type === 'text') {
1891
+ try {
1892
+ const parsed = JSON.parse(content[0].text);
1893
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1894
+ structuredContent = parsed;
1895
+ }
1896
+ } catch {
1897
+ /* withProjectContext always renders text blocks as JSON above */
1898
+ }
1899
+ }
1900
+ return {
1901
+ ...result,
1902
+ ...context,
1903
+ content,
1904
+ ...(structuredContent ? { structuredContent } : {}),
1905
+ };
1906
+ }
1907
+
1908
+ function bindingFailureResult(binding) {
1909
+ return withProjectContext(
1910
+ {
1911
+ content: [
1912
+ {
1913
+ type: 'text',
1914
+ text: JSON.stringify({
1915
+ ok: false,
1916
+ error: {
1917
+ code: binding.code,
1918
+ message: binding.message,
1919
+ },
1920
+ }),
1921
+ },
1922
+ ],
1923
+ isError: true,
1924
+ },
1925
+ binding
1926
+ );
1927
+ }
1403
1928
 
1404
1929
  const TOOLS = [
1930
+ {
1931
+ name: 'ark_identity',
1932
+ description:
1933
+ 'Return the canonical ArkGate project, config, contract, and live MCP runtime identity. ' +
1934
+ 'Pass project.expectedRoot and/or expectedProjectId to verify this process before ' +
1935
+ 'trusting any architecture evidence.',
1936
+ inputSchema: { type: 'object', properties: {} },
1937
+ },
1938
+ {
1939
+ name: 'ark_manifest',
1940
+ description:
1941
+ 'Return the machine-readable architecture contract with an authoritative project ' +
1942
+ 'binding when project.expectedRoot matches this MCP project. Prefer this tool over ' +
1943
+ 'the ark://manifest compatibility resource, whose standard MCP read shape cannot ' +
1944
+ 'carry a portable project expectation.',
1945
+ inputSchema: { type: 'object', properties: {} },
1946
+ },
1405
1947
  {
1406
1948
  name: 'validate_code',
1407
1949
  description:
@@ -1428,7 +1970,7 @@ export async function runArkMcp({ hookInput } = {}) {
1428
1970
  },
1429
1971
  required: ['source'],
1430
1972
  },
1431
- outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
1973
+ outputSchema: projectAwareAnalysisResultSchema,
1432
1974
  },
1433
1975
  {
1434
1976
  name: 'ark_check',
@@ -1436,7 +1978,8 @@ export async function runArkMcp({ hookInput } = {}) {
1436
1978
  'Run the full Ark architecture check on the project and return structured results ' +
1437
1979
  '(layer-import violations, forbidden globals, circular deps, config warnings). Use ' +
1438
1980
  'this to answer "is the architecture currently valid?" instead of shelling out to ' +
1439
- 'ark-check. Applies the baseline automatically when one exists. isError when not ok.',
1981
+ 'ark-check. Preserves legacy ok and adds identity/completeness/graph/coverage/gates/' +
1982
+ 'overall verdicts. Applies the baseline automatically when one exists. isError when not ok.',
1440
1983
  inputSchema: {
1441
1984
  type: 'object',
1442
1985
  properties: {
@@ -1451,7 +1994,7 @@ export async function runArkMcp({ hookInput } = {}) {
1451
1994
  },
1452
1995
  },
1453
1996
  },
1454
- outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
1997
+ outputSchema: projectAwareAnalysisResultSchema,
1455
1998
  },
1456
1999
  {
1457
2000
  name: 'ark_policy_delta',
@@ -1602,13 +2145,24 @@ export async function runArkMcp({ hookInput } = {}) {
1602
2145
  },
1603
2146
  ];
1604
2147
 
2148
+ for (const tool of TOOLS) {
2149
+ tool.inputSchema = {
2150
+ ...tool.inputSchema,
2151
+ properties: {
2152
+ ...(tool.inputSchema.properties ?? {}),
2153
+ project: PROJECT_EXPECTATION_SCHEMA,
2154
+ },
2155
+ };
2156
+ }
2157
+
1605
2158
  const RESOURCES = [
1606
2159
  {
1607
2160
  uri: 'ark://manifest',
1608
2161
  name: 'Ark architectural contract',
1609
2162
  description:
1610
- 'The architecture agents must obey before generating code: layers and layer rules ' +
1611
- '(plus the full project manifest when --manifest is provided).',
2163
+ 'Compatibility-only architecture contract resource. Standard MCP resource reads cannot ' +
2164
+ 'portably carry a project expectation, so this surface is always unverified and ' +
2165
+ 'non-authoritative. Use the ark_manifest tool for bound contract evidence.',
1612
2166
  mimeType: 'application/json',
1613
2167
  },
1614
2168
  ];
@@ -1641,10 +2195,15 @@ export async function runArkMcp({ hookInput } = {}) {
1641
2195
  }));
1642
2196
  }
1643
2197
 
1644
- function manifestText() {
2198
+ function manifestText(binding = unverifiedBinding()) {
2199
+ const context = contextFor(binding);
1645
2200
  if (projectManifest) {
1646
2201
  return JSON.stringify(
1647
- { ...projectManifest, source: projectManifest.source ?? 'manifest' },
2202
+ {
2203
+ ...projectManifest,
2204
+ source: projectManifest.source ?? 'manifest',
2205
+ ...context,
2206
+ },
1648
2207
  null,
1649
2208
  2
1650
2209
  );
@@ -1678,7 +2237,7 @@ export async function runArkMcp({ hookInput } = {}) {
1678
2237
  ...(config.arkRules && typeof config.arkRules === 'object'
1679
2238
  ? { arkRules: config.arkRules }
1680
2239
  : {}),
1681
- ...arkRulesCatalogForManifest(args.root, config),
2240
+ ...arkRulesCatalogForManifest(effectiveArkRulesSnapshot),
1682
2241
  ...(suggestions.length > 0
1683
2242
  ? {
1684
2243
  suggestedLayers: suggestions,
@@ -1689,12 +2248,36 @@ export async function runArkMcp({ hookInput } = {}) {
1689
2248
  'inventing an ungoverned location.',
1690
2249
  }
1691
2250
  : {}),
2251
+ ...context,
1692
2252
  },
1693
2253
  null,
1694
2254
  2
1695
2255
  );
1696
2256
  }
1697
2257
 
2258
+ function runIdentityTool() {
2259
+ return {
2260
+ content: [
2261
+ {
2262
+ type: 'text',
2263
+ text: JSON.stringify({
2264
+ ok: true,
2265
+ instruction:
2266
+ 'Reuse projectIdentity.projectId with project.expectedRoot on subsequent calls.',
2267
+ }),
2268
+ },
2269
+ ],
2270
+ isError: false,
2271
+ };
2272
+ }
2273
+
2274
+ function runManifestTool(_params, binding) {
2275
+ return {
2276
+ content: [{ type: 'text', text: manifestText(binding) }],
2277
+ isError: false,
2278
+ };
2279
+ }
2280
+
1698
2281
  function runValidate(params) {
1699
2282
  const source = params?.arguments?.source;
1700
2283
  if (typeof source !== 'string') {
@@ -1755,7 +2338,7 @@ export async function runArkMcp({ hookInput } = {}) {
1755
2338
  );
1756
2339
  }
1757
2340
 
1758
- function runCheckTool(params) {
2341
+ function runCheckTool(params, binding) {
1759
2342
  const strict = params?.arguments?.strict !== false; // default true
1760
2343
  const baselineArg = params?.arguments?.baseline;
1761
2344
  const baselineExists = fs.existsSync(path.join(args.root, '.ark-baseline.json'));
@@ -1767,8 +2350,61 @@ export async function runArkMcp({ hookInput } = {}) {
1767
2350
  if (!data) {
1768
2351
  return { content: [{ type: 'text', text: `ark-check produced no JSON:\n${raw}` }], isError: true };
1769
2352
  }
2353
+ const { data: coverageData } = runArkCheckJson(['--coverage']);
2354
+ const coverage = coverageData?.coverage;
2355
+ const coverageOk = Boolean(
2356
+ coverage &&
2357
+ coverage.emptyScope === false &&
2358
+ coverage.governed?.percent === 100 &&
2359
+ coverage.unclassified?.count === 0
2360
+ );
2361
+ let writePath;
2362
+ try {
2363
+ writePath = detectWritePathCapabilities(args.root, 'unknown');
2364
+ } catch {
2365
+ writePath = undefined;
2366
+ }
2367
+ const localWriteActive = writePath?.enforcementState?.localWrite?.active ?? 'unverified';
2368
+ const ciMergeActive = writePath?.enforcementState?.ciMerge?.active ?? 'unverified';
2369
+ const gatesOk = localWriteActive === true && ciMergeActive === true;
2370
+ const verdict = {
2371
+ identity: {
2372
+ status: binding.status,
2373
+ ok: binding.status === 'matched',
2374
+ },
2375
+ completeness: {
2376
+ status: data.completeness ?? 'unavailable',
2377
+ ok: data.completeness === 'complete',
2378
+ },
2379
+ graph: {
2380
+ ok: data.valid === true,
2381
+ violations: Array.isArray(data.violations) ? data.violations.length : null,
2382
+ },
2383
+ coverage: {
2384
+ ok: coverageOk,
2385
+ governedPercent: coverage?.governed?.percent ?? null,
2386
+ unclassified: coverage?.unclassified?.count ?? null,
2387
+ emptyScope: coverage?.emptyScope ?? null,
2388
+ },
2389
+ gates: {
2390
+ ok: gatesOk,
2391
+ localWriteActive,
2392
+ advisoryMcpActive: true,
2393
+ advisoryMcpRuntimeObserved: true,
2394
+ ciMergeActive,
2395
+ },
2396
+ overallOk: Boolean(
2397
+ binding.status === 'matched' &&
2398
+ data.ok === true &&
2399
+ data.completeness === 'complete' &&
2400
+ data.valid === true &&
2401
+ coverageOk &&
2402
+ gatesOk
2403
+ ),
2404
+ };
2405
+ const payload = { ...data, verdict };
1770
2406
  return {
1771
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
2407
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1772
2408
  structuredContent: {
1773
2409
  schemaVersion: data.schemaVersion,
1774
2410
  mode: data.mode,
@@ -1780,6 +2416,7 @@ export async function runArkMcp({ hookInput } = {}) {
1780
2416
  ...(data.resolverIdentity ? { resolverIdentity: data.resolverIdentity } : {}),
1781
2417
  ...(data.factsHash ? { factsHash: data.factsHash } : {}),
1782
2418
  ...(data.candidateTreeHash ? { candidateTreeHash: data.candidateTreeHash } : {}),
2419
+ verdict,
1783
2420
  },
1784
2421
  isError: data.ok === false,
1785
2422
  };
@@ -1882,7 +2519,7 @@ export async function runArkMcp({ hookInput } = {}) {
1882
2519
  message: noLayers
1883
2520
  ? 'This project declares no path-based layers in ark.config.json, so a ' +
1884
2521
  'layer cannot be inferred from the path. The gate still enforces the ' +
1885
- 'default 11-layer profile by intent-name prefix — read ark://manifest ' +
2522
+ 'default 11-layer profile by intent-name prefix — call ark_manifest ' +
1886
2523
  'for the layers and validate the actual snippet with validate_code.'
1887
2524
  : 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
1888
2525
  'rules enforced). Place it under a directory a layer in ark.config.json ' +
@@ -2031,20 +2668,31 @@ export async function runArkMcp({ hookInput } = {}) {
2031
2668
  try {
2032
2669
  const governed = collectGovernedFiles(args.root, config);
2033
2670
  const fileContents = {};
2671
+ const fileLayers = {};
2034
2672
  for (const file of governed.slice(0, 400)) {
2035
2673
  const rel = path.relative(args.root, file).split(path.sep).join('/');
2036
2674
  try {
2037
2675
  fileContents[rel] = fs.readFileSync(file, 'utf8');
2676
+ const layer = layerForFile(args.root, file, config.layers);
2677
+ if (layer) fileLayers[rel] = layer;
2038
2678
  } catch {
2039
2679
  /* skip */
2040
2680
  }
2041
2681
  }
2042
2682
  const contracted = [];
2043
- const loaded = loadEffectiveArkRulesFromDisk(args.root, config);
2044
- for (const rule of loaded.arkRules?.structure ?? []) contracted.push(rule.id);
2045
- for (const inv of loaded.arkRules?.invariants ?? []) contracted.push(inv.id);
2683
+ for (const rule of effectiveArkRulesSnapshot.arkRules?.structure ?? []) {
2684
+ contracted.push(rule.id);
2685
+ }
2686
+ for (const inv of effectiveArkRulesSnapshot.arkRules?.invariants ?? []) {
2687
+ contracted.push(inv.id);
2688
+ }
2046
2689
  const inventory = buildRulesInventory({
2047
2690
  fileContents,
2691
+ fileLayers,
2692
+ layerContexts: (config.layers ?? []).map((layer) => ({
2693
+ name: layer.name,
2694
+ intentPrefixes: layer.intentPrefixes ?? [],
2695
+ })),
2048
2696
  contractedRuleIds: contracted,
2049
2697
  });
2050
2698
  const nextPilot =
@@ -2112,6 +2760,8 @@ export async function runArkMcp({ hookInput } = {}) {
2112
2760
  }
2113
2761
 
2114
2762
  const TOOL_HANDLERS = {
2763
+ ark_identity: runIdentityTool,
2764
+ ark_manifest: runManifestTool,
2115
2765
  validate_code: runValidate,
2116
2766
  ark_check: runCheckTool,
2117
2767
  ark_policy_delta: runPolicyDeltaTool,
@@ -2126,7 +2776,16 @@ export async function runArkMcp({ hookInput } = {}) {
2126
2776
 
2127
2777
  const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
2128
2778
  const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
2129
- const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
2779
+ const fail = (id, code, message, binding = unverifiedBinding()) =>
2780
+ send({
2781
+ jsonrpc: '2.0',
2782
+ id,
2783
+ error: {
2784
+ code,
2785
+ message,
2786
+ data: contextFor(binding),
2787
+ },
2788
+ });
2130
2789
 
2131
2790
  function handle(msg) {
2132
2791
  const { id, method, params } = msg;
@@ -2137,38 +2796,81 @@ export async function runArkMcp({ hookInput } = {}) {
2137
2796
 
2138
2797
  switch (method) {
2139
2798
  case 'initialize':
2799
+ {
2800
+ const binding = bindingForExpectation(params?.project);
2140
2801
  reply(id, {
2141
2802
  protocolVersion: params?.protocolVersion ?? DEFAULT_PROTOCOL,
2142
2803
  capabilities: { tools: {}, resources: {} },
2143
2804
  serverInfo: SERVER_INFO,
2805
+ ...contextFor(binding),
2144
2806
  });
2807
+ }
2145
2808
  return;
2146
2809
  case 'ping':
2147
2810
  reply(id, {});
2148
2811
  return;
2149
2812
  case 'tools/list':
2150
- reply(id, { tools: TOOLS });
2813
+ reply(id, { tools: TOOLS, ...contextFor(unverifiedBinding()) });
2151
2814
  return;
2152
2815
  case 'tools/call': {
2153
2816
  const handler = TOOL_HANDLERS[params?.name];
2154
2817
  if (!handler) {
2155
- fail(id, -32602, `Unknown tool: ${params?.name}`);
2818
+ fail(
2819
+ id,
2820
+ -32602,
2821
+ `Unknown tool: ${params?.name}`,
2822
+ bindingForExpectation(params?.arguments?.project)
2823
+ );
2156
2824
  return;
2157
2825
  }
2158
- reply(id, handler(params));
2826
+ let binding = bindingForExpectation(params?.arguments?.project);
2827
+ binding = bindingForToolPaths(params?.name, params?.arguments, binding);
2828
+ if (binding.status === 'mismatch') {
2829
+ reply(id, bindingFailureResult(binding));
2830
+ return;
2831
+ }
2832
+ try {
2833
+ reply(id, withProjectContext(handler(params, binding), binding));
2834
+ } catch (error) {
2835
+ const message = error instanceof Error ? error.message : String(error);
2836
+ reply(
2837
+ id,
2838
+ withProjectContext(
2839
+ {
2840
+ content: [{ type: 'text', text: message }],
2841
+ isError: true,
2842
+ },
2843
+ binding
2844
+ )
2845
+ );
2846
+ }
2159
2847
  return;
2160
2848
  }
2161
2849
  case 'resources/list':
2162
- reply(id, { resources: RESOURCES });
2850
+ reply(id, { resources: RESOURCES, ...contextFor(unverifiedBinding()) });
2163
2851
  return;
2164
2852
  case 'resources/read':
2853
+ {
2854
+ // MCP resources/read has a standard `{ uri }` request shape. Some clients may
2855
+ // forward extension fields, but treating those as a binding would make project
2856
+ // safety host-dependent. Keep this compatibility resource non-authoritative and
2857
+ // require the project-aware ark_manifest tool for trusted contract evidence.
2858
+ const binding = unverifiedBinding();
2165
2859
  if (params?.uri !== 'ark://manifest') {
2166
- fail(id, -32602, `Unknown resource: ${params?.uri}`);
2860
+ fail(id, -32602, `Unknown resource: ${params?.uri}`, binding);
2167
2861
  return;
2168
2862
  }
2169
2863
  reply(id, {
2170
- contents: [{ uri: 'ark://manifest', mimeType: 'application/json', text: manifestText() }],
2864
+ contents: [
2865
+ {
2866
+ uri: 'ark://manifest',
2867
+ mimeType: 'application/json',
2868
+ text: manifestText(binding),
2869
+ },
2870
+ ],
2871
+ ...contextFor(binding),
2171
2872
  });
2873
+ }
2172
2874
  return;
2173
2875
  default:
2174
2876
  fail(id, -32601, `Method not found: ${method}`);