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.
- package/CHANGELOG.md +122 -0
- package/README.md +90 -51
- package/bin/ark-check.mjs +156 -39
- package/bin/ark-mcp.mjs +119 -6
- package/bin/ark-shared.mjs +216 -129
- package/bin/ark.mjs +134 -34
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +13 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -127
- package/bin/lib/ci-and-commands.mjs +40 -3
- package/bin/lib/codex-home.mjs +7 -0
- package/bin/lib/config-contract.mjs +331 -0
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/doctor-plan.mjs +43 -16
- package/bin/lib/enforcement-profiles.mjs +97 -0
- package/bin/lib/field-install.mjs +67 -10
- package/bin/lib/gate-files.mjs +42 -3
- package/bin/lib/graph-cycles.mjs +4 -54
- package/bin/lib/hook-templates.mjs +33 -1
- package/bin/lib/host-support-matrix.mjs +83 -0
- package/bin/lib/install-migrate.mjs +99 -30
- package/bin/lib/mcp-adoption.mjs +35 -3
- package/bin/lib/open-html.mjs +75 -0
- package/bin/lib/presets.mjs +45 -4
- package/bin/lib/safety-diagnostics.mjs +36 -15
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +72 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +13 -3
- package/bin/lib/weakest-link.mjs +417 -0
- package/bin/lib/write-path-capabilities.mjs +186 -0
- package/bin/lib/write-path-detect.mjs +62 -99
- package/compat/nestjs.cjs +2 -0
- package/compat/nestjs.d.ts +2 -0
- package/compat/nestjs.js +1 -0
- package/compat/runtime.cjs +2 -0
- package/compat/runtime.d.ts +2 -0
- package/compat/runtime.js +1 -0
- package/dist/configContract-BxSIwVRo.d.cts +259 -0
- package/dist/configContract-BxSIwVRo.d.ts +259 -0
- package/dist/eslint/index.cjs +500 -61
- package/dist/eslint/index.d.cts +36 -20
- package/dist/eslint/index.d.ts +36 -20
- package/dist/eslint/index.js +500 -61
- package/dist/index.cjs +1349 -2741
- package/dist/index.d.cts +449 -483
- package/dist/index.d.ts +449 -483
- package/dist/index.js +1325 -2687
- package/docs/agent-guide.md +58 -34
- package/docs/ai-gates.md +79 -21
- package/docs/configuration.md +97 -0
- package/docs/enthusiast/README.md +3 -3
- package/docs/enthusiast/how-to-agent-gates.md +7 -3
- package/docs/migrate-from-ark-runtime-kernel.md +5 -3
- package/docs/package-surface.md +19 -19
- package/docs/production-hardening.md +31 -5
- package/docs/threat-model.md +65 -0
- package/docs/typescript-support.md +30 -3
- package/package.json +46 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/schemas/ark.config.schema.json +750 -0
- package/server.json +2 -2
- package/templates/hooks/pre-commit-ark +37 -0
- package/templates/skills/ark-architect.md +3 -2
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-runtime.md +8 -5
- package/templates/skills/ark-upgrade.md +36 -16
- package/tests/fixtures/ts-consumer/ark.config.json +2 -0
- package/dist/eslint/index.cjs.map +0 -1
- package/dist/eslint/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nestjs/index.cjs +0 -2498
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -22
- package/dist/nestjs/index.d.ts +0 -22
- package/dist/nestjs/index.js +0 -2474
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -3352
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -2
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.js +0 -3270
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BZ17b9i5.d.cts +0 -1068
- 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
|
-
|
|
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
|
}
|
package/bin/lib/gate-files.mjs
CHANGED
|
@@ -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
|
|
145
|
-
|
|
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
|
}
|
package/bin/lib/graph-cycles.mjs
CHANGED
|
@@ -1,56 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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 } =
|
|
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:
|
|
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
|
-
|
|
220
|
-
templates.push([
|
|
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(['
|
|
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
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
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(
|
|
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
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
-
|
|
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.');
|