arkgate 2.12.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +90 -51
  3. package/bin/ark-check.mjs +156 -39
  4. package/bin/ark-mcp.mjs +119 -6
  5. package/bin/ark-shared.mjs +216 -129
  6. package/bin/ark.mjs +134 -34
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +13 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -127
  11. package/bin/lib/ci-and-commands.mjs +40 -3
  12. package/bin/lib/codex-home.mjs +7 -0
  13. package/bin/lib/config-contract.mjs +331 -0
  14. package/bin/lib/config-warnings.mjs +7 -205
  15. package/bin/lib/doctor-plan.mjs +43 -16
  16. package/bin/lib/enforcement-profiles.mjs +97 -0
  17. package/bin/lib/field-install.mjs +67 -10
  18. package/bin/lib/gate-files.mjs +42 -3
  19. package/bin/lib/graph-cycles.mjs +4 -54
  20. package/bin/lib/hook-templates.mjs +33 -1
  21. package/bin/lib/host-support-matrix.mjs +83 -0
  22. package/bin/lib/install-migrate.mjs +99 -30
  23. package/bin/lib/mcp-adoption.mjs +35 -3
  24. package/bin/lib/open-html.mjs +75 -0
  25. package/bin/lib/presets.mjs +45 -4
  26. package/bin/lib/safety-diagnostics.mjs +36 -15
  27. package/bin/lib/scan-files.mjs +12 -1
  28. package/bin/lib/skill-install.mjs +72 -1
  29. package/bin/lib/source-policy.mjs +36 -0
  30. package/bin/lib/start-preview.mjs +271 -0
  31. package/bin/lib/ts-resolve.mjs +13 -3
  32. package/bin/lib/weakest-link.mjs +417 -0
  33. package/bin/lib/write-path-capabilities.mjs +186 -0
  34. package/bin/lib/write-path-detect.mjs +62 -99
  35. package/compat/nestjs.cjs +2 -0
  36. package/compat/nestjs.d.ts +2 -0
  37. package/compat/nestjs.js +1 -0
  38. package/compat/runtime.cjs +2 -0
  39. package/compat/runtime.d.ts +2 -0
  40. package/compat/runtime.js +1 -0
  41. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  42. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  43. package/dist/eslint/index.cjs +500 -61
  44. package/dist/eslint/index.d.cts +36 -20
  45. package/dist/eslint/index.d.ts +36 -20
  46. package/dist/eslint/index.js +500 -61
  47. package/dist/index.cjs +1349 -2741
  48. package/dist/index.d.cts +449 -483
  49. package/dist/index.d.ts +449 -483
  50. package/dist/index.js +1325 -2687
  51. package/docs/agent-guide.md +58 -34
  52. package/docs/ai-gates.md +79 -21
  53. package/docs/configuration.md +97 -0
  54. package/docs/enthusiast/README.md +3 -3
  55. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  56. package/docs/migrate-from-ark-runtime-kernel.md +5 -3
  57. package/docs/package-surface.md +19 -19
  58. package/docs/production-hardening.md +31 -5
  59. package/docs/threat-model.md +65 -0
  60. package/docs/typescript-support.md +30 -3
  61. package/package.json +46 -11
  62. package/schemas/ark.analysis-result.schema.json +91 -0
  63. package/schemas/ark.config.schema.json +750 -0
  64. package/server.json +2 -2
  65. package/templates/hooks/pre-commit-ark +37 -0
  66. package/templates/skills/ark-architect.md +3 -2
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
  71. package/dist/eslint/index.cjs.map +0 -1
  72. package/dist/eslint/index.js.map +0 -1
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/nestjs/index.cjs +0 -2498
  76. package/dist/nestjs/index.cjs.map +0 -1
  77. package/dist/nestjs/index.d.cts +0 -22
  78. package/dist/nestjs/index.d.ts +0 -22
  79. package/dist/nestjs/index.js +0 -2474
  80. package/dist/nestjs/index.js.map +0 -1
  81. package/dist/runtime/index.cjs +0 -3352
  82. package/dist/runtime/index.cjs.map +0 -1
  83. package/dist/runtime/index.d.cts +0 -2
  84. package/dist/runtime/index.d.ts +0 -2
  85. package/dist/runtime/index.js +0 -3270
  86. package/dist/runtime/index.js.map +0 -1
  87. package/dist/types-BZ17b9i5.d.cts +0 -1068
  88. package/dist/types-BZ17b9i5.d.ts +0 -1068
