arkgate 2.11.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 (77) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +70 -41
  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 +97 -29
  7. package/bin/lib/agent-gates.mjs +79 -2093
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +392 -0
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/deploy-path.mjs +205 -0
  13. package/bin/lib/doctor-plan.mjs +43 -16
  14. package/bin/lib/enforcement-profiles.mjs +97 -0
  15. package/bin/lib/gate-files.mjs +223 -0
  16. package/bin/lib/hook-templates.mjs +99 -0
  17. package/bin/lib/host-support-matrix.mjs +77 -0
  18. package/bin/lib/install-migrate.mjs +473 -0
  19. package/bin/lib/mcp-adoption.mjs +455 -0
  20. package/bin/lib/open-html.mjs +75 -0
  21. package/bin/lib/presets.mjs +6 -2
  22. package/bin/lib/safety-diagnostics.mjs +31 -11
  23. package/bin/lib/skill-install.mjs +323 -0
  24. package/bin/lib/ts-resolve.mjs +2 -1
  25. package/bin/lib/typescript-host.mjs +88 -0
  26. package/bin/lib/weakest-link.mjs +417 -0
  27. package/bin/lib/write-path-capabilities.mjs +182 -0
  28. package/bin/lib/write-path-detect.mjs +101 -0
  29. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  30. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  31. package/dist/eslint/index.cjs +375 -13
  32. package/dist/eslint/index.cjs.map +1 -1
  33. package/dist/eslint/index.d.cts +30 -20
  34. package/dist/eslint/index.d.ts +30 -20
  35. package/dist/eslint/index.js +375 -13
  36. package/dist/eslint/index.js.map +1 -1
  37. package/dist/index.cjs +723 -61
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +95 -5
  40. package/dist/index.d.ts +95 -5
  41. package/dist/index.js +716 -61
  42. package/dist/index.js.map +1 -1
  43. package/dist/nestjs/index.cjs +150 -42
  44. package/dist/nestjs/index.cjs.map +1 -1
  45. package/dist/nestjs/index.d.cts +2 -1
  46. package/dist/nestjs/index.d.ts +2 -1
  47. package/dist/nestjs/index.js +150 -42
  48. package/dist/nestjs/index.js.map +1 -1
  49. package/dist/runtime/index.cjs +723 -61
  50. package/dist/runtime/index.cjs.map +1 -1
  51. package/dist/runtime/index.d.cts +3 -2
  52. package/dist/runtime/index.d.ts +3 -2
  53. package/dist/runtime/index.js +716 -61
  54. package/dist/runtime/index.js.map +1 -1
  55. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  56. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  57. package/docs/agent-guide.md +43 -21
  58. package/docs/ai-gates.md +53 -18
  59. package/docs/configuration.md +97 -0
  60. package/docs/enthusiast/README.md +3 -3
  61. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  62. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  63. package/docs/package-surface.md +22 -10
  64. package/docs/production-hardening.md +15 -2
  65. package/docs/threat-model.md +65 -0
  66. package/docs/typescript-support.md +3 -3
  67. package/package.json +15 -2
  68. package/schemas/ark.config.schema.json +750 -0
  69. package/server.json +2 -2
  70. package/templates/hooks/pre-commit-ark +37 -0
  71. package/templates/skills/ark-autopilot.md +77 -45
  72. package/templates/skills/ark-coverage.md +2 -2
  73. package/templates/skills/ark-explain.md +2 -1
  74. package/templates/skills/ark-explore.md +135 -34
  75. package/templates/skills/ark-runtime.md +8 -5
  76. package/templates/skills/ark-upgrade.md +36 -16
  77. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Static host support plus preflight validation for requested write guarantees.
