arkgate 2.10.0 → 2.12.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 (55) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +21 -12
  3. package/SECURITY.md +3 -4
  4. package/bin/ark-check.mjs +41 -16
  5. package/bin/ark-mcp.mjs +54 -10
  6. package/bin/ark.mjs +87 -24
  7. package/bin/lib/agent-gates.mjs +68 -2090
  8. package/bin/lib/architecture-scan.mjs +4 -1
  9. package/bin/lib/baseline-key.mjs +17 -0
  10. package/bin/lib/ci-and-commands.mjs +386 -0
  11. package/bin/lib/config-warnings.mjs +22 -0
  12. package/bin/lib/core-layers.mjs +7 -0
  13. package/bin/lib/core-ratchet.mjs +3 -7
  14. package/bin/lib/deploy-path.mjs +205 -0
  15. package/bin/lib/doctor-plan.mjs +29 -5
  16. package/bin/lib/gate-files.mjs +223 -0
  17. package/bin/lib/hook-templates.mjs +99 -0
  18. package/bin/lib/install-migrate.mjs +442 -0
  19. package/bin/lib/mcp-adoption.mjs +423 -0
  20. package/bin/lib/presets.mjs +3 -0
  21. package/bin/lib/safety-diagnostics.mjs +263 -0
  22. package/bin/lib/scan-files.mjs +51 -6
  23. package/bin/lib/skill-install.mjs +259 -0
  24. package/bin/lib/typescript-host.mjs +88 -0
  25. package/bin/lib/violations.mjs +3 -3
  26. package/bin/lib/write-path-detect.mjs +138 -0
  27. package/dist/index.cjs +103 -8
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -3
  30. package/dist/index.d.ts +5 -3
  31. package/dist/index.js +103 -8
  32. package/dist/index.js.map +1 -1
  33. package/dist/nestjs/index.cjs +18 -5
  34. package/dist/nestjs/index.cjs.map +1 -1
  35. package/dist/nestjs/index.d.cts +1 -1
  36. package/dist/nestjs/index.d.ts +1 -1
  37. package/dist/nestjs/index.js +18 -5
  38. package/dist/nestjs/index.js.map +1 -1
  39. package/dist/runtime/index.cjs +103 -8
  40. package/dist/runtime/index.cjs.map +1 -1
  41. package/dist/runtime/index.d.cts +1 -1
  42. package/dist/runtime/index.d.ts +1 -1
  43. package/dist/runtime/index.js +103 -8
  44. package/dist/runtime/index.js.map +1 -1
  45. package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
  46. package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
  47. package/docs/agent-guide.md +12 -2
  48. package/docs/ai-gates.md +20 -2
  49. package/docs/package-surface.md +10 -3
  50. package/docs/production-hardening.md +5 -0
  51. package/package.json +5 -2
  52. package/server.json +2 -2
  53. package/templates/skills/ark-autopilot.md +77 -45
  54. package/templates/skills/ark-explain.md +2 -1
  55. package/templates/skills/ark-explore.md +135 -34
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Production deploy-path quality signals (install modularization).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { readPackageJson, packageScriptsHaveTypecheck } from './gate-files.mjs';
7
+
8
+ /**
9
+ * Production deploy path quality (universal — any consumer repo).
10
+ * Detects when the production build host runs ESLint / typecheck as part of
11
+ * `build` (e.g. Next.js "Linting and checking validity of types") so failures
12
+ * surface first on Vercel/Netlify/etc. unless CI/pre-merge runs the same checks.
13
+ * Framework signals only (deps + scripts + config) — never project-specific.
14
+ *
15
+ * @returns {{
16
+ * embedsLintInBuild: boolean,
17
+ * embedsTypecheckInBuild: boolean,
18
+ * engines: string[],
19
+ * hasLintScript: boolean,
20
+ * hasTypecheckScript: boolean,
21
+ * ciRunsLint: boolean,
22
+ * ciRunsTypecheck: boolean,
23
+ * eslintIgnoreDuringBuilds: boolean,
24
+ * }}
25
+ */
26
+ export function detectDeployPathQuality(root) {
27
+ const pkg = readPackageJson(root) || {};
28
+ const deps = {
29
+ ...(pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}),
30
+ ...(pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {}),
31
+ ...(pkg.peerDependencies && typeof pkg.peerDependencies === 'object' ? pkg.peerDependencies : {}),
32
+ };
33
+ const scripts =
34
+ pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
35
+ const buildScript = typeof scripts.build === 'string' ? scripts.build : '';
36
+
37
+ const engines = [];
38
+ // Next.js production build runs ESLint + typecheck by default (unless opted out).
39
+ if (deps.next || /\bnext\s+build\b/.test(buildScript)) engines.push('next');
40
+ // Nuxt 3+ can lint via modules; only flag when build clearly invokes nuxt build + eslint tooling present.
41
+ if ((deps.nuxt || deps['nuxt3'] || /\bnuxt\s+build\b/.test(buildScript)) && (deps.eslint || hasEslintConfig(root))) {
42
+ engines.push('nuxt');
43
+ }
44
+ // Create React App historically failed build on ESLint errors.
45
+ if (deps['react-scripts'] || /\breact-scripts\s+build\b/.test(buildScript)) engines.push('cra');
46
+
47
+ const eslintIgnoreDuringBuilds = engines.includes('next') && nextIgnoresEslintDuringBuilds(root);
48
+ const embedsLintInBuild = engines.length > 0 && !eslintIgnoreDuringBuilds;
49
+ // Next still typechecks during build even when eslint.ignoreDuringBuilds is true.
50
+ const embedsTypecheckInBuild = engines.includes('next') || engines.includes('nuxt');
51
+
52
+ const scriptHasLint = (s) =>
53
+ Boolean(
54
+ s &&
55
+ ((typeof s.lint === 'string' && s.lint.trim()) ||
56
+ (typeof s.eslint === 'string' && s.eslint.trim()) ||
57
+ (typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
58
+ (typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
59
+ );
60
+
61
+ let hasLintScript = scriptHasLint(scripts);
62
+ let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
63
+ const packageLintScripts = [];
64
+ // Monorepo: package-level scripts count (apps/web, packages/ui, …).
65
+ try {
66
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
67
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
68
+ const candidates = [path.join(root, entry.name)];
69
+ // one more level: packages/foo
70
+ try {
71
+ for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
72
+ if (child.isDirectory() && !child.name.startsWith('.')) {
73
+ candidates.push(path.join(root, entry.name, child.name));
74
+ }
75
+ }
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ for (const dir of candidates) {
80
+ const pj = path.join(dir, 'package.json');
81
+ if (!fs.existsSync(pj)) continue;
82
+ try {
83
+ const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
84
+ const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
85
+ if (scriptHasLint(ns)) {
86
+ hasLintScript = true;
87
+ packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
88
+ }
89
+ if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
90
+ const nd = {
91
+ ...(nested.dependencies || {}),
92
+ ...(nested.devDependencies || {}),
93
+ };
94
+ if (nd.next && !engines.includes('next')) engines.push('next');
95
+ } catch {
96
+ /* ignore */
97
+ }
98
+ }
99
+ }
100
+ } catch {
101
+ /* ignore */
102
+ }
103
+
104
+ const ciTexts = collectCiWorkflowTexts(root);
105
+ const ciJoined = ciTexts.join('\n');
106
+ const ciRunsLint =
107
+ ciTexts.length > 0 &&
108
+ (/\bnpm\s+run\s+lint\b/i.test(ciJoined) ||
109
+ /\bpnpm\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
110
+ /\byarn\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
111
+ /\bbun\s+run\s+lint\b/i.test(ciJoined) ||
112
+ /\beslint\b/i.test(ciJoined) ||
113
+ /\blint:ci\b/i.test(ciJoined) ||
114
+ /\bcheck:lint\b/i.test(ciJoined) ||
115
+ // package-level: working-directory + lint, or path/filter lint
116
+ (packageLintScripts.length > 0 &&
117
+ packageLintScripts.some((p) => ciJoined.includes(p) && /lint/i.test(ciJoined))));
118
+ const ciRunsTypecheck =
119
+ ciTexts.length > 0 &&
120
+ (/\btypecheck\b/i.test(ciJoined) ||
121
+ /\btype-check\b/i.test(ciJoined) ||
122
+ /\bcheck:types\b/i.test(ciJoined) ||
123
+ /\btsc\s+--noEmit\b/i.test(ciJoined));
124
+
125
+ return {
126
+ embedsLintInBuild,
127
+ embedsTypecheckInBuild,
128
+ engines,
129
+ hasLintScript,
130
+ hasTypecheckScript,
131
+ ciRunsLint,
132
+ ciRunsTypecheck,
133
+ eslintIgnoreDuringBuilds,
134
+ hasCiWorkflows: ciTexts.length > 0,
135
+ packageLintScripts,
136
+ };
137
+ }
138
+
139
+ function hasEslintConfig(root) {
140
+ return [
141
+ 'eslint.config.mjs',
142
+ 'eslint.config.js',
143
+ 'eslint.config.cjs',
144
+ 'eslint.config.ts',
145
+ '.eslintrc.json',
146
+ '.eslintrc.cjs',
147
+ '.eslintrc.js',
148
+ '.eslintrc.yml',
149
+ '.eslintrc.yaml',
150
+ ].some((f) => fs.existsSync(path.join(root, f)));
151
+ }
152
+
153
+ /** next.config.* eslint.ignoreDuringBuilds: true → production build will not fail on ESLint. */
154
+ function nextIgnoresEslintDuringBuilds(root) {
155
+ const names = [
156
+ 'next.config.ts',
157
+ 'next.config.mts',
158
+ 'next.config.js',
159
+ 'next.config.mjs',
160
+ 'next.config.cjs',
161
+ ];
162
+ for (const name of names) {
163
+ const file = path.join(root, name);
164
+ if (!fs.existsSync(file)) continue;
165
+ try {
166
+ const text = fs.readFileSync(file, 'utf8');
167
+ // Common patterns: ignoreDuringBuilds: true | ignoreDuringBuilds: true,
168
+ if (/ignoreDuringBuilds\s*:\s*true/.test(text)) return true;
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function collectCiWorkflowTexts(root) {
177
+ const texts = [];
178
+ const pushFile = (rel) => {
179
+ try {
180
+ const full = path.join(root, rel);
181
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
182
+ texts.push(fs.readFileSync(full, 'utf8'));
183
+ }
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ };
188
+ pushFile('.gitlab-ci.yml');
189
+ pushFile('bitbucket-pipelines.yml');
190
+ pushFile('azure-pipelines.yml');
191
+ pushFile('.circleci/config.yml');
192
+ const wfDir = path.join(root, '.github', 'workflows');
193
+ try {
194
+ if (fs.existsSync(wfDir)) {
195
+ for (const f of fs.readdirSync(wfDir)) {
196
+ if (!/\.ya?ml$/i.test(f)) continue;
197
+ pushFile(path.join('.github', 'workflows', f));
198
+ }
199
+ }
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ return texts;
204
+ }
205
+
@@ -19,7 +19,7 @@ import {
19
19
  staleRunnerGateFiles,
20
20
  } from './agent-gates.mjs';
21
21
  import {
22
- baselineKey,
22
+ baselineOccurrenceKeys,
23
23
  readBaseline,
24
24
  summarizeViolations,
25
25
  violationEdge,
@@ -272,9 +272,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
272
272
  // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
273
273
  const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
274
274
  const baseline = readBaseline(root, '.ark-baseline.json');
275
- const currentKeys = new Set(violations.map(baselineKey));
275
+ const occurrenceKeys = baselineOccurrenceKeys(violations);
276
+ const currentKeys = new Set(occurrenceKeys);
276
277
  const suppressed = baseline.exists
277
- ? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
278
+ ? occurrenceKeys.filter((key) => baseline.keys.has(key)).length
278
279
  : 0;
279
280
  const staleBaseline = baseline.exists
280
281
  ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
@@ -347,6 +348,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
347
348
  : { gap: null }),
348
349
  },
349
350
  adoption,
351
+ safety: options.safety,
350
352
  newHere: showNewHere
351
353
  ? {
352
354
  show: true,
@@ -592,11 +594,33 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
592
594
  line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
593
595
  }
594
596
 
597
+ console.log('');
598
+ console.log(color.bold('Safety / bypass resistance'));
599
+ const safety = options.safety;
600
+ if (!safety) {
601
+ line(warn, 'Safety diagnostics unavailable');
602
+ } else {
603
+ const rows = [
604
+ ['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
605
+ ['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
606
+ ['Explicit any casts', safety.anyCasts],
607
+ ['InMemory stores in production source', safety.inMemoryProductionStores],
608
+ ['Rules with peerIsolation: false', safety.disabledPeerIsolationRules],
609
+ ];
610
+ for (const [label, entries] of rows) {
611
+ line(entries.length === 0 ? ok : warn, `${label}: ${entries.length}`);
612
+ }
613
+ if (rows.some(([, entries]) => entries.length > 0)) {
614
+ actions.push('resolve strict safety diagnostics before treating CI as enforcement');
615
+ }
616
+ }
617
+
595
618
  console.log('');
596
619
  if (actions.length === 0) {
597
620
  console.log(color.green('✔ Healthy — nothing to do.'));
598
621
  } else {
599
- console.log(color.bold(`Top actions (${actions.length}):`));
600
- actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
622
+ const uniqueActions = [...new Set(actions.filter(Boolean))];
623
+ console.log(color.bold(`Top actions (${uniqueActions.length}):`));
624
+ uniqueActions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
601
625
  }
602
626
  }
@@ -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
+ }