@@ -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
+ }
@@ -21,6 +21,68 @@ function arkPackageVersion() {
21
21
  }
22
22
  }
23
23
 
24
+ function matchingBrace(text, openIndex) {
25
+ let depth = 0;
26
+ let quoted = false;
27
+ let escaped = false;
28
+ for (let index = openIndex; index < text.length; index += 1) {
29
+ const char = text[index];
30
+ if (quoted) {
31
+ if (escaped) escaped = false;
32
+ else if (char === '\\') escaped = true;
33
+ else if (char === '"') quoted = false;
34
+ continue;
35
+ }
36
+ if (char === '"') quoted = true;
37
+ else if (char === '{') depth += 1;
38
+ else if (char === '}' && --depth === 0) return index;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ function addDevDependencyPreservingFormat(source, version) {
44
+ const multiline = /\r?\n/.test(source);
45
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
46
+ const rootPropertyIndent = source.match(/\r?\n([ \t]+)"[^"\n]+"\s*:/)?.[1] ?? ' ';
47
+ const indentUnit = rootPropertyIndent;
48
+ const encoded = JSON.stringify(version);
49
+ const devMatch = /"devDependencies"\s*:\s*\{/.exec(source);
50
+
51
+ if (devMatch) {
52
+ const open = source.indexOf('{', devMatch.index);
53
+ const close = matchingBrace(source, open);
54
+ if (close === -1) throw new Error('Unbalanced devDependencies object');
55
+ const body = source.slice(open + 1, close);
56
+ if (!multiline) {
57
+ const addition = body.trim() ? `,"arkgate":${encoded}` : `"arkgate":${encoded}`;
58
+ return `${source.slice(0, close)}${addition}${source.slice(close)}`;
59
+ }
60
+ const beforeClose = source.slice(0, close);
61
+ const trailing = beforeClose.match(/\s*$/)?.[0] ?? '';
62
+ const contentEnd = close - trailing.length;
63
+ const closingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
64
+ const propertyIndent = `${closingIndent}${indentUnit}`;
65
+ const addition = body.trim()
66
+ ? `,${eol}${propertyIndent}"arkgate": ${encoded}`
67
+ : `${propertyIndent}"arkgate": ${encoded}`;
68
+ return `${source.slice(0, contentEnd)}${addition}${eol}${closingIndent}${source.slice(close)}`;
69
+ }
70
+
71
+ const rootClose = source.lastIndexOf('}');
72
+ if (rootClose === -1) throw new Error('Unbalanced package.json object');
73
+ const rootBody = source.slice(0, rootClose);
74
+ if (!multiline) {
75
+ const separator = rootBody.trim().endsWith('{') ? '' : ',';
76
+ return `${rootBody}${separator}"devDependencies":{"arkgate":${encoded}}${source.slice(rootClose)}`;
77
+ }
78
+ const trailing = rootBody.match(/\s*$/)?.[0] ?? '';
79
+ const contentEnd = rootClose - trailing.length;
80
+ const rootClosingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
81
+ const separator = source.slice(0, contentEnd).trimEnd().endsWith('{') ? '' : ',';
82
+ const addition = `${separator}${eol}${rootPropertyIndent}"devDependencies": {${eol}${rootPropertyIndent}${indentUnit}"arkgate": ${encoded}${eol}${rootPropertyIndent}}`;
83
+ return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
84
+ }
85
+
24
86
  /**
25
87
  * Ensure a check command string includes `--baseline <file>`.
26
88
  * Only touches strings that already invoke ark-check / arkgate-check.
@@ -150,16 +212,15 @@ export function pinArkgateDevDependency(root, opts = {}) {
150
212
  return { changed: false, reason: 'no-package-json' };
151
213
  }
152
214
  let pkg;
215
+ let source;
153
216
  try {
154
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
217
+ source = fs.readFileSync(pkgPath, 'utf8');
218
+ pkg = JSON.parse(source);
155
219
  } catch {
156
220
  return { changed: false, reason: 'unreadable-package-json' };
157
221
  }
158
222
  const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
159
- const dev =
160
- pkg.devDependencies && typeof pkg.devDependencies === 'object'
161
- ? { ...pkg.devDependencies }
162
- : {};
223
+ const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
163
224
  if (typeof deps.arkgate === 'string' || typeof dev.arkgate === 'string') {
164
225
  return {
165
226
  changed: false,
@@ -174,12 +235,8 @@ export function pinArkgateDevDependency(root, opts = {}) {
174
235
  : shipped
175
236
  ? `^${shipped}`
176
237
  : 'latest';
177
- dev.arkgate = version;
178
238
  if (opts.write !== false) {
179
- fs.writeFileSync(
180
- pkgPath,
181
- `${JSON.stringify({ ...pkg, devDependencies: dev }, null, 2)}\n`
182
- );
239
+ fs.writeFileSync(pkgPath, addDevDependencyPreservingFormat(source, version));
183
240
  }
184
241
  return { changed: true, reason: 'added', version };
185
242
  }
@@ -119,6 +119,40 @@ export const REQUIRED_GATE_FILES = [
119
119
  '.mcp.json',
120
120
  ];
121
121
  const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
122
+ const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
123
+
124
+ const COMPACT_HOST_FILES = {
125
+ claude: ['.claude/settings.json'],
126
+ grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json'],
127
+ cursor: ['.cursor/mcp.json'],
128
+ codex: ['.codex/hooks.json'],
129
+ windsurf: ['.windsurf/rules/ark.md'],
130
+ cline: ['.clinerules/ark.md'],
131
+ copilot: ['.github/copilot-instructions.md'],
132
+ kiro: ['.kiro/steering/ark.md'],
133
+ roo: ['.roo/rules/ark.md'],
134
+ continue: ['.continue/rules/ark.md'],
135
+ gemini: ['GEMINI.md'],
136
+ };
137
+
138
+ export function compactRouterHost(root) {
139
+ try {
140
+ const agents = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
141
+ return agents.match(COMPACT_ROUTER)?.[1] ?? null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ export function isCompactRouterAgentsContent(text) {
148
+ return typeof text === 'string' && COMPACT_ROUTER.test(text);
149
+ }
150
+
151
+ function hasCompactHostRegistration(root, host) {
152
+ if (host === 'none') return fs.existsSync(path.join(root, '.mcp.json'));
153
+ const files = COMPACT_HOST_FILES[host];
154
+ return Boolean(files) && files.every((relativePath) => fs.existsSync(path.join(root, relativePath)));
155
+ }
122
156
 
123
157
  export function hasArkWorkflow(root) {
124
158
  const workflowsDir = path.join(root, '.github', 'workflows');
@@ -141,9 +175,14 @@ export function hasArkWorkflow(root) {
141
175
  }
142
176
 
143
177
  export function missingGates(root) {
144
- const missing = REQUIRED_GATE_FILES.filter(
145
- (relativePath) => !fs.existsSync(path.join(root, relativePath))
146
- );
178
+ const compactHost = compactRouterHost(root);
179
+ const required = compactHost
180
+ ? REQUIRED_GATE_FILES.filter((relativePath) => relativePath !== '.mcp.json')
181
+ : REQUIRED_GATE_FILES;
182
+ const missing = required.filter((relativePath) => !fs.existsSync(path.join(root, relativePath)));
183
+ if (compactHost && !hasCompactHostRegistration(root, compactHost)) {
184
+ missing.push(`compact host registration (${compactHost})`);
185
+ }
147
186
  if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
148
187
  return missing;
149
188
  }
@@ -1,56 +1,6 @@
1
- /**
2
- * Import-graph cycle detection (Tarjan) for ark-check.
3
- * Extracted from ark-check entry (R3).
4
- */
5
- export function detectCycles(graph) {
6
- let index = 0;
7
- const indices = new Map();
8
- const low = new Map();
9
- const onStack = new Set();
10
- const stack = [];
11
- const components = [];
12
-
13
- // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
14
- const strongconnect = (v) => {
15
- indices.set(v, index);
16
- low.set(v, index);
17
- index += 1;
18
- stack.push(v);
19
- onStack.add(v);
20
- for (const w of [...(graph.get(v) ?? [])].sort()) {
21
- if (!graph.has(w)) continue;
22
- if (!indices.has(w)) {
23
- strongconnect(w);
24
- low.set(v, Math.min(low.get(v), low.get(w)));
25
- } else if (onStack.has(w)) {
26
- low.set(v, Math.min(low.get(v), indices.get(w)));
27
- }
28
- }
29
- if (low.get(v) === indices.get(v)) {
30
- const comp = [];
31
- let w;
32
- do {
33
- w = stack.pop();
34
- onStack.delete(w);
35
- comp.push(w);
36
- } while (w !== v);
37
- if (comp.length > 1) components.push(comp.sort());
38
- }
39
- };
1
+ /** Compatibility adapter; canonical Tarjan evaluation lives in the bundled Kernel engine. */
2
+ import { detectArchitectureCycles } from './analysis-engine.mjs';
40
3
 
41
- for (const v of [...graph.keys()].sort()) {
42
- if (!indices.has(v)) strongconnect(v);
43
- }
44
-
45
- return components
46
- .sort((a, b) => a[0].localeCompare(b[0]))
47
- .map((members) => ({
48
- ruleId: 'CIRCULAR_DEPENDENCY',
49
- file: members[0],
50
- line: 1,
51
- target: members.join(' → '),
52
- message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
53
- // Graph is value/runtime edges only (type-only imports omitted).
54
- cycleKind: 'value',
55
- }));
4
+ export function detectCycles(graph) {
5
+ return detectArchitectureCycles(graph);
56
6
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Host hook / MCP project templates for agent-gate install (Claude, Grok).
2
+ * Host hook / MCP project templates for agent-gate install (Claude, Grok, Codex).
3
3
  * Kept out of agent-gates.mjs so install orchestration stays scannable (explore gap #5).
4
4
  */
5
5
  import { execCommandParts, execRunner } from '../ark-shared.mjs';
@@ -41,6 +41,38 @@ export function claudeSettings(root) {
41
41
  }, null, 2)}\n`;
42
42
  }
43
43
 
44
+ export function codexHooks(root) {
45
+ const runner = execRunner(root);
46
+ const codexRoot = '${CODEX_PROJECT_DIR:-${PWD:-.}}';
47
+ return `${JSON.stringify({
48
+ hooks: {
49
+ SessionStart: [
50
+ {
51
+ hooks: [
52
+ {
53
+ type: 'command',
54
+ timeout: 30,
55
+ command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${codexRoot}" --config ark.config.json`,
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ PreToolUse: [
61
+ {
62
+ matcher: 'ApplyPatch|apply_patch|Write|Edit|MultiEdit',
63
+ hooks: [
64
+ {
65
+ type: 'command',
66
+ timeout: 30,
67
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${codexRoot}" --config ark.config.json`,
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ }, null, 2)}\n`;
74
+ }
75
+
44
76
  // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
45
77
  // Codex's global config.toml, Grok loads .grok/config.toml from the project).
46
78
  export function grokProjectConfig(root) {
@@ -0,0 +1,83 @@
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(
40
+ 'OpenAI Codex',
41
+ '.codex/hooks.json',
42
+ 'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
43
+ false,
44
+ false
45
+ ),
46
+ });
47
+
48
+ export const HOST_SUPPORT_HOSTS = Object.freeze(Object.keys(HOST_SUPPORT_MATRIX));
49
+
50
+ export function getHostSupportProfile(host) {
51
+ const normalized = typeof host === 'string' ? host.trim().toLowerCase() : '';
52
+ return HOST_SUPPORT_MATRIX[normalized] ?? null;
53
+ }
54
+
55
+ export function formatHostSupportSummary(profile) {
56
+ if (!profile) return 'unknown host; no local write guarantee';
57
+ const capabilities = profile.capabilities;
58
+ const write = capabilities['hard-write']
59
+ ? 'hard local write boundary'
60
+ : 'no hard local write boundary';
61
+ const repair = capabilities['repair-payload'] ? 'repair payload' : 'no hard-boundary repair';
62
+ return `${write} + advisory MCP + CI check + ${repair}`;
63
+ }
64
+
65
+ export function renderHostSupportMatrixMarkdown() {
66
+ const rows = HOST_SUPPORT_HOSTS.map((host) => {
67
+ const profile = HOST_SUPPORT_MATRIX[host];
68
+ const capabilities = profile.capabilities;
69
+ const local = capabilities['hard-write']
70
+ ? `Hard block for ${profile.hookSurface}`
71
+ : 'No hard hook; MCP/rules are advisory';
72
+ const repair = capabilities['repair-payload']
73
+ ? 'Emitted on hook deny; host must re-inject'
74
+ : 'No hard-boundary payload';
75
+ return `| ${profile.label} | ${local} | Advisory; the agent must call it | Available \`arkgate-check --strict-merge\` check | ${repair} |`;
76
+ }).join('\n');
77
+
78
+ return `| Host | Local write boundary | MCP validation | CI / merge path | Repair payload |
79
+ |------|----------------------|----------------|-----------------|----------------|
80
+ ${rows}
81
+
82
+ 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.`;
83
+ }
@@ -14,17 +14,20 @@ import {
14
14
  codexPromptsDir,
15
15
  codexConfigPath,
16
16
  isTempOrUpgradeRoot,
17
+ usesDefaultCodexHome,
17
18
  wireCodexMcp,
18
19
  } from './codex-home.mjs';
19
20
  import {
20
21
  PREFERRED_MCP_BIN,
21
22
  claudeSettings,
23
+ codexHooks,
22
24
  grokHooks,
23
25
  grokProjectConfig,
24
26
  } from './hook-templates.mjs';
25
27
  import {
26
28
  hasCheckArchitectureScript,
27
29
  ensureTypecheckScript,
30
+ compactRouterHost,
28
31
  writeTemplate,
29
32
  } from './gate-files.mjs';
30
33
  import {
@@ -35,14 +38,13 @@ import {
35
38
  detectCiNode,
36
39
  cursorRule,
37
40
  instructionRule,
41
+ compactAgentInstructions,
38
42
  codexTomlSnippet,
39
43
  arkCheckCommand,
40
44
  checkArchitectureScriptSnippet,
41
45
  } from './ci-and-commands.mjs';
42
46
  import {
43
- normalizeToolsList,
44
47
  resolveTools,
45
- KNOWN_TOOLS,
46
48
  SKILL_TOOL_TARGETS,
47
49
  skillTemplates,
48
50
  stampSkill,
@@ -59,6 +61,11 @@ import {
59
61
  PREFERRED_CHECK_BIN,
60
62
  RUNNER_BEFORE_ARK,
61
63
  } from './mcp-adoption.mjs';
64
+ import {
65
+ hasHardWriteHook,
66
+ validateHardWriteRequest,
67
+ validateSelectedTools,
68
+ } from './enforcement-profiles.mjs';
62
69
 
63
70
  export function staleRunnerGateFiles(root) {
64
71
  const want = execRunner(root);
@@ -179,26 +186,53 @@ export function runInstallAgentGates(args) {
179
186
  return;
180
187
  }
181
188
  if (args.tools != null) {
182
- const list = normalizeToolsList(args.tools);
183
- args.tools = list;
184
- const unknown = list.filter((tool) => !KNOWN_TOOLS.includes(tool));
185
- if (list.length === 0 || unknown.length > 0) {
186
- console.error(
187
- `--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
188
- (unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
189
- );
189
+ const selection = validateSelectedTools(args.tools);
190
+ if (!selection.ok) {
191
+ console.error(selection.error);
190
192
  process.exitCode = 2;
191
193
  return;
192
194
  }
195
+ args.tools = selection.tools;
196
+ }
197
+ const writeRequest = validateHardWriteRequest({
198
+ root,
199
+ host: args.requireWriteHook,
200
+ tools: args.tools,
201
+ force: args.force,
202
+ });
203
+ if (!writeRequest.ok) {
204
+ console.error(writeRequest.error);
205
+ process.exitCode = 2;
206
+ return;
207
+ }
208
+ if (writeRequest.host && args.tools == null) {
209
+ args.tools = writeRequest.tools;
210
+ }
211
+ if (writeRequest.host && args.skillsOnly && !hasHardWriteHook(root, writeRequest.host)) {
212
+ console.error(
213
+ `--skills-only cannot install the requested ${writeRequest.host} hard-write hook. ` +
214
+ 'Remove --skills-only or omit --require-write-hook.'
215
+ );
216
+ process.exitCode = 2;
217
+ return;
193
218
  }
194
219
  const pm = packageManager(root);
195
220
  const hasCheckScript = hasCheckArchitectureScript(root);
196
- const { tools, source } = resolveTools(args);
221
+ const { tools, source } = args.compact && args.tools == null
222
+ ? { tools: new Set(), source: 'compact-none' }
223
+ : resolveTools(args);
224
+ if (args.compact && tools.size > 1) {
225
+ console.error('--compact accepts exactly one selected host. Pass --tools <host>.');
226
+ process.exitCode = 2;
227
+ return;
228
+ }
197
229
  const toolSource =
198
230
  source === 'explicit'
199
231
  ? 'from --tools'
200
232
  : source === 'detected'
201
233
  ? 'auto-detected from config dirs'
234
+ : source === 'compact-none'
235
+ ? 'no active host detected'
202
236
  : 'default set — no agent config dirs found';
203
237
  console.log(`Agent gates for: ${[...tools].sort().join(', ')} (${toolSource})`);
204
238
  const templates = [];
@@ -209,15 +243,21 @@ export function runInstallAgentGates(args) {
209
243
  // Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
210
244
  if (!args.skillsOnly) {
211
245
  // Bootstrap typecheck before CI template so generated workflow includes the step.
212
- const typecheckBootstrap = ensureTypecheckScript(root, { write: true });
213
- if (typecheckBootstrap.changed && !args.json) {
246
+ const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
247
+ if (typecheckBootstrap.changed && !args.compact && !args.json) {
214
248
  console.log(
215
249
  `Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
216
250
  );
217
251
  }
218
252
  // Base gates: tool-agnostic contract + CI backstop, always written.
219
- templates.push(['AGENTS.md', agentInstructions(root)]);
220
- templates.push(['.mcp.json', mcpJson(root)]);
253
+ const compactHost = args.compact ? [...tools][0] ?? null : null;
254
+ templates.push([
255
+ 'AGENTS.md',
256
+ args.compact ? compactAgentInstructions(root, compactHost) : agentInstructions(root),
257
+ ]);
258
+ if (!args.compact || !compactHost || compactHost === 'claude') {
259
+ templates.push(['.mcp.json', mcpJson(root)]);
260
+ }
221
261
  templates.push([
222
262
  '.github/workflows/ark-check.yml',
223
263
  (() => {
@@ -230,13 +270,14 @@ export function runInstallAgentGates(args) {
230
270
  ]);
231
271
  if (tools.has('cursor')) {
232
272
  templates.push(['.cursor/mcp.json', mcpJson(root)]);
233
- templates.push(['.cursor/rules/ark.mdc', cursorRule(root)]);
273
+ if (!args.compact) templates.push(['.cursor/rules/ark.mdc', cursorRule(root)]);
234
274
  }
235
275
  if (tools.has('claude')) {
236
276
  templates.push(['.claude/settings.json', claudeSettings(root)]);
237
277
  }
238
278
  if (tools.has('codex')) {
239
- templates.push(['docs/ark-codex-config.toml', codexTomlSnippet(root)]);
279
+ templates.push(['.codex/hooks.json', codexHooks(root)]);
280
+ if (!args.compact) templates.push(['docs/ark-codex-config.toml', codexTomlSnippet(root)]);
240
281
  }
241
282
  if (tools.has('grok')) {
242
283
  templates.push(['.grok/config.toml', grokProjectConfig(root)]);
@@ -274,18 +315,37 @@ export function runInstallAgentGates(args) {
274
315
  const version = arkPackageVersion();
275
316
  const skills = skillTemplates().map(([name, content]) => [name, stampSkill(content, version)]);
276
317
  const skillPaths = new Set();
277
- for (const tool of tools) {
278
- const target = SKILL_TOOL_TARGETS[tool];
279
- if (!target) continue;
280
- for (const [name, content] of skills) {
281
- const relativePath = target(name);
282
- skillPaths.add(relativePath);
283
- templates.push([relativePath, content]);
318
+ if (!args.compact) {
319
+ for (const tool of tools) {
320
+ const target = SKILL_TOOL_TARGETS[tool];
321
+ if (!target) continue;
322
+ for (const [name, content] of skills) {
323
+ const relativePath = target(name);
324
+ skillPaths.add(relativePath);
325
+ templates.push([relativePath, content]);
326
+ }
327
+ }
328
+ }
329
+
330
+ // A compact router can be moved back from an explicit host removal. Delete the
331
+ // generic MCP file only when it exactly matches Ark's generated artifact.
332
+ const priorCompactHost = compactRouterHost(root);
333
+ if (args.compact && priorCompactHost === 'none' && [...tools][0]) {
334
+ const genericMcp = path.join(root, '.mcp.json');
335
+ try {
336
+ if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) fs.rmSync(genericMcp);
337
+ } catch {
338
+ // Missing or customized user MCP configuration is deliberately retained.
284
339
  }
285
340
  }
286
341
 
287
342
  const results = templates.map(([relativePath, content]) =>
288
- writeTemplate(root, relativePath, content, args.force)
343
+ writeTemplate(
344
+ root,
345
+ relativePath,
346
+ content,
347
+ args.force || (args.compact && relativePath === 'AGENTS.md' && priorCompactHost !== null)
348
+ )
289
349
  );
290
350
 
291
351
  console.log('Ark agent gate templates:');
@@ -370,16 +430,17 @@ export function runInstallAgentGates(args) {
370
430
  // without a manual copy step.
371
431
  //
372
432
  // Skip home-dir mutation when the project root is a temp/upgrade scratch *and*
373
- // CODEX_HOME is the default (~/.codex). Fixtures must not rewrite the developer's
374
- // real Codex config. When CODEX_HOME is redirected (unit tests set a temp home) or
375
- // --codex-home is explicit, wire as usual.
433
+ // CODEX_HOME resolves to the default (~/.codex). Codex itself may export that exact
434
+ // path, so presence alone does not prove isolation. Fixtures must not rewrite the
435
+ // developer's real config. A genuinely redirected CODEX_HOME or explicit
436
+ // --codex-home still wires as requested.
376
437
  let codexMcp = null;
377
- const wantCodexWire = tools.has('codex') || args.codexHome;
438
+ const wantCodexWire = !args.compact && (tools.has('codex') || args.codexHome);
378
439
  const skipHomeWire =
379
440
  wantCodexWire &&
380
441
  isTempOrUpgradeRoot(root) &&
381
442
  !args.codexHome &&
382
- !process.env.CODEX_HOME;
443
+ usesDefaultCodexHome();
383
444
  if (wantCodexWire && !skipHomeWire) {
384
445
  codexMcp = wireCodexMcp(root, args.force);
385
446
  console.log('');
@@ -416,6 +477,14 @@ export function runInstallAgentGates(args) {
416
477
  `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --tools codex --force.`
417
478
  );
418
479
  }
480
+ if (writeRequest.host) {
481
+ if (!hasHardWriteHook(root, writeRequest.host)) {
482
+ console.error(`\nFailed to verify the ${writeRequest.host} hard-write hook after install.`);
483
+ process.exitCode = 1;
484
+ return;
485
+ }
486
+ console.log(`\nHard-write hook verified for ${writeRequest.host}.`);
487
+ }
419
488
  console.log('');
420
489
  console.log('Next steps:');
421
490
  console.log(' 1. Review the generated files and commit the ones that match your tools.');