arkgate 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +55 -31
  3. package/bin/ark-check.mjs +95 -36
  4. package/bin/ark-mcp.mjs +11 -5
  5. package/bin/ark-shared.mjs +88 -56
  6. package/bin/ark.mjs +45 -10
  7. package/bin/lib/agent-gates.mjs +12 -0
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +9 -3
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/doctor-plan.mjs +43 -16
  13. package/bin/lib/enforcement-profiles.mjs +97 -0
  14. package/bin/lib/host-support-matrix.mjs +77 -0
  15. package/bin/lib/install-migrate.mjs +45 -14
  16. package/bin/lib/mcp-adoption.mjs +35 -3
  17. package/bin/lib/open-html.mjs +75 -0
  18. package/bin/lib/presets.mjs +3 -2
  19. package/bin/lib/safety-diagnostics.mjs +31 -11
  20. package/bin/lib/skill-install.mjs +64 -0
  21. package/bin/lib/ts-resolve.mjs +2 -1
  22. package/bin/lib/weakest-link.mjs +417 -0
  23. package/bin/lib/write-path-capabilities.mjs +182 -0
  24. package/bin/lib/write-path-detect.mjs +62 -99
  25. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  26. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  27. package/dist/eslint/index.cjs +375 -13
  28. package/dist/eslint/index.cjs.map +1 -1
  29. package/dist/eslint/index.d.cts +30 -20
  30. package/dist/eslint/index.d.ts +30 -20
  31. package/dist/eslint/index.js +375 -13
  32. package/dist/eslint/index.js.map +1 -1
  33. package/dist/index.cjs +723 -61
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +95 -5
  36. package/dist/index.d.ts +95 -5
  37. package/dist/index.js +716 -61
  38. package/dist/index.js.map +1 -1
  39. package/dist/nestjs/index.cjs +150 -42
  40. package/dist/nestjs/index.cjs.map +1 -1
  41. package/dist/nestjs/index.d.cts +2 -1
  42. package/dist/nestjs/index.d.ts +2 -1
  43. package/dist/nestjs/index.js +150 -42
  44. package/dist/nestjs/index.js.map +1 -1
  45. package/dist/runtime/index.cjs +723 -61
  46. package/dist/runtime/index.cjs.map +1 -1
  47. package/dist/runtime/index.d.cts +3 -2
  48. package/dist/runtime/index.d.ts +3 -2
  49. package/dist/runtime/index.js +716 -61
  50. package/dist/runtime/index.js.map +1 -1
  51. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  52. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  53. package/docs/agent-guide.md +32 -20
  54. package/docs/ai-gates.md +53 -18
  55. package/docs/configuration.md +97 -0
  56. package/docs/enthusiast/README.md +3 -3
  57. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  58. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  59. package/docs/package-surface.md +14 -9
  60. package/docs/production-hardening.md +15 -2
  61. package/docs/threat-model.md +65 -0
  62. package/docs/typescript-support.md +3 -3
  63. package/package.json +15 -2
  64. package/schemas/ark.config.schema.json +750 -0
  65. package/server.json +2 -2
  66. package/templates/hooks/pre-commit-ark +37 -0
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Q3 — Weakest-link enforcement sensors (local, pure FS; optional gh for protection).
3
+ * Missing CI, config drift, pre-commit human-edit path, and honest branch-protection report.
4
+ */
5
+ import { spawnSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { arkCommand } from '../ark-shared.mjs';
9
+
10
+ const PRECOMMIT_MARKERS = [
11
+ 'ark-check',
12
+ 'arkgate-check',
13
+ 'check:architecture',
14
+ 'pre-commit-ark',
15
+ ];
16
+
17
+ /**
18
+ * @param {string} root
19
+ * @returns {{ present: boolean, arkAware: boolean, path: string|null }}
20
+ */
21
+ export function detectPreCommitArk(root) {
22
+ const candidates = [
23
+ path.join(root, '.git', 'hooks', 'pre-commit'),
24
+ path.join(root, '.husky', 'pre-commit'),
25
+ path.join(root, 'templates', 'hooks', 'pre-commit-ark'),
26
+ ];
27
+ let present = false;
28
+ let arkAware = false;
29
+ let hit = null;
30
+ for (const file of candidates) {
31
+ if (!fs.existsSync(file)) continue;
32
+ // templates/hooks is the shipped install source, not an installed hook
33
+ const isTemplate = file.includes(`${path.sep}templates${path.sep}hooks${path.sep}`);
34
+ if (isTemplate) continue;
35
+ let text = '';
36
+ try {
37
+ text = fs.readFileSync(file, 'utf8');
38
+ } catch {
39
+ continue;
40
+ }
41
+ present = true;
42
+ hit = path.relative(root, file) || file;
43
+ if (PRECOMMIT_MARKERS.some((m) => text.includes(m))) {
44
+ arkAware = true;
45
+ break;
46
+ }
47
+ }
48
+ return { present, arkAware, path: hit };
49
+ }
50
+
51
+ /**
52
+ * @param {string} root
53
+ * @returns {{
54
+ * hasWorkflowsDir: boolean,
55
+ * workflowFiles: string[],
56
+ * arkWorkflowFiles: string[],
57
+ * hasArkCheckWorkflow: boolean,
58
+ * hasStrictFlag: boolean,
59
+ * hasArchitectureJobName: boolean,
60
+ * }}
61
+ */
62
+ export function detectCiEnforcement(root) {
63
+ const wfDir = path.join(root, '.github', 'workflows');
64
+ const out = {
65
+ hasWorkflowsDir: fs.existsSync(wfDir),
66
+ workflowFiles: [],
67
+ arkWorkflowFiles: [],
68
+ hasArkCheckWorkflow: false,
69
+ hasStrictFlag: false,
70
+ hasArchitectureJobName: false,
71
+ };
72
+ if (!out.hasWorkflowsDir) return out;
73
+ let files = [];
74
+ try {
75
+ files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/i.test(f));
76
+ } catch {
77
+ return out;
78
+ }
79
+ out.workflowFiles = files;
80
+ for (const f of files) {
81
+ let text = '';
82
+ try {
83
+ text = fs.readFileSync(path.join(wfDir, f), 'utf8');
84
+ } catch {
85
+ continue;
86
+ }
87
+ const mentionsArk =
88
+ /\barkgate-check\b/.test(text) ||
89
+ /\bark-check\b/.test(text) ||
90
+ /check:architecture/.test(text) ||
91
+ /pedroknigge\/arkgate/.test(text);
92
+ if (mentionsArk) {
93
+ out.hasArkCheckWorkflow = true;
94
+ out.arkWorkflowFiles.push(`.github/workflows/${f}`);
95
+ if (/--strict\b/.test(text) || /check:architecture/.test(text)) {
96
+ out.hasStrictFlag = true;
97
+ }
98
+ if (/architecture|ark-check|arkgate-check/i.test(f) || /name:\s*.*ark/i.test(text)) {
99
+ out.hasArchitectureJobName = true;
100
+ }
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Job ids (GitHub Actions) whose job body runs ark-check / check:architecture.
108
+ * Used so required status check "build" counts when the build job runs Ark.
109
+ * @param {string} root
110
+ * @returns {Set<string>}
111
+ */
112
+ export function jobIdsThatRunArkCheck(root) {
113
+ const ids = new Set();
114
+ const wfDir = path.join(root, '.github', 'workflows');
115
+ if (!fs.existsSync(wfDir)) return ids;
116
+ let files = [];
117
+ try {
118
+ files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/i.test(f));
119
+ } catch {
120
+ return ids;
121
+ }
122
+ for (const f of files) {
123
+ let text = '';
124
+ try {
125
+ text = fs.readFileSync(path.join(wfDir, f), 'utf8');
126
+ } catch {
127
+ continue;
128
+ }
129
+ if (!/\barkgate-check\b|\bark-check\b|check:architecture/.test(text)) continue;
130
+ const jobsIdx = text.search(/^jobs:\s*$/m);
131
+ if (jobsIdx < 0) continue;
132
+ const jobsSection = text.slice(jobsIdx);
133
+ const re = /^ {2}([A-Za-z0-9_-]+):\s*$/gm;
134
+ const matches = [...jobsSection.matchAll(re)];
135
+ for (let i = 0; i < matches.length; i++) {
136
+ const jobId = matches[i][1];
137
+ const start = matches[i].index ?? 0;
138
+ const end = i + 1 < matches.length ? (matches[i + 1].index ?? jobsSection.length) : jobsSection.length;
139
+ const body = jobsSection.slice(start, end);
140
+ if (/\barkgate-check\b|\bark-check\b|check:architecture/.test(body)) {
141
+ ids.add(jobId);
142
+ }
143
+ }
144
+ }
145
+ return ids;
146
+ }
147
+
148
+ /**
149
+ * Whether required status checks include Ark (by name or by matching a job that runs Ark).
150
+ * @param {string} root
151
+ * @param {string[]} requiredNames
152
+ */
153
+ export function isArkRequiredStatusCheck(root, requiredNames) {
154
+ if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
155
+ if (requiredNames.some((n) => /ark|architecture|arkgate/i.test(String(n)))) return true;
156
+ const jobIds = jobIdsThatRunArkCheck(root);
157
+ return requiredNames.some((n) => jobIds.has(String(n)));
158
+ }
159
+
160
+ /**
161
+ * Config / gate surface drift (adopted projects only).
162
+ * @param {string} root
163
+ * @param {{ adopted?: boolean, isProducer?: boolean }} [opts]
164
+ */
165
+ export function detectConfigGateDrift(root, opts = {}) {
166
+ const adopted =
167
+ opts.adopted ?? fs.existsSync(path.join(root, 'AGENTS.md'));
168
+ const isProducer =
169
+ opts.isProducer ?? fs.existsSync(path.join(root, 'templates', 'skills'));
170
+ const hasConfig = fs.existsSync(path.join(root, 'ark.config.json'));
171
+ const hasAgents = fs.existsSync(path.join(root, 'AGENTS.md'));
172
+ let hasCheckScript = false;
173
+ try {
174
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
175
+ hasCheckScript = Boolean(pkg?.scripts?.['check:architecture']);
176
+ } catch {
177
+ hasCheckScript = false;
178
+ }
179
+ const issues = [];
180
+ if (adopted && !hasConfig && !isProducer) {
181
+ issues.push({
182
+ id: 'config-drift-agents-without-config',
183
+ severity: 'warn',
184
+ message: 'AGENTS.md present but ark.config.json missing — gates cannot enforce the contract',
185
+ fix: arkCommand(root, 'ark', 'init'),
186
+ });
187
+ }
188
+ if (hasConfig && !hasCheckScript && !isProducer) {
189
+ issues.push({
190
+ id: 'config-drift-no-check-script',
191
+ severity: 'warn',
192
+ message:
193
+ 'ark.config.json exists but package.json has no check:architecture script (CI/local parity drift)',
194
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
195
+ });
196
+ }
197
+ if (hasConfig && !hasAgents && !isProducer) {
198
+ issues.push({
199
+ id: 'config-drift-config-without-agents',
200
+ severity: 'info',
201
+ message: 'ark.config.json without AGENTS.md — agent hosts may not see the write-gate contract',
202
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
203
+ });
204
+ }
205
+ return { hasConfig, hasAgents, hasCheckScript, issues };
206
+ }
207
+
208
+ /**
209
+ * Optional GitHub branch-protection / required-check report.
210
+ * Never fakes green: unavailable when gh missing, no remote, or API error.
211
+ *
212
+ * @param {{ cwd?: string, repo?: string, branch?: string, env?: NodeJS.ProcessEnv }} [opts]
213
+ * @returns {{
214
+ * available: boolean,
215
+ * reason?: string,
216
+ * requiredStatusChecks?: string[] | null,
217
+ * strict?: boolean | null,
218
+ * enforcesAdmins?: boolean | null,
219
+ * arkCheckRequired?: boolean | null,
220
+ * raw?: unknown,
221
+ * }}
222
+ */
223
+ export function reportGithubBranchProtection(opts = {}) {
224
+ const cwd = opts.cwd ?? process.cwd();
225
+ const env = opts.env ?? process.env;
226
+ const gh = spawnSync('gh', ['--version'], { encoding: 'utf8', env });
227
+ if (gh.status !== 0) {
228
+ return { available: false, reason: 'gh-cli-unavailable' };
229
+ }
230
+
231
+ let repo = opts.repo;
232
+ if (!repo) {
233
+ const r = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], {
234
+ cwd,
235
+ encoding: 'utf8',
236
+ env,
237
+ });
238
+ if (r.status !== 0 || !r.stdout?.trim()) {
239
+ return { available: false, reason: 'gh-repo-unavailable', raw: r.stderr };
240
+ }
241
+ repo = r.stdout.trim();
242
+ }
243
+
244
+ let branch = opts.branch;
245
+ if (!branch) {
246
+ const b = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
247
+ cwd,
248
+ encoding: 'utf8',
249
+ env,
250
+ });
251
+ branch = b.status === 0 ? b.stdout.trim() : 'main';
252
+ if (branch === 'HEAD') branch = 'main';
253
+ }
254
+
255
+ const view = spawnSync(
256
+ 'gh',
257
+ [
258
+ 'api',
259
+ `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`,
260
+ '--jq',
261
+ '{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
262
+ ],
263
+ { cwd, encoding: 'utf8', env }
264
+ );
265
+
266
+ if (view.status !== 0) {
267
+ const err = `${view.stderr || ''}${view.stdout || ''}`;
268
+ if (/Not Found|404|Branch not protected/i.test(err)) {
269
+ return {
270
+ available: true,
271
+ reason: 'branch-not-protected',
272
+ requiredStatusChecks: [],
273
+ strict: false,
274
+ enforcesAdmins: false,
275
+ arkCheckRequired: false,
276
+ raw: err.slice(0, 400),
277
+ };
278
+ }
279
+ return { available: false, reason: 'gh-api-error', raw: err.slice(0, 400) };
280
+ }
281
+
282
+ let parsed = null;
283
+ try {
284
+ parsed = JSON.parse(view.stdout || '{}');
285
+ } catch {
286
+ return { available: false, reason: 'gh-api-parse-error', raw: view.stdout };
287
+ }
288
+
289
+ const contexts = Array.isArray(parsed.contexts) ? parsed.contexts.map(String) : [];
290
+ const checkNames = Array.isArray(parsed.checks)
291
+ ? parsed.checks.map((c) => String(c?.context || c?.name || '')).filter(Boolean)
292
+ : [];
293
+ const all = [...new Set([...contexts, ...checkNames])];
294
+ // Name match OR required check id equals a workflow job that runs ark-check (e.g. "build").
295
+ const arkCheckRequired = isArkRequiredStatusCheck(cwd, all);
296
+
297
+ return {
298
+ available: true,
299
+ reason: 'ok',
300
+ requiredStatusChecks: all,
301
+ strict: Boolean(parsed.strict),
302
+ enforcesAdmins: Boolean(parsed.enforcesAdmins),
303
+ arkCheckRequired,
304
+ raw: parsed,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Adoption gaps for weakest-link (Q3). Does not require network.
310
+ * @param {string} root
311
+ * @param {{ adopted?: boolean, isProducer?: boolean, includeGithub?: boolean }} [opts]
312
+ */
313
+ export function collectWeakestLinkGaps(root, opts = {}) {
314
+ const adopted =
315
+ opts.adopted ?? fs.existsSync(path.join(root, 'AGENTS.md'));
316
+ const isProducer =
317
+ opts.isProducer ?? fs.existsSync(path.join(root, 'templates', 'skills'));
318
+ const gaps = [];
319
+
320
+ const ci = detectCiEnforcement(root);
321
+ const pre = detectPreCommitArk(root);
322
+ const drift = detectConfigGateDrift(root, { adopted, isProducer });
323
+ const templatePreCommit = path.join(root, 'templates', 'hooks', 'pre-commit-ark');
324
+ const shipsPreCommitTemplate =
325
+ isProducer && fs.existsSync(templatePreCommit);
326
+
327
+ if (adopted && !isProducer && !ci.hasWorkflowsDir) {
328
+ gaps.push({
329
+ id: 'enforcement-ci-missing',
330
+ severity: 'warn',
331
+ message: 'No .github/workflows directory — CI architecture gate cannot be required on merge',
332
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
333
+ });
334
+ } else if (adopted && !isProducer && ci.hasWorkflowsDir && !ci.hasArkCheckWorkflow) {
335
+ gaps.push({
336
+ id: 'enforcement-ci-no-ark-check',
337
+ severity: 'warn',
338
+ message:
339
+ 'CI workflows exist but none run ark-check / arkgate-check / check:architecture',
340
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
341
+ });
342
+ } else if (
343
+ adopted &&
344
+ !isProducer &&
345
+ ci.hasArkCheckWorkflow &&
346
+ !ci.hasStrictFlag
347
+ ) {
348
+ gaps.push({
349
+ id: 'enforcement-ci-not-strict',
350
+ severity: 'info',
351
+ message:
352
+ 'Architecture CI job found but does not pass --strict / check:architecture (weaker than recommended)',
353
+ fix: 'Add --strict (or npm run check:architecture) to the architecture workflow step',
354
+ });
355
+ }
356
+
357
+ for (const issue of drift.issues) {
358
+ gaps.push(issue);
359
+ }
360
+
361
+ // Human-edit path: recommend pre-commit when adopted consumer has no ark-aware hook
362
+ if (adopted && !isProducer && !pre.arkAware) {
363
+ gaps.push({
364
+ id: 'enforcement-pre-commit-missing',
365
+ severity: 'info',
366
+ message: pre.present
367
+ ? 'pre-commit hook exists but does not run Ark architecture check (human disk edits can bypass agent gates)'
368
+ : 'No ark-aware pre-commit hook — human edits can land without the write gate; install maintained template',
369
+ fix: shipsPreCommitTemplate || fs.existsSync(path.join(process.cwd(), 'templates', 'hooks', 'pre-commit-ark'))
370
+ ? 'Install: cp templates/hooks/pre-commit-ark .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit (or husky equivalent)'
371
+ : 'Copy pre-commit-ark from the arkgate package templates/hooks/ into .git/hooks/pre-commit',
372
+ });
373
+ }
374
+
375
+ // Producer tree: ensure template ships
376
+ if (isProducer && !fs.existsSync(templatePreCommit)) {
377
+ gaps.push({
378
+ id: 'enforcement-pre-commit-template-missing',
379
+ severity: 'warn',
380
+ message: 'Producer package missing templates/hooks/pre-commit-ark (Q3 human-edit path)',
381
+ fix: 'Add templates/hooks/pre-commit-ark to the package',
382
+ });
383
+ }
384
+
385
+ let github = null;
386
+ if (opts.includeGithub) {
387
+ github = reportGithubBranchProtection({ cwd: root });
388
+ if (github.available && github.reason === 'branch-not-protected') {
389
+ gaps.push({
390
+ id: 'enforcement-branch-unprotected',
391
+ severity: 'warn',
392
+ message: 'Default branch has no GitHub branch protection (architecture check cannot be required)',
393
+ fix: 'Enable branch protection and require the architecture / ark-check status check',
394
+ });
395
+ } else if (
396
+ github.available &&
397
+ github.arkCheckRequired === false &&
398
+ Array.isArray(github.requiredStatusChecks)
399
+ ) {
400
+ gaps.push({
401
+ id: 'enforcement-ark-check-not-required',
402
+ severity: 'warn',
403
+ message:
404
+ 'Branch protection exists but no required status check looks like ark/architecture',
405
+ fix: 'Add the architecture CI job name to required status checks',
406
+ });
407
+ }
408
+ }
409
+
410
+ return {
411
+ gaps,
412
+ ci,
413
+ preCommit: pre,
414
+ drift,
415
+ github,
416
+ };
417
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Host-specific write enforcement inventory and active-host projection.
3
+ *
4
+ * Hard hooks, advisory MCP tools, CI checks, and repair payloads are
5
+ * deliberately separate capabilities. Repo-wide inventory never becomes an
6
+ * active-host guarantee unless that host owns the supporting evidence.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {
11
+ codexConfigPath,
12
+ listCodexArkServerTables,
13
+ } from './codex-home.mjs';
14
+ import {
15
+ getHostSupportProfile,
16
+ HOST_SUPPORT_HOSTS,
17
+ } from './host-support-matrix.mjs';
18
+ import { detectActiveAgentHost } from './skill-install.mjs';
19
+ import { detectCiEnforcement } from './weakest-link.mjs';
20
+
21
+ export const WRITE_CAPABILITY_NAMES = [
22
+ 'hard-write',
23
+ 'advisory-write',
24
+ 'merge-gate',
25
+ 'repair-payload',
26
+ ];
27
+
28
+ const KNOWN_HOSTS = HOST_SUPPORT_HOSTS;
29
+
30
+ function unique(values) {
31
+ return [...new Set(values)];
32
+ }
33
+
34
+ function emptyEvidence() {
35
+ return {
36
+ 'hard-write': [],
37
+ 'advisory-write': [],
38
+ 'merge-gate': [],
39
+ 'repair-payload': [],
40
+ };
41
+ }
42
+
43
+ function capabilityMap(evidence) {
44
+ return {
45
+ 'hard-write': evidence['hard-write'].length > 0,
46
+ 'advisory-write': evidence['advisory-write'].length > 0,
47
+ 'merge-gate': evidence['merge-gate'].length > 0,
48
+ 'repair-payload': evidence['repair-payload'].length > 0,
49
+ };
50
+ }
51
+
52
+ function readText(file) {
53
+ try {
54
+ return fs.readFileSync(file, 'utf8');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function relativeEvidencePath(root, file) {
61
+ const relative = path.relative(root, file);
62
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
63
+ return relative.split(path.sep).join('/');
64
+ }
65
+ return file.split(path.sep).join('/');
66
+ }
67
+
68
+ function isArkMcpText(text) {
69
+ return (
70
+ /\b(ark|arkgate)-mcp\b/.test(text) ||
71
+ /mcp_servers\.ark\b/.test(text) ||
72
+ /"ark"\s*:\s*\{/.test(text) ||
73
+ /mcpServers[\s\S]*\bark\b/.test(text)
74
+ );
75
+ }
76
+
77
+ function hookEvidence(root, relativePath) {
78
+ const text = readText(path.join(root, relativePath));
79
+ const hard = Boolean(text && /--hook\b/.test(text));
80
+ const repair = Boolean(
81
+ hard &&
82
+ (/--hook-repair\b/.test(text) ||
83
+ /ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text))
84
+ );
85
+ return {
86
+ hard: hard ? [relativePath] : [],
87
+ repair: repair ? [relativePath] : [],
88
+ };
89
+ }
90
+
91
+ function mcpEvidence(root, relativePath) {
92
+ const text = readText(path.join(root, relativePath));
93
+ return isArkMcpText(text) ? [relativePath] : [];
94
+ }
95
+
96
+ function codexMcpEvidence(root) {
97
+ const file = codexConfigPath();
98
+ const text = readText(file);
99
+ const resolvedRoot = path.resolve(root);
100
+ const registered = listCodexArkServerTables(text).some((entry) => {
101
+ if (!entry.root || !/\b(ark|arkgate)-mcp\b/.test(entry.block)) return false;
102
+ return path.resolve(entry.root) === resolvedRoot;
103
+ });
104
+ return registered ? [relativeEvidencePath(root, file)] : [];
105
+ }
106
+
107
+ function hostRecord(hard, advisory, repair, merge) {
108
+ const evidence = {
109
+ 'hard-write': unique(hard),
110
+ 'advisory-write': unique(advisory),
111
+ 'merge-gate': unique(merge),
112
+ 'repair-payload': unique(repair),
113
+ };
114
+ return {
115
+ configured:
116
+ evidence['hard-write'].length > 0 ||
117
+ evidence['advisory-write'].length > 0,
118
+ capabilities: capabilityMap(evidence),
119
+ evidence,
120
+ };
121
+ }
122
+
123
+ export function detectWritePathInventory(root) {
124
+ const merge = detectCiEnforcement(root).arkWorkflowFiles;
125
+ const claudeHook = hookEvidence(root, '.claude/settings.json');
126
+ const grokHook = hookEvidence(root, '.grok/hooks/ark-write-gate.json');
127
+ const hosts = {
128
+ claude: hostRecord(
129
+ claudeHook.hard,
130
+ mcpEvidence(root, '.mcp.json'),
131
+ claudeHook.repair,
132
+ merge
133
+ ),
134
+ grok: hostRecord(
135
+ grokHook.hard,
136
+ mcpEvidence(root, '.grok/config.toml'),
137
+ grokHook.repair,
138
+ merge
139
+ ),
140
+ cursor: hostRecord([], mcpEvidence(root, '.cursor/mcp.json'), [], merge),
141
+ codex: hostRecord([], codexMcpEvidence(root), [], merge),
142
+ };
143
+
144
+ const evidence = emptyEvidence();
145
+ for (const host of KNOWN_HOSTS) {
146
+ for (const capability of WRITE_CAPABILITY_NAMES) {
147
+ evidence[capability].push(...hosts[host].evidence[capability]);
148
+ }
149
+ }
150
+ for (const capability of WRITE_CAPABILITY_NAMES) {
151
+ evidence[capability] = unique(evidence[capability]);
152
+ }
153
+
154
+ return {
155
+ capabilities: capabilityMap(evidence),
156
+ evidence,
157
+ hosts,
158
+ };
159
+ }
160
+
161
+ export function buildWritePathCapabilityModel(root, explicitHost) {
162
+ const inventory = detectWritePathInventory(root);
163
+ const detectedHost = explicitHost ?? detectActiveAgentHost();
164
+ const activeHost = KNOWN_HOSTS.includes(detectedHost) ? detectedHost : 'unknown';
165
+ const activeRecord = inventory.hosts[activeHost];
166
+ const capabilityEvidence = activeRecord
167
+ ? Object.fromEntries(
168
+ WRITE_CAPABILITY_NAMES.map((name) => [name, [...activeRecord.evidence[name]]])
169
+ )
170
+ : {
171
+ ...emptyEvidence(),
172
+ 'merge-gate': [...inventory.evidence['merge-gate']],
173
+ };
174
+
175
+ return {
176
+ activeHost,
177
+ support: getHostSupportProfile(activeHost),
178
+ capabilities: capabilityMap(capabilityEvidence),
179
+ capabilityEvidence,
180
+ inventory,
181
+ };
182
+ }