3
+ * Installed evidence remains authoritative in write-path-capabilities.mjs.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { detectWritePathCapabilities } from './write-path-detect.mjs';
8
+ import {
9
+ HOST_SUPPORT_HOSTS,
10
+ HOST_SUPPORT_MATRIX,
11
+ } from './host-support-matrix.mjs';
12
+ import { KNOWN_TOOLS, normalizeToolsList } from './skill-install.mjs';
13
+
14
+ export const HOST_ENFORCEMENT_SUPPORT = Object.freeze(
15
+ Object.fromEntries(
16
+ HOST_SUPPORT_HOSTS.map((host) => {
17
+ const profile = HOST_SUPPORT_MATRIX[host];
18
+ return [
19
+ host,
20
+ Object.freeze({
21
+ hardWrite: profile.capabilities['hard-write'],
22
+ advisoryWrite: profile.capabilities['advisory-write'],
23
+ hookPath: profile.hookPath,
24
+ }),
25
+ ];
26
+ })
27
+ )
28
+ );
29
+
30
+ export const WRITE_PROFILE_HOSTS = HOST_SUPPORT_HOSTS;
31
+
32
+ export function validateSelectedTools(tools) {
33
+ if (tools == null) return { ok: true, tools: null };
34
+ const selected = normalizeToolsList(tools);
35
+ const unknown = selected.filter((tool) => !KNOWN_TOOLS.includes(tool));
36
+ if (selected.length === 0 || unknown.length > 0) {
37
+ return {
38
+ ok: false,
39
+ error:
40
+ `--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
41
+ (unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : ''),
42
+ };
43
+ }
44
+ return { ok: true, tools: selected };
45
+ }
46
+
47
+ export function hasHardWriteHook(root, host) {
48
+ return detectWritePathCapabilities(root, host).capabilities['hard-write'];
49
+ }
50
+
51
+ export function validateHardWriteRequest({ root, host, tools, force = false }) {
52
+ const toolSelection = validateSelectedTools(tools);
53
+ if (!toolSelection.ok) return toolSelection;
54
+ if (host == null) return { ok: true, host: null, tools: toolSelection.tools };
55
+
56
+ const normalizedHost = String(host).trim().toLowerCase();
57
+ const support = HOST_ENFORCEMENT_SUPPORT[normalizedHost];
58
+ if (!support) {
59
+ return {
60
+ ok: false,
61
+ error: `Unknown write host "${normalizedHost}". Expected: ${WRITE_PROFILE_HOSTS.join(', ')}.`,
62
+ };
63
+ }
64
+ if (!support.hardWrite) {
65
+ return {
66
+ ok: false,
67
+ error:
68
+ `${normalizedHost} supports advisory-write plus the shared CI check, not a hard local write hook. ` +
69
+ 'Omit --require-write-hook, keep --strict-merge in CI, and require that status to block merges.',
70
+ };
71
+ }
72
+
73
+ const selectedTools = toolSelection.tools ?? [normalizedHost];
74
+ if (!selectedTools.includes(normalizedHost)) {
75
+ return {
76
+ ok: false,
77
+ error: `--require-write-hook ${normalizedHost} requires --tools to include ${normalizedHost}.`,
78
+ };
79
+ }
80
+
81
+ const hookFile = path.join(root, support.hookPath);
82
+ if (fs.existsSync(hookFile) && !force && !hasHardWriteHook(root, normalizedHost)) {
83
+ return {
84
+ ok: false,
85
+ error:
86
+ `${support.hookPath} already exists without an Ark hard-write hook and would be preserved. ` +
87
+ 'Use --force to replace that host file, or omit --require-write-hook for merge-only enforcement.',
88
+ };
89
+ }
90
+
91
+ return {
92
+ ok: true,
93
+ host: normalizedHost,
94
+ tools: selectedTools,
95
+ support,
96
+ };
97
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Gate file IO: package.json helpers, template writes, required gates.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
9
+ export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
10
+
11
+ export function readJson(file) {
12
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ }
14
+
15
+ export function readPackageJson(root) {
16
+ const file = path.join(root, 'package.json');
17
+ if (!fs.existsSync(file)) return null;
18
+ return readJson(file);
19
+ }
20
+
21
+ export function hasCheckArchitectureScript(root) {
22
+ const pkg = readPackageJson(root);
23
+ return Boolean(pkg?.scripts?.['check:architecture']);
24
+ }
25
+
26
+ /**
27
+ * Whether package.json scripts already expose a typecheck-like command.
28
+ * Shared by deploy-path quality + typecheck bootstrap (single definition).
29
+ * @param {Record<string, unknown>|null|undefined} scripts
30
+ */
31
+ export function packageScriptsHaveTypecheck(scripts) {
32
+ if (!scripts || typeof scripts !== 'object') return false;
33
+ return Boolean(
34
+ (typeof scripts.typecheck === 'string' && scripts.typecheck.trim()) ||
35
+ (typeof scripts['type-check'] === 'string' && scripts['type-check'].trim()) ||
36
+ (typeof scripts['check:types'] === 'string' && scripts['check:types'].trim()) ||
37
+ (typeof scripts.tsc === 'string' && /\btsc\b/.test(scripts.tsc))
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Root package (and shallow nested packages) already have a typecheck script.
43
+ * Does not scan CI or framework configs — only package.json scripts.
44
+ * @param {string} root
45
+ */
46
+ export function treeHasTypecheckScript(root) {
47
+ const pkg = readPackageJson(root);
48
+ if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
49
+ try {
50
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
51
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
52
+ const candidates = [path.join(root, entry.name)];
53
+ try {
54
+ for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
55
+ if (child.isDirectory() && !child.name.startsWith('.')) {
56
+ candidates.push(path.join(root, entry.name, child.name));
57
+ }
58
+ }
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ for (const dir of candidates) {
63
+ const pj = path.join(dir, 'package.json');
64
+ if (!fs.existsSync(pj)) continue;
65
+ try {
66
+ const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
67
+ if (packageScriptsHaveTypecheck(nested.scripts)) return true;
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ }
73
+ } catch {
74
+ /* ignore */
75
+ }
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Add a conservative `typecheck` script when the host has a TS/JS project config
81
+ * but no typecheck-like script yet. Never overwrites an existing script.
82
+ *
83
+ * @param {string} root
84
+ * @param {{ write?: boolean }} [opts]
85
+ * @returns {{
86
+ * changed: boolean,
87
+ * reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
88
+ * script?: string,
89
+ * }}
90
+ */
91
+ export function ensureTypecheckScript(root, opts = {}) {
92
+ const write = opts.write !== false;
93
+ const hasTsconfig =
94
+ fs.existsSync(path.join(root, 'tsconfig.json')) ||
95
+ fs.existsSync(path.join(root, 'jsconfig.json'));
96
+ if (!hasTsconfig) return { changed: false, reason: 'no-tsconfig' };
97
+
98
+ const pkgPath = path.join(root, 'package.json');
99
+ if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
100
+
101
+ if (treeHasTypecheckScript(root)) {
102
+ return { changed: false, reason: 'already' };
103
+ }
104
+
105
+ const pkg = readPackageJson(root) || {};
106
+ const scripts =
107
+ pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
108
+ const script = 'tsc --noEmit';
109
+ scripts.typecheck = script;
110
+ if (write) {
111
+ const next = { ...pkg, scripts };
112
+ fs.writeFileSync(pkgPath, `${JSON.stringify(next, null, 2)}\n`);
113
+ }
114
+ return { changed: true, reason: 'added', script };
115
+ }
116
+
117
+ export const REQUIRED_GATE_FILES = [
118
+ 'AGENTS.md',
119
+ '.mcp.json',
120
+ ];
121
+ const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
122
+
123
+ export function hasArkWorkflow(root) {
124
+ const workflowsDir = path.join(root, '.github', 'workflows');
125
+ if (!fs.existsSync(workflowsDir)) return false;
126
+ return fs
127
+ .readdirSync(workflowsDir)
128
+ .filter((file) => /\.ya?ml$/i.test(file))
129
+ .some((file) => {
130
+ try {
131
+ const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
132
+ return (
133
+ /\bark-check\b/.test(content) ||
134
+ /\bcheck:architecture\b/.test(content) ||
135
+ /\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
136
+ );
137
+ } catch {
138
+ return false;
139
+ }
140
+ });
141
+ }
142
+
143
+ export function missingGates(root) {
144
+ const missing = REQUIRED_GATE_FILES.filter(
145
+ (relativePath) => !fs.existsSync(path.join(root, relativePath))
146
+ );
147
+ if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
148
+ return missing;
149
+ }
150
+
151
+ export function ensureDirForFile(file) {
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ }
154
+
155
+ /**
156
+ * True when AGENTS.md is wholly Ark-owned (header is Ark Enforcement).
157
+ * Project guides that merely append an Ark section must remain non-Ark so --force
158
+ * never wipes them.
159
+ */
160
+ export function isArkAgentsContent(text) {
161
+ if (typeof text !== 'string' || !text.trim()) return false;
162
+ const head = text.trimStart().slice(0, 120);
163
+ return /^#\s*Ark(Gate)?\s+Enforcement\b/.test(head);
164
+ }
165
+
166
+ /**
167
+ * True when AGENTS.md is the **library mother-repo** self-hosted guide (Identity block).
168
+ * Never replace with the consumer install template — even under `--force`.
169
+ */
170
+ export function isSelfHostedLibraryAgents(text) {
171
+ if (typeof text !== 'string' || !text.trim()) return false;
172
+ return (
173
+ /##\s*Identity\s*[—\-–-]\s*read this first/i.test(text) ||
174
+ /mother\s*\/\s*canonical development repository/i.test(text) ||
175
+ /Git\s*\/\s*clone only/i.test(text)
176
+ );
177
+ }
178
+
179
+ export function writeTemplate(root, relativePath, content, force) {
180
+ const fullPath = path.join(root, relativePath);
181
+ if (relativePath === 'AGENTS.md' && fs.existsSync(fullPath)) {
182
+ let existing = '';
183
+ try {
184
+ existing = fs.readFileSync(fullPath, 'utf8');
185
+ } catch {
186
+ existing = '';
187
+ }
188
+ // Library authoring tree: keep Identity + 4-layer dogfood contract forever.
189
+ if (existing && isSelfHostedLibraryAgents(existing)) {
190
+ return { relativePath, status: 'skipped-self-hosted' };
191
+ }
192
+ if (existing && !isArkAgentsContent(existing)) {
193
+ // Never clobber a project-owned AGENTS.md — even with --force.
194
+ // If Ark section not present yet, merge once; subsequent runs leave it alone.
195
+ const hasArkSection =
196
+ /#\s*Ark(Gate)?\s+Enforcement\b/.test(existing) ||
197
+ /ark\.config\.json is authoritative/i.test(existing);
198
+ if (force && isArkAgentsContent(content) && !hasArkSection) {
199
+ try {
200
+ const merged = `${existing.replace(/\s*$/, '')}\n\n---\n\n${content}`;
201
+ ensureDirForFile(fullPath);
202
+ fs.writeFileSync(fullPath, merged);
203
+ return { relativePath, status: 'merged' };
204
+ } catch {
205
+ return { relativePath, status: 'failed' };
206
+ }
207
+ }
208
+ return { relativePath, status: 'skipped-non-ark' };
209
+ }
210
+ if (!force && isArkAgentsContent(existing)) {
211
+ return { relativePath, status: 'skipped' };
212
+ }
213
+ } else if (fs.existsSync(fullPath) && !force) {
214
+ return { relativePath, status: 'skipped' };
215
+ }
216
+ try {
217
+ ensureDirForFile(fullPath);
218
+ fs.writeFileSync(fullPath, content);
219
+ return { relativePath, status: 'written' };
220
+ } catch {
221
+ return { relativePath, status: 'failed' };
222
+ }
223
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Host hook / MCP project templates for agent-gate install (Claude, Grok).
3
+ * Kept out of agent-gates.mjs so install orchestration stays scannable (explore gap #5).
4
+ */
5
+ import { execCommandParts, execRunner } from '../ark-shared.mjs';
6
+
7
+ /** Preferred MCP binary name for generated hooks (package dual-bin). */
8
+ export const PREFERRED_MCP_BIN = 'arkgate-mcp';
9
+
10
+ export function claudeSettings(root) {
11
+ const runner = execRunner(root);
12
+ return `${JSON.stringify({
13
+ hooks: {
14
+ // Inject the contract at session start so the agent knows the architecture from
15
+ // the first token. Project-scoped by design; --session-context is also a silent
16
+ // no-op when no ark.config.json exists, so it can never leak into other projects.
17
+ SessionStart: [
18
+ {
19
+ hooks: [
20
+ {
21
+ type: 'command',
22
+ command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
23
+ },
24
+ ],
25
+ },
26
+ ],
27
+ PreToolUse: [
28
+ {
29
+ matcher: 'Write|Edit|MultiEdit',
30
+ hooks: [
31
+ {
32
+ type: 'command',
33
+ // W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
34
+ // (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
35
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ },
41
+ }, null, 2)}\n`;
42
+ }
43
+
44
+ // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
45
+ // Codex's global config.toml, Grok loads .grok/config.toml from the project).
46
+ export function grokProjectConfig(root) {
47
+ const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
48
+ '--root',
49
+ '.',
50
+ '--config',
51
+ 'ark.config.json',
52
+ ]);
53
+ const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
54
+ return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
55
+ # Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
56
+ [mcp_servers.ark]
57
+ command = "${command.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
58
+ args = [${argsToml}]
59
+ `;
60
+ }
61
+
62
+ // Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets both
63
+ // GROK_WORKSPACE_ROOT and CLAUDE_PROJECT_DIR (Claude-compatible alias). Prefer
64
+ // GROK_* with fallback so hooks still work if only one is present.
65
+ // Matcher keeps Claude names (Write|Edit|MultiEdit) and Grok natives
66
+ // (write|search_replace) — Grok aliases both directions.
67
+ export function grokHooks(root) {
68
+ const runner = execRunner(root);
69
+ // Nested defaults: Grok native → Claude alias → project cwd (hook cwd is the workspace).
70
+ const grokRoot = '${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}';
71
+ return `${JSON.stringify({
72
+ hooks: {
73
+ SessionStart: [
74
+ {
75
+ hooks: [
76
+ {
77
+ type: 'command',
78
+ timeout: 30,
79
+ command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${grokRoot}" --config ark.config.json`,
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ PreToolUse: [
85
+ {
86
+ matcher: 'Write|Edit|MultiEdit|write|search_replace',
87
+ hooks: [
88
+ {
89
+ type: 'command',
90
+ timeout: 30,
91
+ // W4: --hook-repair → structured autoPatch on deny (hard block still).
92
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ }, null, 2)}\n`;
99
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Canonical host support promises.
3
+ *
4
+ * These records describe what ArkGate can install for each supported host.
5
+ * Installed evidence remains authoritative for a specific repository and is
6
+ * reported separately by write-path-capabilities.mjs.
7
+ */
8
+
9
+ function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
10
+ return Object.freeze({
11
+ label,
12
+ hookPath,
13
+ hookSurface,
14
+ capabilities: Object.freeze({
15
+ 'hard-write': hardWrite,
16
+ 'advisory-write': true,
17
+ 'merge-gate': true,
18
+ 'repair-payload': repairPayload,
19
+ }),
20
+ });
21
+ }
22
+
23
+ export const HOST_SUPPORT_MATRIX = Object.freeze({
24
+ claude: hostProfile(
25
+ 'Claude Code',
26
+ '.claude/settings.json',
27
+ 'PreToolUse `Write` / `Edit` / `MultiEdit`',
28
+ true,
29
+ true
30
+ ),
31
+ grok: hostProfile(
32
+ 'Grok Build',
33
+ '.grok/hooks/ark-write-gate.json',
34
+ 'PreToolUse `write` / `search_replace` (plus aliases)',
35
+ true,
36
+ true
37
+ ),
38
+ cursor: hostProfile('Cursor', null, null, false, false),
39
+ codex: hostProfile('OpenAI Codex', null, null, false, false),
40
+ });
41
+
42
+ export const HOST_SUPPORT_HOSTS = Object.freeze(Object.keys(HOST_SUPPORT_MATRIX));
43
+
44
+ export function getHostSupportProfile(host) {
45
+ const normalized = typeof host === 'string' ? host.trim().toLowerCase() : '';
46
+ return HOST_SUPPORT_MATRIX[normalized] ?? null;
47
+ }
48
+
49
+ export function formatHostSupportSummary(profile) {
50
+ if (!profile) return 'unknown host; no local write guarantee';
51
+ const capabilities = profile.capabilities;
52
+ const write = capabilities['hard-write']
53
+ ? 'hard local write boundary'
54
+ : 'no hard local write boundary';
55
+ const repair = capabilities['repair-payload'] ? 'repair payload' : 'no hard-boundary repair';
56
+ return `${write} + advisory MCP + CI check + ${repair}`;
57
+ }
58
+
59
+ export function renderHostSupportMatrixMarkdown() {
60
+ const rows = HOST_SUPPORT_HOSTS.map((host) => {
61
+ const profile = HOST_SUPPORT_MATRIX[host];
62
+ const capabilities = profile.capabilities;
63
+ const local = capabilities['hard-write']
64
+ ? `Hard block for ${profile.hookSurface}`
65
+ : 'No hard hook; MCP/rules are advisory';
66
+ const repair = capabilities['repair-payload']
67
+ ? 'Emitted on hook deny; host must re-inject'
68
+ : 'No hard-boundary payload';
69
+ return `| ${profile.label} | ${local} | Advisory; the agent must call it | Available \`arkgate-check --strict-merge\` check | ${repair} |`;
70
+ }).join('\n');
71
+
72
+ return `| Host | Local write boundary | MCP validation | CI / merge path | Repair payload |
73
+ |------|----------------------|----------------|-----------------|----------------|
74
+ ${rows}
75
+
76
+ This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run \`arkgate-check --doctor\` for the evidence actually detected in the current repository.`;
77
+ }