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.
- package/CHANGELOG.md +107 -0
- package/README.md +21 -12
- package/SECURITY.md +3 -4
- package/bin/ark-check.mjs +41 -16
- package/bin/ark-mcp.mjs +54 -10
- package/bin/ark.mjs +87 -24
- package/bin/lib/agent-gates.mjs +68 -2090
- package/bin/lib/architecture-scan.mjs +4 -1
- package/bin/lib/baseline-key.mjs +17 -0
- package/bin/lib/ci-and-commands.mjs +386 -0
- package/bin/lib/config-warnings.mjs +22 -0
- package/bin/lib/core-layers.mjs +7 -0
- package/bin/lib/core-ratchet.mjs +3 -7
- package/bin/lib/deploy-path.mjs +205 -0
- package/bin/lib/doctor-plan.mjs +29 -5
- package/bin/lib/gate-files.mjs +223 -0
- package/bin/lib/hook-templates.mjs +99 -0
- package/bin/lib/install-migrate.mjs +442 -0
- package/bin/lib/mcp-adoption.mjs +423 -0
- package/bin/lib/presets.mjs +3 -0
- package/bin/lib/safety-diagnostics.mjs +263 -0
- package/bin/lib/scan-files.mjs +51 -6
- package/bin/lib/skill-install.mjs +259 -0
- package/bin/lib/typescript-host.mjs +88 -0
- package/bin/lib/violations.mjs +3 -3
- package/bin/lib/write-path-detect.mjs +138 -0
- package/dist/index.cjs +103 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +103 -8
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +18 -5
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +18 -5
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +103 -8
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +103 -8
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
- package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
- package/docs/agent-guide.md +12 -2
- package/docs/ai-gates.md +20 -2
- package/docs/package-surface.md +10 -3
- package/docs/production-hardening.md +5 -0
- package/package.json +5 -2
- package/server.json +2 -2
- package/templates/skills/ark-autopilot.md +77 -45
- package/templates/skills/ark-explain.md +2 -1
- package/templates/skills/ark-explore.md +135 -34
package/bin/lib/scan-files.mjs
CHANGED
|
@@ -32,24 +32,62 @@ export function isSkippedSourceDir(name) {
|
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/bin/lib/violations.mjs
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|