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
@@ -32,24 +32,62 @@ export function isSkippedSourceDir(name) {
32
32
  );
33
33
  }
34
34
 
35
- export function walk(dir, files = []) {
36
- const stat = fs.statSync(dir, { throwIfNoEntry: false });
35
+ function isInsideRoot(root, target) {
36
+ const rel = path.relative(root, target);
37
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
38
+ }
39
+
40
+ /**
41
+ * Walk source files while treating symlinks explicitly.
42
+ *
43
+ * When `root` is provided, every resolved file/directory must stay inside it.
44
+ * Internal symlink directories are followed once (TypeScript follows them too),
45
+ * while escaping links fail closed instead of reading arbitrary filesystem paths.
46
+ */
47
+ export function walk(dir, files = [], options = {}) {
48
+ const state = options.state ?? {
49
+ root: options.root ? fs.realpathSync(options.root) : undefined,
50
+ visitedDirectories: new Set(),
51
+ visitedFiles: new Set(),
52
+ };
53
+ const lstat = fs.lstatSync(dir, { throwIfNoEntry: false });
54
+ if (!lstat) return files;
55
+ const resolved = fs.realpathSync(dir);
56
+ if (state.root && !isInsideRoot(state.root, resolved)) {
57
+ throw new Error(
58
+ `Refusing to scan symlink outside project root: ${dir} -> ${resolved}`
59
+ );
60
+ }
61
+ const stat = lstat.isSymbolicLink()
62
+ ? fs.statSync(dir, { throwIfNoEntry: false })
63
+ : lstat;
37
64
  if (!stat) return files;
38
65
  // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
39
66
  // not just a directory — govern it directly instead of trying to scandir it
40
67
  // (which threw ENOTDIR). The extension filter still applies.
41
68
  if (stat.isFile()) {
42
- if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
69
+ if (
70
+ isGovernableSourceFile(path.basename(dir)) &&
71
+ !state.visitedFiles.has(resolved)
72
+ ) {
73
+ state.visitedFiles.add(resolved);
74
+ files.push(dir);
75
+ }
43
76
  return files;
44
77
  }
45
78
  if (!stat.isDirectory()) return files;
79
+ if (state.visitedDirectories.has(resolved)) return files;
80
+ state.visitedDirectories.add(resolved);
46
81
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
82
  const full = path.join(dir, entry.name);
48
83
  if (entry.isDirectory()) {
49
84
  if (isSkippedSourceDir(entry.name)) continue;
50
- walk(full, files);
85
+ walk(full, files, { state });
86
+ } else if (entry.isSymbolicLink()) {
87
+ if (isSkippedSourceDir(entry.name)) continue;
88
+ walk(full, files, { state });
51
89
  } else if (isGovernableSourceFile(entry.name)) {
52
- files.push(full);
90
+ walk(full, files, { state });
53
91
  }
54
92
  }
55
93
  return files;
@@ -57,7 +95,14 @@ export function walk(dir, files = []) {
57
95
 
58
96
  /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
59
97
  export function collectGovernedFiles(root, config) {
60
- const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
98
+ const state = {
99
+ root: fs.realpathSync(root),
100
+ visitedDirectories: new Set(),
101
+ visitedFiles: new Set(),
102
+ };
103
+ const raw = (config.include ?? []).flatMap((entry) =>
104
+ walk(path.join(root, entry), [], { state })
105
+ );
61
106
  return raw.filter((abs) => {
62
107
  const rel = normalize(path.relative(root, abs));
63
108
  return !isScanExcludedRelative(rel, config);
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Tool detection, skill templates, stamping, and skill freshness gaps.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { codexPromptsDir } from './codex-home.mjs';
7
+ import { __packageRoot, readJson } from './gate-files.mjs';
8
+
9
+ export function normalizeToolsList(tools) {
10
+ if (tools == null) return [];
11
+ if (Array.isArray(tools)) {
12
+ return tools
13
+ .flatMap((t) => String(t).split(','))
14
+ .map((t) => t.trim().toLowerCase())
15
+ .filter(Boolean);
16
+ }
17
+ if (typeof tools === 'string') {
18
+ return tools
19
+ .split(',')
20
+ .map((t) => t.trim().toLowerCase())
21
+ .filter(Boolean);
22
+ }
23
+ return [];
24
+ }
25
+
26
+ export function resolveTools(args) {
27
+ const explicit = normalizeToolsList(args.tools);
28
+ if (explicit.length > 0) {
29
+ return { tools: new Set(explicit), source: 'explicit' };
30
+ }
31
+ const root = args.root;
32
+ const detected = new Set();
33
+ if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
34
+ if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
35
+ if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
36
+ if (fs.existsSync(path.join(root, '.grok'))) detected.add('grok');
37
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
38
+ // .clinerules can also be a single FILE (older Cline convention); only a directory
39
+ // can receive .clinerules/ark.md, so a file must not trigger detection.
40
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
41
+ detected.add('cline');
42
+ }
43
+ if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
44
+ if (fs.existsSync(path.join(root, '.roo'))) detected.add('roo');
45
+ if (fs.existsSync(path.join(root, '.continue'))) detected.add('continue');
46
+ if (fs.existsSync(path.join(root, '.gemini'))) detected.add('gemini');
47
+ // copilot has no reliable directory signal (.github exists in most repos),
48
+ // so it is explicit-only via --tools.
49
+ // Host signals: Grok Build / xAI agents often have no project `.grok/` yet but
50
+ // set an env marker (or run with GROK_*). Include Grok so skills install there.
51
+ if (
52
+ process.env.GROK_BUILD === '1' ||
53
+ process.env.GROK_BUILD === 'true' ||
54
+ process.env.XAI_GROK === '1' ||
55
+ process.env.XAI_GROK === 'true'
56
+ ) {
57
+ detected.add('grok');
58
+ }
59
+ // No signal at all: fall back to a complete starter set including Grok (field
60
+ // log: default claude+cursor+codex silently omitted Grok skills for Grok hosts).
61
+ if (detected.size === 0) {
62
+ return { tools: new Set(['claude', 'cursor', 'codex', 'grok']), source: 'default' };
63
+ }
64
+ return { tools: detected, source: 'detected' };
65
+ }
66
+
67
+ export const KNOWN_TOOLS = [
68
+ 'claude',
69
+ 'cursor',
70
+ 'codex',
71
+ 'grok',
72
+ 'windsurf',
73
+ 'cline',
74
+ 'copilot',
75
+ 'kiro',
76
+ 'roo',
77
+ 'continue',
78
+ 'gemini',
79
+ ];
80
+
81
+ // One canonical markdown per skill (templates/skills/*.md, shipped in the npm
82
+ // package); installed into each tool's slash-command location. The YAML
83
+ // frontmatter (name/description) is understood or harmlessly ignored by every
84
+ // host. Kiro has no command mechanism — its steering rule file is the only gate.
85
+ export const SKILL_TOOL_TARGETS = {
86
+ claude: (name) => `.claude/skills/${name}/SKILL.md`,
87
+ cursor: (name) => `.cursor/commands/${name}.md`,
88
+ codex: (name) => `.codex/prompts/${name}.md`,
89
+ // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
90
+ grok: (name) => `.grok/skills/${name}/SKILL.md`,
91
+ windsurf: (name) => `.windsurf/workflows/${name}.md`,
92
+ cline: (name) => `.clinerules/workflows/${name}.md`,
93
+ copilot: (name) => `.github/prompts/${name}.prompt.md`,
94
+ };
95
+
96
+ // The version of the arkgate package these bins ship with. Used to
97
+ // stamp installed skills so a normal ark-check can tell "outdated skill from an
98
+ // older Ark" apart from "user-customized skill" — the stamp moves with the
99
+ // package, editing the body doesn't.
100
+ export function arkPackageVersion() {
101
+ try {
102
+ const pkg = readJson(path.join(__packageRoot, 'package.json'));
103
+ return typeof pkg.version === 'string' ? pkg.version : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
110
+ // `---`). No frontmatter → returned unchanged. Idempotent for a given version.
111
+ export function stampSkill(content, version) {
112
+ if (!version) return content;
113
+ const lines = content.split('\n');
114
+ if (lines[0] !== '---') return content;
115
+ const closeIdx = lines.indexOf('---', 1);
116
+ if (closeIdx === -1) return content;
117
+ const existing = lines.findIndex(
118
+ (line, i) => i > 0 && i < closeIdx && /^arkVersion:/.test(line)
119
+ );
120
+ if (existing !== -1) {
121
+ lines[existing] = `arkVersion: ${version}`;
122
+ } else {
123
+ lines.splice(closeIdx, 0, `arkVersion: ${version}`);
124
+ }
125
+ return lines.join('\n');
126
+ }
127
+
128
+ // Read the `arkVersion:` stamp from an installed skill file. Returns null when
129
+ // the file is absent or has no stamp (installed by a pre-stamp Ark, or hand-authored).
130
+ export function installedSkillVersion(filePath) {
131
+ let content;
132
+ try {
133
+ content = fs.readFileSync(filePath, 'utf8');
134
+ } catch {
135
+ return null;
136
+ }
137
+ const match = content.match(/^arkVersion:\s*(.+)$/m);
138
+ return match ? match[1].trim() : null;
139
+ }
140
+
141
+ // Numeric-tuple compare of dotted versions; true when `a` is strictly older than
142
+ // `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
143
+ export function isVersionOlder(a, b) {
144
+ const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
145
+ const av = parse(a);
146
+ const bv = parse(b);
147
+ const len = Math.max(av.length, bv.length);
148
+ for (let i = 0; i < len; i += 1) {
149
+ const x = av[i] ?? 0;
150
+ const y = bv[i] ?? 0;
151
+ if (x !== y) return x < y;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ export function skillTemplates() {
157
+ const dir = path.join(__packageRoot, 'templates', 'skills');
158
+ // A missing/mispackaged templates dir would otherwise install zero skills with
159
+ // exit 0 — warn so a packaging regression (e.g. "templates" dropped from the
160
+ // package.json files array) is visible instead of a silent no-op.
161
+ let entries;
162
+ try {
163
+ entries = fs.readdirSync(dir, { withFileTypes: true });
164
+ } catch {
165
+ console.error(
166
+ `Warning: skill templates directory not found (${dir}); no /ark-* skills installed.`
167
+ );
168
+ return [];
169
+ }
170
+ return entries
171
+ .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
172
+ .map((entry) => entry.name)
173
+ .sort()
174
+ .map((name) => [path.basename(name, '.md'), fs.readFileSync(path.join(dir, name), 'utf8')]);
175
+ }
176
+
177
+ // Skill names only, silent on a missing templates dir — for the freshness
178
+ // advisory below, which must not print packaging warnings on every check run.
179
+ export function skillTemplateNames() {
180
+ const dir = path.join(__packageRoot, 'templates', 'skills');
181
+ let entries;
182
+ try {
183
+ entries = fs.readdirSync(dir, { withFileTypes: true });
184
+ } catch {
185
+ return [];
186
+ }
187
+ return entries
188
+ .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
189
+ .map((entry) => path.basename(entry.name, '.md'));
190
+ }
191
+
192
+ // A normal ark-check run is the reliable discovery point for new /ark-* skills.
193
+ // Ark ships no install lifecycle script (a postinstall banner would be blocked by
194
+ // modern package managers' script-approval policy anyway, so careful users never
195
+ // saw it — and it broke hardened installs). When a project has adopted Ark agent
196
+ // gates (AGENTS.md present) but a detected tool is missing
197
+ // skills this version ships, surface it here so agents and CI actually notice.
198
+ // Advisory only — never affects the exit code. Copilot has no reliable directory
199
+ // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
200
+ export function detectCodexHomeGap(root) {
201
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
202
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
203
+ const skillNames = skillTemplateNames();
204
+ if (skillNames.length === 0) return null;
205
+ const dir = codexPromptsDir();
206
+ if (!fs.existsSync(dir)) return null;
207
+ const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
208
+ if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
209
+ const version = arkPackageVersion();
210
+ const missing = skillNames.length - present.length;
211
+ let stale = 0;
212
+ if (version) {
213
+ for (const name of present) {
214
+ const installed = installedSkillVersion(path.join(dir, `${name}.md`));
215
+ if (installed === null || isVersionOlder(installed, version)) stale += 1;
216
+ }
217
+ }
218
+ return missing > 0 || stale > 0 ? { missing, stale } : null;
219
+ }
220
+
221
+ export function detectSkillGaps(root) {
222
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return [];
223
+ // The Ark source tree keeps the skill templates at templates/skills/ — it's the
224
+ // producer, not a consumer, so it must not nag itself to "install" its own skills.
225
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
226
+ const skillNames = skillTemplateNames();
227
+ if (skillNames.length === 0) return [];
228
+ const detected = [];
229
+ if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
230
+ if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
231
+ if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
232
+ if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
233
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
234
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
235
+ detected.push('cline');
236
+ }
237
+ const version = arkPackageVersion();
238
+ const gaps = [];
239
+ for (const tool of detected) {
240
+ const target = SKILL_TOOL_TARGETS[tool];
241
+ if (!target) continue;
242
+ let missing = 0;
243
+ let stale = 0;
244
+ for (const name of skillNames) {
245
+ const file = path.join(root, target(name));
246
+ if (!fs.existsSync(file)) {
247
+ missing += 1;
248
+ } else if (version) {
249
+ // An installed skill with no stamp predates stamping (older Ark), or one
250
+ // stamped behind the current version is left over from an older install.
251
+ // Either way the shipped skill has moved on — offer a --force refresh.
252
+ const installed = installedSkillVersion(file);
253
+ if (installed === null || isVersionOlder(installed, version)) stale += 1;
254
+ }
255
+ }
256
+ if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
257
+ }
258
+ return gaps;
259
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * TypeScript host resolution for architecture scan (API-compatible loader).
3
+ */
4
+ import path from 'node:path';
5
+ import { usableTypescript, typescriptUsabilityHint } from '../ark-shared.mjs';
6
+ import { __arkCheckCli } from './gate-files.mjs';
7
+
8
+ export async function loadTypeScript(root) {
9
+ const { createRequire } = await import('node:module');
10
+ const loaders = [];
11
+ try {
12
+ const req = createRequire(path.join(root, 'package.json'));
13
+ loaders.push({
14
+ label: 'project',
15
+ load: () => req('typescript'),
16
+ resolvePath: () => {
17
+ try {
18
+ return req.resolve('typescript');
19
+ } catch {
20
+ return null;
21
+ }
22
+ },
23
+ });
24
+ } catch {
25
+ /* project has no package.json resolvable tree */
26
+ }
27
+ // Nested under arkgate (production dependency) — must work when project has only TS7.
28
+ try {
29
+ const req = createRequire(__arkCheckCli);
30
+ loaders.push({
31
+ label: 'arkgate',
32
+ load: () => req('typescript'),
33
+ resolvePath: () => {
34
+ try {
35
+ return req.resolve('typescript');
36
+ } catch {
37
+ return null;
38
+ }
39
+ },
40
+ });
41
+ } catch {
42
+ /* ark install tree unavailable */
43
+ }
44
+ loaders.push({
45
+ label: 'import',
46
+ load: async () => {
47
+ const m = await import('typescript');
48
+ return m;
49
+ },
50
+ resolvePath: () => null,
51
+ });
52
+
53
+ let projectRejected = null;
54
+ const triedPaths = new Set();
55
+ for (const { label, load, resolvePath } of loaders) {
56
+ try {
57
+ const resolved = typeof resolvePath === 'function' ? resolvePath() : null;
58
+ if (resolved && triedPaths.has(resolved)) {
59
+ // Same physical package already rejected (e.g. project === hoisted arkgate path).
60
+ continue;
61
+ }
62
+ if (resolved) triedPaths.add(resolved);
63
+
64
+ const mod = await load();
65
+ const ts = usableTypescript(mod);
66
+ if (ts) {
67
+ const version =
68
+ typeof ts.version === 'string'
69
+ ? ts.version
70
+ : typeof mod?.version === 'string'
71
+ ? mod.version
72
+ : undefined;
73
+ return {
74
+ ts,
75
+ source: label,
76
+ version,
77
+ ...(projectRejected ? { fallbackReason: projectRejected } : {}),
78
+ };
79
+ }
80
+ if (label === 'project' && mod) {
81
+ projectRejected = `project typescript is not API-compatible (${typescriptUsabilityHint(mod)}); using ArkGate's JS-API TypeScript fallback (TypeScript 7.0 main export is version-only). See docs/typescript-support.md.`;
82
+ }
83
+ } catch {
84
+ /* try next loader */
85
+ }
86
+ }
87
+ return null;
88
+ }
@@ -11,8 +11,8 @@ const color = {
11
11
  };
12
12
 
13
13
  /** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
14
- import { baselineKey } from './baseline-key.mjs';
15
- export { baselineKey };
14
+ import { baselineKey, baselineOccurrenceKeys } from './baseline-key.mjs';
15
+ export { baselineKey, baselineOccurrenceKeys };
16
16
 
17
17
  export function readBaseline(root, baselinePath) {
18
18
  const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
@@ -23,7 +23,7 @@ export function readBaseline(root, baselinePath) {
23
23
 
24
24
  export function writeBaseline(root, baselinePath, violations) {
25
25
  const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
26
- const keys = [...new Set(violations.map(baselineKey))].sort();
26
+ const keys = baselineOccurrenceKeys(violations).sort();
27
27
  fs.writeFileSync(
28
28
  fullPath,
29
29
  `${JSON.stringify({ version: 1, note: 'Frozen ark-check violations. Only NEW violations fail --baseline runs. Regenerate with: ark-check --update-baseline', violations: keys }, null, 2)}\n`
@@ -0,0 +1,138 @@
1
+ /**
2
+ * W5 — Write-path capability surface for doctor (stable additive JSON).
3
+ * Extracted from agent-gates so install orchestration stays scannable.
4
+ *
5
+ * Detects whether installed agent gates expose:
6
+ * - MCP prepare-write / validate_code (autoPatch) tools
7
+ * - PreToolUse hook in reject-only vs repair mode (--hook-repair / ARK_HOOK_REPAIR)
8
+ *
9
+ * Never claims silent apply; "repair" means host can re-inject a patch after hard deny.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { arkCommand } from '../ark-shared.mjs';
14
+
15
+ /**
16
+ * @returns {{
17
+ * mode: 'repair' | 'reject-only' | 'mcp-only' | 'none',
18
+ * prepareWrite: boolean,
19
+ * autoPatch: boolean,
20
+ * hookPresent: boolean,
21
+ * hookRepair: boolean,
22
+ * mcpPresent: boolean,
23
+ * evidence: string[],
24
+ * gap: null | { id: string, severity: string, message: string, fix: string },
25
+ * }}
26
+ */
27
+ export function detectWritePathCapabilities(root) {
28
+ const evidence = [];
29
+ let hookPresent = false;
30
+ let hookRepair = false;
31
+
32
+ const hookFiles = [
33
+ '.claude/settings.json',
34
+ '.grok/hooks/ark-write-gate.json',
35
+ ];
36
+ for (const rel of hookFiles) {
37
+ const abs = path.join(root, rel);
38
+ if (!fs.existsSync(abs)) continue;
39
+ let text = '';
40
+ try {
41
+ text = fs.readFileSync(abs, 'utf8');
42
+ } catch {
43
+ continue;
44
+ }
45
+ // PreToolUse / write-gate command referencing ark(-gate)?-mcp --hook
46
+ if (
47
+ /--hook\b/.test(text) ||
48
+ /\b(ark|arkgate)-mcp\b[\s\S]{0,80}--hook\b/.test(text) ||
49
+ /\b--hook\b[\s\S]{0,80}\b(ark|arkgate)-mcp\b/.test(text)
50
+ ) {
51
+ hookPresent = true;
52
+ evidence.push(rel);
53
+ }
54
+ if (
55
+ /--hook-repair\b/.test(text) ||
56
+ /ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text)
57
+ ) {
58
+ hookRepair = true;
59
+ if (!evidence.includes(rel)) evidence.push(rel);
60
+ }
61
+ }
62
+
63
+ let mcpPresent = false;
64
+ const mcpFiles = ['.mcp.json', '.cursor/mcp.json', '.grok/config.toml'];
65
+ for (const rel of mcpFiles) {
66
+ const abs = path.join(root, rel);
67
+ if (!fs.existsSync(abs)) continue;
68
+ let text = '';
69
+ try {
70
+ text = fs.readFileSync(abs, 'utf8');
71
+ } catch {
72
+ continue;
73
+ }
74
+ if (
75
+ /\b(ark|arkgate)-mcp\b/.test(text) ||
76
+ /mcp_servers\.ark\b/.test(text) ||
77
+ /"ark"\s*:\s*\{/.test(text) ||
78
+ /mcpServers[\s\S]*\bark\b/.test(text)
79
+ ) {
80
+ mcpPresent = true;
81
+ evidence.push(rel);
82
+ }
83
+ }
84
+
85
+ // Package tools when MCP is wired: ark_prepare_write + validate_code(autoPatch).
86
+ // Hook repair emits machine-readable autoPatch without silent write.
87
+ const prepareWrite = mcpPresent;
88
+ const autoPatch = mcpPresent || hookRepair;
89
+
90
+ /** @type {'repair' | 'reject-only' | 'mcp-only' | 'none'} */
91
+ let mode = 'none';
92
+ if (hookPresent && hookRepair) mode = 'repair';
93
+ else if (hookPresent && !hookRepair) mode = 'reject-only';
94
+ else if (mcpPresent) mode = 'mcp-only';
95
+
96
+ let gap = null;
97
+ if (mode === 'none') {
98
+ gap = {
99
+ id: 'write-path-none',
100
+ severity: 'warn',
101
+ message:
102
+ 'Write path is not installed — no PreToolUse hook and no Ark MCP. Agents write without architecture gate or prepare-write.',
103
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
104
+ };
105
+ } else if (mode === 'reject-only') {
106
+ gap = {
107
+ id: 'write-path-reject-only',
108
+ severity: 'info',
109
+ message: mcpPresent
110
+ ? 'PreToolUse hook is reject-only (hard block, no ARK_REPAIR_JSON). MCP still exposes prepare-write/autoPatch — enable --hook-repair so the write boundary itself can re-inject patches.'
111
+ : 'Write path is reject-only (hard block with prose; no repair payload). Enable --hook-repair or ARK_HOOK_REPAIR=1 so hosts can re-inject patches without full re-draft.',
112
+ fix: arkCommand(
113
+ root,
114
+ 'ark-check',
115
+ '--install-agent-gates --tools claude,grok --force'
116
+ ),
117
+ };
118
+ } else if (mode === 'mcp-only') {
119
+ gap = {
120
+ id: 'write-path-mcp-only',
121
+ severity: 'info',
122
+ message:
123
+ 'MCP exposes prepare-write / autoPatch tools, but no PreToolUse write hook is installed — enforcement is advisory unless the agent calls tools.',
124
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates --tools claude,grok'),
125
+ };
126
+ }
127
+
128
+ return {
129
+ mode,
130
+ prepareWrite,
131
+ autoPatch,
132
+ hookPresent,
133
+ hookRepair,
134
+ mcpPresent,
135
+ evidence: [...new Set(evidence)],
136
+ gap,
137
+ };
138
+ }