arkgate 2.11.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 +64 -0
- package/README.md +15 -10
- package/bin/ark.mjs +52 -19
- package/bin/lib/agent-gates.mjs +68 -2094
- package/bin/lib/ci-and-commands.mjs +386 -0
- package/bin/lib/deploy-path.mjs +205 -0
- 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/skill-install.mjs +259 -0
- package/bin/lib/typescript-host.mjs +88 -0
- package/bin/lib/write-path-detect.mjs +138 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +1 -1
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/docs/agent-guide.md +11 -1
- package/docs/package-surface.md +8 -1
- package/package.json +1 -1
- 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/agent-gates.mjs
CHANGED
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent gate install, migrate, Codex, skills, adoption
|
|
2
|
+
* Agent gate install, migrate, Codex, skills, adoption — public surface.
|
|
3
|
+
* Implementation lives in focused modules under bin/lib/.
|
|
3
4
|
*/
|
|
4
|
-
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import os from 'node:os';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import {
|
|
11
|
-
arkCommand,
|
|
12
|
-
detectPackageManager,
|
|
13
|
-
execCommandParts,
|
|
14
|
-
execRunner,
|
|
15
|
-
presentLockfiles,
|
|
16
|
-
usableTypescript,
|
|
17
|
-
typescriptUsabilityHint,
|
|
18
|
-
DEFAULT_INTENT_PREFIXES,
|
|
19
|
-
DEFAULT_LAYER_DIRECTORIES,
|
|
20
|
-
DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
21
|
-
DEFAULT_RULES,
|
|
22
|
-
createElevenLayerConfig,
|
|
23
|
-
applyFrameworkLayoutOverlays,
|
|
24
|
-
} from '../ark-shared.mjs';
|
|
25
|
-
import { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
26
|
-
import { falseGreenAdoptionGap } from './field-install.mjs';
|
|
27
|
-
import {
|
|
5
|
+
export {
|
|
28
6
|
assessCodexHomeMcp,
|
|
29
7
|
codexArkBlockHasPreferredBin,
|
|
30
8
|
codexArkBlockNeedsRewrite,
|
|
@@ -41,337 +19,15 @@ import {
|
|
|
41
19
|
wireCodexMcp,
|
|
42
20
|
} from './codex-home.mjs';
|
|
43
21
|
|
|
44
|
-
// Re-export Codex home API for existing consumers (ark-check, tests).
|
|
45
22
|
export {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
codexProjectSlug,
|
|
52
|
-
codexPromptsDir,
|
|
53
|
-
codexScopedTableForRoot,
|
|
54
|
-
extractCodexArkRootFromToml,
|
|
55
|
-
extractCodexRootFromBlock,
|
|
56
|
-
isTempOrUpgradeRoot,
|
|
57
|
-
listCodexArkServerTables,
|
|
58
|
-
upsertCodexMcpTable,
|
|
59
|
-
wireCodexMcp,
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/** Package root (parent of bin/). All modules live under bin/lib/. */
|
|
63
|
-
const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
64
|
-
const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
|
|
65
|
-
|
|
66
|
-
export function readJson(file) {
|
|
67
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function readPackageJson(root) {
|
|
71
|
-
const file = path.join(root, 'package.json');
|
|
72
|
-
if (!fs.existsSync(file)) return null;
|
|
73
|
-
return readJson(file);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function hasCheckArchitectureScript(root) {
|
|
77
|
-
const pkg = readPackageJson(root);
|
|
78
|
-
return Boolean(pkg?.scripts?.['check:architecture']);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Whether package.json scripts already expose a typecheck-like command.
|
|
83
|
-
* Shared by deploy-path quality + typecheck bootstrap (single definition).
|
|
84
|
-
* @param {Record<string, unknown>|null|undefined} scripts
|
|
85
|
-
*/
|
|
86
|
-
export function packageScriptsHaveTypecheck(scripts) {
|
|
87
|
-
if (!scripts || typeof scripts !== 'object') return false;
|
|
88
|
-
return Boolean(
|
|
89
|
-
(typeof scripts.typecheck === 'string' && scripts.typecheck.trim()) ||
|
|
90
|
-
(typeof scripts['type-check'] === 'string' && scripts['type-check'].trim()) ||
|
|
91
|
-
(typeof scripts['check:types'] === 'string' && scripts['check:types'].trim()) ||
|
|
92
|
-
(typeof scripts.tsc === 'string' && /\btsc\b/.test(scripts.tsc))
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Root package (and shallow nested packages) already have a typecheck script.
|
|
98
|
-
* Does not scan CI or framework configs — only package.json scripts.
|
|
99
|
-
* @param {string} root
|
|
100
|
-
*/
|
|
101
|
-
export function treeHasTypecheckScript(root) {
|
|
102
|
-
const pkg = readPackageJson(root);
|
|
103
|
-
if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
|
|
104
|
-
try {
|
|
105
|
-
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
106
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
107
|
-
const candidates = [path.join(root, entry.name)];
|
|
108
|
-
try {
|
|
109
|
-
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
110
|
-
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
111
|
-
candidates.push(path.join(root, entry.name, child.name));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
/* ignore */
|
|
116
|
-
}
|
|
117
|
-
for (const dir of candidates) {
|
|
118
|
-
const pj = path.join(dir, 'package.json');
|
|
119
|
-
if (!fs.existsSync(pj)) continue;
|
|
120
|
-
try {
|
|
121
|
-
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
122
|
-
if (packageScriptsHaveTypecheck(nested.scripts)) return true;
|
|
123
|
-
} catch {
|
|
124
|
-
/* ignore */
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} catch {
|
|
129
|
-
/* ignore */
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Add a conservative `typecheck` script when the host has a TS/JS project config
|
|
136
|
-
* but no typecheck-like script yet. Never overwrites an existing script.
|
|
137
|
-
*
|
|
138
|
-
* @param {string} root
|
|
139
|
-
* @param {{ write?: boolean }} [opts]
|
|
140
|
-
* @returns {{
|
|
141
|
-
* changed: boolean,
|
|
142
|
-
* reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
|
|
143
|
-
* script?: string,
|
|
144
|
-
* }}
|
|
145
|
-
*/
|
|
146
|
-
export function ensureTypecheckScript(root, opts = {}) {
|
|
147
|
-
const write = opts.write !== false;
|
|
148
|
-
const hasTsconfig =
|
|
149
|
-
fs.existsSync(path.join(root, 'tsconfig.json')) ||
|
|
150
|
-
fs.existsSync(path.join(root, 'jsconfig.json'));
|
|
151
|
-
if (!hasTsconfig) return { changed: false, reason: 'no-tsconfig' };
|
|
152
|
-
|
|
153
|
-
const pkgPath = path.join(root, 'package.json');
|
|
154
|
-
if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
|
|
155
|
-
|
|
156
|
-
if (treeHasTypecheckScript(root)) {
|
|
157
|
-
return { changed: false, reason: 'already' };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const pkg = readPackageJson(root) || {};
|
|
161
|
-
const scripts =
|
|
162
|
-
pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
|
|
163
|
-
const script = 'tsc --noEmit';
|
|
164
|
-
scripts.typecheck = script;
|
|
165
|
-
if (write) {
|
|
166
|
-
const next = { ...pkg, scripts };
|
|
167
|
-
fs.writeFileSync(pkgPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
168
|
-
}
|
|
169
|
-
return { changed: true, reason: 'added', script };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export const REQUIRED_GATE_FILES = [
|
|
173
|
-
'AGENTS.md',
|
|
174
|
-
'.mcp.json',
|
|
175
|
-
];
|
|
176
|
-
const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
177
|
-
|
|
178
|
-
export function hasArkWorkflow(root) {
|
|
179
|
-
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
180
|
-
if (!fs.existsSync(workflowsDir)) return false;
|
|
181
|
-
return fs
|
|
182
|
-
.readdirSync(workflowsDir)
|
|
183
|
-
.filter((file) => /\.ya?ml$/i.test(file))
|
|
184
|
-
.some((file) => {
|
|
185
|
-
try {
|
|
186
|
-
const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
|
|
187
|
-
return (
|
|
188
|
-
/\bark-check\b/.test(content) ||
|
|
189
|
-
/\bcheck:architecture\b/.test(content) ||
|
|
190
|
-
/\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
|
|
191
|
-
);
|
|
192
|
-
} catch {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function missingGates(root) {
|
|
199
|
-
const missing = REQUIRED_GATE_FILES.filter(
|
|
200
|
-
(relativePath) => !fs.existsSync(path.join(root, relativePath))
|
|
201
|
-
);
|
|
202
|
-
if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
|
|
203
|
-
return missing;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function checkArchitectureScriptSnippet(root) {
|
|
207
|
-
// The package manager's runner resolves the installed binary; `node bin/ark-check.mjs`
|
|
208
|
-
// only works inside Ark's own repo. Package-manager aware so a pnpm/yarn repo isn't
|
|
209
|
-
// handed an `npx` alias that violates its "never npx" policy.
|
|
210
|
-
return `"check:architecture": "${arkCheckCommand(root)}"`;
|
|
211
|
-
}
|
|
212
|
-
export function ensureDirForFile(file) {
|
|
213
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* True when AGENTS.md is wholly Ark-owned (header is Ark Enforcement).
|
|
218
|
-
* Project guides that merely append an Ark section must remain non-Ark so --force
|
|
219
|
-
* never wipes them.
|
|
220
|
-
*/
|
|
221
|
-
export function isArkAgentsContent(text) {
|
|
222
|
-
if (typeof text !== 'string' || !text.trim()) return false;
|
|
223
|
-
const head = text.trimStart().slice(0, 120);
|
|
224
|
-
return /^#\s*Ark(Gate)?\s+Enforcement\b/.test(head);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function writeTemplate(root, relativePath, content, force) {
|
|
228
|
-
const fullPath = path.join(root, relativePath);
|
|
229
|
-
if (relativePath === 'AGENTS.md' && fs.existsSync(fullPath)) {
|
|
230
|
-
let existing = '';
|
|
231
|
-
try {
|
|
232
|
-
existing = fs.readFileSync(fullPath, 'utf8');
|
|
233
|
-
} catch {
|
|
234
|
-
existing = '';
|
|
235
|
-
}
|
|
236
|
-
if (existing && !isArkAgentsContent(existing)) {
|
|
237
|
-
// Never clobber a project-owned AGENTS.md — even with --force.
|
|
238
|
-
// If Ark section not present yet, merge once; subsequent runs leave it alone.
|
|
239
|
-
const hasArkSection =
|
|
240
|
-
/#\s*Ark(Gate)?\s+Enforcement\b/.test(existing) ||
|
|
241
|
-
/ark\.config\.json is authoritative/i.test(existing);
|
|
242
|
-
if (force && isArkAgentsContent(content) && !hasArkSection) {
|
|
243
|
-
try {
|
|
244
|
-
const merged = `${existing.replace(/\s*$/, '')}\n\n---\n\n${content}`;
|
|
245
|
-
ensureDirForFile(fullPath);
|
|
246
|
-
fs.writeFileSync(fullPath, merged);
|
|
247
|
-
return { relativePath, status: 'merged' };
|
|
248
|
-
} catch {
|
|
249
|
-
return { relativePath, status: 'failed' };
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return { relativePath, status: 'skipped-non-ark' };
|
|
253
|
-
}
|
|
254
|
-
if (!force && isArkAgentsContent(existing)) {
|
|
255
|
-
return { relativePath, status: 'skipped' };
|
|
256
|
-
}
|
|
257
|
-
} else if (fs.existsSync(fullPath) && !force) {
|
|
258
|
-
return { relativePath, status: 'skipped' };
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
ensureDirForFile(fullPath);
|
|
262
|
-
fs.writeFileSync(fullPath, content);
|
|
263
|
-
return { relativePath, status: 'written' };
|
|
264
|
-
} catch {
|
|
265
|
-
return { relativePath, status: 'failed' };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Load a TypeScript module with a working JS API host (`sys` + AST + resolve).
|
|
271
|
-
* Prefer the project's install when API-compatible (TS 5/6 + any TS 7 that still
|
|
272
|
-
* exposes the classic JS host). TypeScript 7.0.x main entry is version-only
|
|
273
|
-
* (`{ version, versionMajorMinor }`); programmatic APIs live under
|
|
274
|
-
* `typescript/unstable/*` and are not yet the gate's host — we fall through to
|
|
275
|
-
* ArkGate's own `typescript` dependency (JS-API 5.x) or a bare import.
|
|
276
|
-
* Returns `{ ts, source, version, fallbackReason? }` or null.
|
|
277
|
-
*/
|
|
278
|
-
export async function loadTypeScript(root) {
|
|
279
|
-
const { createRequire } = await import('node:module');
|
|
280
|
-
const loaders = [];
|
|
281
|
-
try {
|
|
282
|
-
const req = createRequire(path.join(root, 'package.json'));
|
|
283
|
-
loaders.push({
|
|
284
|
-
label: 'project',
|
|
285
|
-
load: () => req('typescript'),
|
|
286
|
-
resolvePath: () => {
|
|
287
|
-
try {
|
|
288
|
-
return req.resolve('typescript');
|
|
289
|
-
} catch {
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
} catch {
|
|
295
|
-
/* project has no package.json resolvable tree */
|
|
296
|
-
}
|
|
297
|
-
// Nested under arkgate (production dependency) — must work when project has only TS7.
|
|
298
|
-
try {
|
|
299
|
-
const req = createRequire(__arkCheckCli);
|
|
300
|
-
loaders.push({
|
|
301
|
-
label: 'arkgate',
|
|
302
|
-
load: () => req('typescript'),
|
|
303
|
-
resolvePath: () => {
|
|
304
|
-
try {
|
|
305
|
-
return req.resolve('typescript');
|
|
306
|
-
} catch {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
},
|
|
310
|
-
});
|
|
311
|
-
} catch {
|
|
312
|
-
/* ark install tree unavailable */
|
|
313
|
-
}
|
|
314
|
-
loaders.push({
|
|
315
|
-
label: 'import',
|
|
316
|
-
load: async () => {
|
|
317
|
-
const m = await import('typescript');
|
|
318
|
-
return m;
|
|
319
|
-
},
|
|
320
|
-
resolvePath: () => null,
|
|
321
|
-
});
|
|
23
|
+
PREFERRED_MCP_BIN,
|
|
24
|
+
claudeSettings,
|
|
25
|
+
grokHooks,
|
|
26
|
+
grokProjectConfig,
|
|
27
|
+
} from './hook-templates.mjs';
|
|
322
28
|
|
|
323
|
-
|
|
324
|
-
const triedPaths = new Set();
|
|
325
|
-
for (const { label, load, resolvePath } of loaders) {
|
|
326
|
-
try {
|
|
327
|
-
const resolved = typeof resolvePath === 'function' ? resolvePath() : null;
|
|
328
|
-
if (resolved && triedPaths.has(resolved)) {
|
|
329
|
-
// Same physical package already rejected (e.g. project === hoisted arkgate path).
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
if (resolved) triedPaths.add(resolved);
|
|
29
|
+
export { detectWritePathCapabilities } from './write-path-detect.mjs';
|
|
333
30
|
|
|
334
|
-
const mod = await load();
|
|
335
|
-
const ts = usableTypescript(mod);
|
|
336
|
-
if (ts) {
|
|
337
|
-
const version =
|
|
338
|
-
typeof ts.version === 'string'
|
|
339
|
-
? ts.version
|
|
340
|
-
: typeof mod?.version === 'string'
|
|
341
|
-
? mod.version
|
|
342
|
-
: undefined;
|
|
343
|
-
return {
|
|
344
|
-
ts,
|
|
345
|
-
source: label,
|
|
346
|
-
version,
|
|
347
|
-
...(projectRejected ? { fallbackReason: projectRejected } : {}),
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
if (label === 'project' && mod) {
|
|
351
|
-
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.`;
|
|
352
|
-
}
|
|
353
|
-
} catch {
|
|
354
|
-
/* try next loader */
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Args for every emitted `ark-check` (AGENTS.md, package.json, Cursor rule, CI).
|
|
362
|
-
* If `.ark-baseline.json` exists, include `--baseline` so agent/local/CI paths
|
|
363
|
-
* match the ratchet — otherwise agents re-fail on frozen debt (field-test bug).
|
|
364
|
-
*/
|
|
365
|
-
export function checkArgsForRoot(root, { requireGates = false } = {}) {
|
|
366
|
-
const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
|
|
367
|
-
? ' --baseline .ark-baseline.json'
|
|
368
|
-
: '';
|
|
369
|
-
const profile = requireGates ? '--strict' : '--strict-config';
|
|
370
|
-
return `--root . --config ark.config.json ${profile}${baselineFlag}`;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Field-install helpers live in field-install.mjs (keep agent-gates scannable).
|
|
374
|
-
// Re-export for callers that already import from this module.
|
|
375
31
|
export {
|
|
376
32
|
ensureBaselineFlagInCheckCommand,
|
|
377
33
|
syncBaselineIntoCheckSurfaces,
|
|
@@ -382,1748 +38,66 @@ export {
|
|
|
382
38
|
falseGreenAdoptionGap,
|
|
383
39
|
} from './field-install.mjs';
|
|
384
40
|
|
|
385
|
-
export
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
cache: 'yarn',
|
|
403
|
-
setup: ['corepack enable'],
|
|
404
|
-
install: 'yarn install --frozen-lockfile',
|
|
405
|
-
run: `yarn ark-check ${checkArgs}`,
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
// Monorepo hosts (e.g. Next app under frontend/) often have a root package.json only for
|
|
409
|
-
// arkgate while real app deps live in frontend/package.json. Install both so CI can resolve
|
|
410
|
-
// the tree; ark-check itself only needs the root arkgate install.
|
|
411
|
-
const frontendPkg = fs.existsSync(path.join(root, 'frontend', 'package.json'));
|
|
412
|
-
const rootInstall = fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install';
|
|
413
|
-
const install = frontendPkg
|
|
414
|
-
? `${rootInstall} && (cd frontend && ${fs.existsSync(path.join(root, 'frontend', 'package-lock.json')) ? 'npm ci' : 'npm install'})`
|
|
415
|
-
: rootInstall;
|
|
416
|
-
return {
|
|
417
|
-
cache: 'npm',
|
|
418
|
-
setup: [],
|
|
419
|
-
install,
|
|
420
|
-
run: `npx ark-check ${checkArgs}`,
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// The runner prefix (npx / pnpm exec / yarn) is added per project by arkCheckCommand
|
|
425
|
-
// so a pnpm-only repo never gets an `npx` instruction — see execRunner() in ark-shared.mjs.
|
|
426
|
-
export function arkCheckCommand(root) {
|
|
427
|
-
return arkCommand(root, 'ark-check', checkArgsForRoot(root));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Canonical agent contract. AGENTS.md and the Cursor rule both derive from this single
|
|
431
|
-
// source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
|
|
432
|
-
// is a builder because the check command's runner prefix varies with the package manager.
|
|
433
|
-
const AGENT_CONTRACT = {
|
|
434
|
-
manifestResource: 'ark://manifest',
|
|
435
|
-
steps: (checkCommand) => [
|
|
436
|
-
`Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
|
|
437
|
-
`Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
|
|
438
|
-
`Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
|
|
439
|
-
`After edits, run \`${checkCommand}\`.`,
|
|
440
|
-
`If Ark reports violations, fix the architecture instead of weakening the gate.`,
|
|
441
|
-
],
|
|
442
|
-
// Cursor-only guidance: the write-time validate_code tool is available in
|
|
443
|
-
// Cursor's runtime but has no equivalent in a plain AGENTS.md read.
|
|
444
|
-
cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
export function layerPlacementTable() {
|
|
448
|
-
const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
|
|
449
|
-
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
450
|
-
.map((directory) => `\`${directory}/\``)
|
|
451
|
-
.join(', ');
|
|
452
|
-
return `| ${entry.layer} | ${dirs} | ${entry.prefixes.map((p) => `\`${p}\``).join(', ')} |`;
|
|
453
|
-
}).join('\n');
|
|
454
|
-
return `| Layer | Conventional directories (under the source root) | Intent prefixes |
|
|
455
|
-
|-------|---------------------------------------------------|-----------------|
|
|
456
|
-
${rows}`;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export function agentInstructions(root) {
|
|
460
|
-
const checkCmd = arkCheckCommand(root);
|
|
461
|
-
const startCmd = arkCommand(root, 'ark', 'start');
|
|
462
|
-
const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
|
|
463
|
-
const steps = AGENT_CONTRACT.steps(checkCmd)
|
|
464
|
-
.map((step, index) => `${index + 1}. ${step}`)
|
|
465
|
-
.join('\n');
|
|
466
|
-
return `# Ark Enforcement
|
|
467
|
-
|
|
468
|
-
## Default agent flow (if unsure, do only this)
|
|
469
|
-
|
|
470
|
-
1. If \`ark.config.json\` is missing: run \`${startCmd}\` once.
|
|
471
|
-
2. For adoption / cleanup / “make architecture sound”: run the **\`/ark-autopilot\`** skill
|
|
472
|
-
(origin report → adopt → plan → safe fixes → gates). Do **not** invent a second
|
|
473
|
-
architecture curriculum outside the routing table below — when a trigger matches, use
|
|
474
|
-
that skill; when unsure, stay on autopilot.
|
|
475
|
-
3. Status anytime: \`${doctorCmd}\` (status light + next action — not a mode picker).
|
|
476
|
-
4. After ordinary feature edits: run \`${checkCmd}\`. On violations → **\`/ark-fix\`** (or
|
|
477
|
-
\`/ark-place\` for new files, \`/ark-contract\` only if the contract itself is wrong).
|
|
478
|
-
|
|
479
|
-
Skills are **dual-engine**: deterministic CLI sensors + exploratory read of *this* repo — not JSON-only wrappers.
|
|
480
|
-
When a skill says **STOP — do not continue this skill as complete**, stop and invoke the named handoff skill.
|
|
481
|
-
|
|
482
|
-
### Subagent fan-out
|
|
483
|
-
If the host supports **parallel subagents**, skills may ask you to fan out **read-only**
|
|
484
|
-
scouts (disjoint path scopes) and merge in the parent. If the host does **not**,
|
|
485
|
-
**fall back to sequential** — one cluster/step at a time. Never parallel-write the same
|
|
486
|
-
files; never weaken the gate via subagents.
|
|
487
|
-
|
|
488
|
-
## Skill routing (triggers → skill)
|
|
489
|
-
|
|
490
|
-
| When | Invoke |
|
|
491
|
-
|------|--------|
|
|
492
|
-
| Unsure / make architecture sound | **/ark-autopilot** (default) |
|
|
493
|
-
| Need map / opportunities only (no apply) | \`/ark-explore\` |
|
|
494
|
-
| Greenfield shape / empty tree | \`/ark-architect\` |
|
|
495
|
-
| Brownfield / wrong contract / false-green | \`/ark-adopt\` then \`/ark-contract\` if globs wrong |
|
|
496
|
-
| Edit \`ark.config.json\` layers/rules/intents | \`/ark-contract\` |
|
|
497
|
-
| New file “where does this go?” | \`/ark-place\` |
|
|
498
|
-
| Gate violation on a change | \`/ark-fix\` |
|
|
499
|
-
| Drive plan to goal.met | \`/ark-loop\` |
|
|
500
|
-
| Deep coverage + ranked audit | \`/ark-coverage\` |
|
|
501
|
-
| Design trade-offs (no package LLM) | \`/ark-think\` |
|
|
502
|
-
| Explain / HTML report tour | \`/ark-explain\` |
|
|
503
|
-
| Bump arkgate + refresh hosts | \`/ark-upgrade\` |
|
|
504
|
-
| Optional runtime kernel migrate | \`/ark-runtime\` |
|
|
505
|
-
|
|
506
|
-
## Before editing TypeScript or JavaScript source files
|
|
507
|
-
|
|
508
|
-
${steps}
|
|
509
|
-
|
|
510
|
-
## Where new code belongs
|
|
511
|
-
|
|
512
|
-
\`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
|
|
513
|
-
that no existing layer covers (a saga, a background job, a read model, ...), use the
|
|
514
|
-
default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
|
|
515
|
-
an ungoverned location:
|
|
516
|
-
|
|
517
|
-
${layerPlacementTable()}
|
|
518
|
-
|
|
519
|
-
The project is only considered Ark-enforced when the write gate and CI gate pass
|
|
520
|
-
(runtime path only if this project opted into the kernel).
|
|
521
|
-
`;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
export function mcpJson(root) {
|
|
525
|
-
return `${JSON.stringify({
|
|
526
|
-
mcpServers: {
|
|
527
|
-
ark: {
|
|
528
|
-
type: 'stdio',
|
|
529
|
-
// Prefer arkgate-mcp; ark-mcp alias still works for one major.
|
|
530
|
-
...execCommandParts(root, PREFERRED_MCP_BIN, ['--root', '.', '--config', 'ark.config.json']),
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
}, null, 2)}\n`;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Sample for docs/ — `ark-check --install-agent-gates --tools codex` auto-merges the real
|
|
537
|
-
// block (with absolute paths) into ~/.codex/config.toml. This copy is a reference only, so
|
|
538
|
-
// it flags the two gotchas of hand-editing the global config: absolute paths (config.toml is
|
|
539
|
-
// loaded without the project as cwd) and the required restart.
|
|
540
|
-
export function codexTomlSnippet(root) {
|
|
541
|
-
const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
|
|
542
|
-
'--root',
|
|
543
|
-
'/absolute/path/to/project',
|
|
544
|
-
'--config',
|
|
545
|
-
'/absolute/path/to/project/ark.config.json',
|
|
546
|
-
]);
|
|
547
|
-
const argsToml = args.map((value) => `"${value}"`).join(', ');
|
|
548
|
-
return `# Add to ~/.codex/config.toml (or $CODEX_HOME/config.toml), then RESTART Codex —
|
|
549
|
-
# it does not hot-load MCP servers. Use ABSOLUTE paths: config.toml is global, so
|
|
550
|
-
# "." would resolve against Codex's launch dir, not this project. Prefer:
|
|
551
|
-
# ark-check --install-agent-gates --tools codex (auto-merges the absolute paths)
|
|
552
|
-
[mcp_servers.ark]
|
|
553
|
-
command = "${command}"
|
|
554
|
-
args = [${argsToml}]
|
|
555
|
-
`;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
|
|
560
|
-
* Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
|
|
561
|
-
* Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
|
|
562
|
-
* can never drift; points at AGENTS.md for the full placement table.
|
|
563
|
-
*/
|
|
564
|
-
export function instructionRule(root) {
|
|
565
|
-
const steps = AGENT_CONTRACT.steps(arkCheckCommand(root))
|
|
566
|
-
.map((step, index) => `${index + 1}. ${step}`)
|
|
567
|
-
.join('\n');
|
|
568
|
-
return `# Ark architecture contract
|
|
569
|
-
|
|
570
|
-
This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
|
|
571
|
-
Before writing or editing TypeScript or JavaScript source files:
|
|
572
|
-
|
|
573
|
-
${steps}
|
|
574
|
-
|
|
575
|
-
See \`AGENTS.md\` for the full contract and the layer placement table.
|
|
576
|
-
`;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export function cursorRule(root) {
|
|
580
|
-
return `---
|
|
581
|
-
description: Ark architecture contract
|
|
582
|
-
alwaysApply: true
|
|
583
|
-
---
|
|
584
|
-
|
|
585
|
-
Before writing or editing TypeScript or JavaScript source files, read the
|
|
586
|
-
\`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
|
|
587
|
-
|
|
588
|
-
${AGENT_CONTRACT.cursorValidateStep} After edits, run:
|
|
589
|
-
|
|
590
|
-
\`\`\`bash
|
|
591
|
-
${arkCheckCommand(root)}
|
|
592
|
-
\`\`\`
|
|
593
|
-
|
|
594
|
-
If Ark reports violations, fix the architecture instead of bypassing the gate.
|
|
595
|
-
`;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Default CI Node when the project declares nothing. A current LTS, NOT the
|
|
599
|
-
// oldest supported: the npm-ci-lockfile-mismatch failure only happens when CI's
|
|
600
|
-
// npm is OLDER than the npm that wrote the lockfile, so defaulting high is safer.
|
|
601
|
-
const DEFAULT_CI_NODE_VERSION = '22';
|
|
602
|
-
|
|
603
|
-
// Decide the Node the generated CI should use, preferring the project's own
|
|
604
|
-
// declaration so CI's npm matches the dev's (a mismatch makes `npm ci` fail with
|
|
605
|
-
// "missing from lock file" — a red gate unrelated to architecture). In order:
|
|
606
|
-
// 1. .nvmrc / .node-version → setup-node's node-version-file (exact, best)
|
|
607
|
-
// 2. package.json engines.node → its concrete major
|
|
608
|
-
// 3. a current-LTS default
|
|
609
|
-
export function detectCiNode(root) {
|
|
610
|
-
for (const file of ['.nvmrc', '.node-version']) {
|
|
611
|
-
if (fs.existsSync(path.join(root, file))) return { kind: 'file', value: file };
|
|
612
|
-
}
|
|
613
|
-
const enginesNode = readPackageJson(root)?.engines?.node;
|
|
614
|
-
if (typeof enginesNode === 'string') {
|
|
615
|
-
const major = enginesNode.match(/\d+/)?.[0];
|
|
616
|
-
if (major) return { kind: 'version', value: major };
|
|
617
|
-
}
|
|
618
|
-
return { kind: 'default', value: DEFAULT_CI_NODE_VERSION };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* @param {{ name: string, install: string, run: string, cache: string, setup: string[] }} pm
|
|
623
|
-
* @param {{ kind: string, value: string }} ciNode
|
|
624
|
-
* @param {{ hasLintScript?: boolean, hasTypecheckScript?: boolean }} [quality]
|
|
625
|
-
*/
|
|
626
|
-
export function githubWorkflow(pm, ciNode, quality = {}) {
|
|
627
|
-
// pnpm/yarn setup (corepack enable) MUST run before actions/setup-node so the package
|
|
628
|
-
// manager is on PATH when setup-node's `cache: pnpm|yarn` tries to resolve the store —
|
|
629
|
-
// otherwise the cache step fails on a fresh runner ("Unable to locate executable file: pnpm").
|
|
630
|
-
const setupSteps = pm.setup.map((command) => ` - run: ${command}`).join('\n');
|
|
631
|
-
// node-version-file keeps CI locked to the dev's exact toolchain; an explicit
|
|
632
|
-
// version comes from engines.node; the default carries a hint for the mismatch
|
|
633
|
-
// symptom since we can't know which npm wrote the lockfile.
|
|
634
|
-
const nodeSetup =
|
|
635
|
-
ciNode.kind === 'file'
|
|
636
|
-
? ` node-version-file: ${ciNode.value}`
|
|
637
|
-
: ciNode.kind === 'version'
|
|
638
|
-
? ` node-version: '${ciNode.value}'`
|
|
639
|
-
: ` # If the install step fails with "missing from lock file" / lockfile out
|
|
640
|
-
# of sync, your local package manager is newer than this Node's — add a
|
|
641
|
-
# .nvmrc with your Node version so CI matches the dev environment.
|
|
642
|
-
node-version: '${ciNode.value}'`;
|
|
643
|
-
// When package.json already has lint/typecheck, emit CI steps so deploy-path
|
|
644
|
-
// honesty matches local scripts (Next/CRA often run these in production build).
|
|
645
|
-
const install = pm.install || '';
|
|
646
|
-
const runPrefix = install.startsWith('pnpm')
|
|
647
|
-
? 'pnpm run'
|
|
648
|
-
: install.startsWith('yarn')
|
|
649
|
-
? 'yarn'
|
|
650
|
-
: install.startsWith('bun')
|
|
651
|
-
? 'bun run'
|
|
652
|
-
: 'npm run';
|
|
653
|
-
const qualityBlock = [
|
|
654
|
-
quality.hasTypecheckScript
|
|
655
|
-
? ` - name: Typecheck\n run: ${runPrefix} typecheck`
|
|
656
|
-
: '',
|
|
657
|
-
quality.hasLintScript ? ` - name: Lint\n run: ${runPrefix} lint` : '',
|
|
658
|
-
]
|
|
659
|
-
.filter(Boolean)
|
|
660
|
-
.join('\n');
|
|
661
|
-
return `name: Ark architecture gate
|
|
662
|
-
|
|
663
|
-
on:
|
|
664
|
-
pull_request:
|
|
665
|
-
push:
|
|
666
|
-
branches: [main, master]
|
|
667
|
-
|
|
668
|
-
jobs:
|
|
669
|
-
ark-check:
|
|
670
|
-
runs-on: ubuntu-latest
|
|
671
|
-
steps:
|
|
672
|
-
- name: Checkout
|
|
673
|
-
uses: actions/checkout@v4
|
|
674
|
-
${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
|
|
675
|
-
uses: actions/setup-node@v4
|
|
676
|
-
with:
|
|
677
|
-
${nodeSetup}
|
|
678
|
-
cache: ${pm.cache}
|
|
679
|
-
- name: Install dependencies
|
|
680
|
-
run: ${pm.install}
|
|
681
|
-
${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
|
|
682
|
-
run: ${pm.run}
|
|
683
|
-
`;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
export function claudeSettings(root) {
|
|
687
|
-
const runner = execRunner(root);
|
|
688
|
-
return `${JSON.stringify({
|
|
689
|
-
hooks: {
|
|
690
|
-
// Inject the contract at session start so the agent knows the architecture from
|
|
691
|
-
// the first token. Project-scoped by design; --session-context is also a silent
|
|
692
|
-
// no-op when no ark.config.json exists, so it can never leak into other projects.
|
|
693
|
-
SessionStart: [
|
|
694
|
-
{
|
|
695
|
-
hooks: [
|
|
696
|
-
{
|
|
697
|
-
type: 'command',
|
|
698
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
699
|
-
},
|
|
700
|
-
],
|
|
701
|
-
},
|
|
702
|
-
],
|
|
703
|
-
PreToolUse: [
|
|
704
|
-
{
|
|
705
|
-
matcher: 'Write|Edit|MultiEdit',
|
|
706
|
-
hooks: [
|
|
707
|
-
{
|
|
708
|
-
type: 'command',
|
|
709
|
-
// W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
|
|
710
|
-
// (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
|
|
711
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
712
|
-
},
|
|
713
|
-
],
|
|
714
|
-
},
|
|
715
|
-
],
|
|
716
|
-
},
|
|
717
|
-
}, null, 2)}\n`;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Grok Build project config: MCP registration (commit-friendly relative paths — unlike
|
|
721
|
-
// Codex's global config.toml, Grok loads .grok/config.toml from the project).
|
|
722
|
-
export function grokProjectConfig(root) {
|
|
723
|
-
const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
|
|
724
|
-
'--root',
|
|
725
|
-
'.',
|
|
726
|
-
'--config',
|
|
727
|
-
'ark.config.json',
|
|
728
|
-
]);
|
|
729
|
-
const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
|
|
730
|
-
return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
|
|
731
|
-
# Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
|
|
732
|
-
[mcp_servers.ark]
|
|
733
|
-
command = "${command.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
734
|
-
args = [${argsToml}]
|
|
735
|
-
`;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets both
|
|
739
|
-
// GROK_WORKSPACE_ROOT and CLAUDE_PROJECT_DIR (Claude-compatible alias). Prefer
|
|
740
|
-
// GROK_* with fallback so hooks still work if only one is present.
|
|
741
|
-
// Matcher keeps Claude names (Write|Edit|MultiEdit) and Grok natives
|
|
742
|
-
// (write|search_replace) — Grok aliases both directions.
|
|
743
|
-
export function grokHooks(root) {
|
|
744
|
-
const runner = execRunner(root);
|
|
745
|
-
// Nested defaults: Grok native → Claude alias → project cwd (hook cwd is the workspace).
|
|
746
|
-
const grokRoot = '${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}';
|
|
747
|
-
return `${JSON.stringify({
|
|
748
|
-
hooks: {
|
|
749
|
-
SessionStart: [
|
|
750
|
-
{
|
|
751
|
-
hooks: [
|
|
752
|
-
{
|
|
753
|
-
type: 'command',
|
|
754
|
-
timeout: 30,
|
|
755
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${grokRoot}" --config ark.config.json`,
|
|
756
|
-
},
|
|
757
|
-
],
|
|
758
|
-
},
|
|
759
|
-
],
|
|
760
|
-
PreToolUse: [
|
|
761
|
-
{
|
|
762
|
-
matcher: 'Write|Edit|MultiEdit|write|search_replace',
|
|
763
|
-
hooks: [
|
|
764
|
-
{
|
|
765
|
-
type: 'command',
|
|
766
|
-
timeout: 30,
|
|
767
|
-
// W4: --hook-repair → structured autoPatch on deny (hard block still).
|
|
768
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
|
|
769
|
-
},
|
|
770
|
-
],
|
|
771
|
-
},
|
|
772
|
-
],
|
|
773
|
-
},
|
|
774
|
-
}, null, 2)}\n`;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** Normalize --tools from array or comma-separated string (never character-split a string). */
|
|
778
|
-
export function normalizeToolsList(tools) {
|
|
779
|
-
if (tools == null) return [];
|
|
780
|
-
if (Array.isArray(tools)) {
|
|
781
|
-
return tools
|
|
782
|
-
.flatMap((t) => String(t).split(','))
|
|
783
|
-
.map((t) => t.trim().toLowerCase())
|
|
784
|
-
.filter(Boolean);
|
|
785
|
-
}
|
|
786
|
-
if (typeof tools === 'string') {
|
|
787
|
-
return tools
|
|
788
|
-
.split(',')
|
|
789
|
-
.map((t) => t.trim().toLowerCase())
|
|
790
|
-
.filter(Boolean);
|
|
791
|
-
}
|
|
792
|
-
return [];
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
export function resolveTools(args) {
|
|
796
|
-
const explicit = normalizeToolsList(args.tools);
|
|
797
|
-
if (explicit.length > 0) {
|
|
798
|
-
return { tools: new Set(explicit), source: 'explicit' };
|
|
799
|
-
}
|
|
800
|
-
const root = args.root;
|
|
801
|
-
const detected = new Set();
|
|
802
|
-
if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
|
|
803
|
-
if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
|
|
804
|
-
if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
|
|
805
|
-
if (fs.existsSync(path.join(root, '.grok'))) detected.add('grok');
|
|
806
|
-
if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
|
|
807
|
-
// .clinerules can also be a single FILE (older Cline convention); only a directory
|
|
808
|
-
// can receive .clinerules/ark.md, so a file must not trigger detection.
|
|
809
|
-
if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
|
|
810
|
-
detected.add('cline');
|
|
811
|
-
}
|
|
812
|
-
if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
|
|
813
|
-
if (fs.existsSync(path.join(root, '.roo'))) detected.add('roo');
|
|
814
|
-
if (fs.existsSync(path.join(root, '.continue'))) detected.add('continue');
|
|
815
|
-
if (fs.existsSync(path.join(root, '.gemini'))) detected.add('gemini');
|
|
816
|
-
// copilot has no reliable directory signal (.github exists in most repos),
|
|
817
|
-
// so it is explicit-only via --tools.
|
|
818
|
-
// Host signals: Grok Build / xAI agents often have no project `.grok/` yet but
|
|
819
|
-
// set an env marker (or run with GROK_*). Include Grok so skills install there.
|
|
820
|
-
if (
|
|
821
|
-
process.env.GROK_BUILD === '1' ||
|
|
822
|
-
process.env.GROK_BUILD === 'true' ||
|
|
823
|
-
process.env.XAI_GROK === '1' ||
|
|
824
|
-
process.env.XAI_GROK === 'true'
|
|
825
|
-
) {
|
|
826
|
-
detected.add('grok');
|
|
827
|
-
}
|
|
828
|
-
// No signal at all: fall back to a complete starter set including Grok (field
|
|
829
|
-
// log: default claude+cursor+codex silently omitted Grok skills for Grok hosts).
|
|
830
|
-
if (detected.size === 0) {
|
|
831
|
-
return { tools: new Set(['claude', 'cursor', 'codex', 'grok']), source: 'default' };
|
|
832
|
-
}
|
|
833
|
-
return { tools: detected, source: 'detected' };
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const KNOWN_TOOLS = [
|
|
837
|
-
'claude',
|
|
838
|
-
'cursor',
|
|
839
|
-
'codex',
|
|
840
|
-
'grok',
|
|
841
|
-
'windsurf',
|
|
842
|
-
'cline',
|
|
843
|
-
'copilot',
|
|
844
|
-
'kiro',
|
|
845
|
-
'roo',
|
|
846
|
-
'continue',
|
|
847
|
-
'gemini',
|
|
848
|
-
];
|
|
849
|
-
|
|
850
|
-
// One canonical markdown per skill (templates/skills/*.md, shipped in the npm
|
|
851
|
-
// package); installed into each tool's slash-command location. The YAML
|
|
852
|
-
// frontmatter (name/description) is understood or harmlessly ignored by every
|
|
853
|
-
// host. Kiro has no command mechanism — its steering rule file is the only gate.
|
|
854
|
-
const SKILL_TOOL_TARGETS = {
|
|
855
|
-
claude: (name) => `.claude/skills/${name}/SKILL.md`,
|
|
856
|
-
cursor: (name) => `.cursor/commands/${name}.md`,
|
|
857
|
-
codex: (name) => `.codex/prompts/${name}.md`,
|
|
858
|
-
// Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
|
|
859
|
-
grok: (name) => `.grok/skills/${name}/SKILL.md`,
|
|
860
|
-
windsurf: (name) => `.windsurf/workflows/${name}.md`,
|
|
861
|
-
cline: (name) => `.clinerules/workflows/${name}.md`,
|
|
862
|
-
copilot: (name) => `.github/prompts/${name}.prompt.md`,
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
// The version of the arkgate package these bins ship with. Used to
|
|
866
|
-
// stamp installed skills so a normal ark-check can tell "outdated skill from an
|
|
867
|
-
// older Ark" apart from "user-customized skill" — the stamp moves with the
|
|
868
|
-
// package, editing the body doesn't.
|
|
869
|
-
export function arkPackageVersion() {
|
|
870
|
-
try {
|
|
871
|
-
const pkg = readJson(path.join(__packageRoot, 'package.json'));
|
|
872
|
-
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
873
|
-
} catch {
|
|
874
|
-
return null;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
|
|
879
|
-
// `---`). No frontmatter → returned unchanged. Idempotent for a given version.
|
|
880
|
-
export function stampSkill(content, version) {
|
|
881
|
-
if (!version) return content;
|
|
882
|
-
const lines = content.split('\n');
|
|
883
|
-
if (lines[0] !== '---') return content;
|
|
884
|
-
const closeIdx = lines.indexOf('---', 1);
|
|
885
|
-
if (closeIdx === -1) return content;
|
|
886
|
-
const existing = lines.findIndex(
|
|
887
|
-
(line, i) => i > 0 && i < closeIdx && /^arkVersion:/.test(line)
|
|
888
|
-
);
|
|
889
|
-
if (existing !== -1) {
|
|
890
|
-
lines[existing] = `arkVersion: ${version}`;
|
|
891
|
-
} else {
|
|
892
|
-
lines.splice(closeIdx, 0, `arkVersion: ${version}`);
|
|
893
|
-
}
|
|
894
|
-
return lines.join('\n');
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
// Read the `arkVersion:` stamp from an installed skill file. Returns null when
|
|
898
|
-
// the file is absent or has no stamp (installed by a pre-stamp Ark, or hand-authored).
|
|
899
|
-
export function installedSkillVersion(filePath) {
|
|
900
|
-
let content;
|
|
901
|
-
try {
|
|
902
|
-
content = fs.readFileSync(filePath, 'utf8');
|
|
903
|
-
} catch {
|
|
904
|
-
return null;
|
|
905
|
-
}
|
|
906
|
-
const match = content.match(/^arkVersion:\s*(.+)$/m);
|
|
907
|
-
return match ? match[1].trim() : null;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// Numeric-tuple compare of dotted versions; true when `a` is strictly older than
|
|
911
|
-
// `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
|
|
912
|
-
export function isVersionOlder(a, b) {
|
|
913
|
-
const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
914
|
-
const av = parse(a);
|
|
915
|
-
const bv = parse(b);
|
|
916
|
-
const len = Math.max(av.length, bv.length);
|
|
917
|
-
for (let i = 0; i < len; i += 1) {
|
|
918
|
-
const x = av[i] ?? 0;
|
|
919
|
-
const y = bv[i] ?? 0;
|
|
920
|
-
if (x !== y) return x < y;
|
|
921
|
-
}
|
|
922
|
-
return false;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
export function skillTemplates() {
|
|
926
|
-
const dir = path.join(__packageRoot, 'templates', 'skills');
|
|
927
|
-
// A missing/mispackaged templates dir would otherwise install zero skills with
|
|
928
|
-
// exit 0 — warn so a packaging regression (e.g. "templates" dropped from the
|
|
929
|
-
// package.json files array) is visible instead of a silent no-op.
|
|
930
|
-
let entries;
|
|
931
|
-
try {
|
|
932
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
933
|
-
} catch {
|
|
934
|
-
console.error(
|
|
935
|
-
`Warning: skill templates directory not found (${dir}); no /ark-* skills installed.`
|
|
936
|
-
);
|
|
937
|
-
return [];
|
|
938
|
-
}
|
|
939
|
-
return entries
|
|
940
|
-
.filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
|
|
941
|
-
.map((entry) => entry.name)
|
|
942
|
-
.sort()
|
|
943
|
-
.map((name) => [path.basename(name, '.md'), fs.readFileSync(path.join(dir, name), 'utf8')]);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// Skill names only, silent on a missing templates dir — for the freshness
|
|
947
|
-
// advisory below, which must not print packaging warnings on every check run.
|
|
948
|
-
export function skillTemplateNames() {
|
|
949
|
-
const dir = path.join(__packageRoot, 'templates', 'skills');
|
|
950
|
-
let entries;
|
|
951
|
-
try {
|
|
952
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
953
|
-
} catch {
|
|
954
|
-
return [];
|
|
955
|
-
}
|
|
956
|
-
return entries
|
|
957
|
-
.filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
|
|
958
|
-
.map((entry) => path.basename(entry.name, '.md'));
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// A normal ark-check run is the reliable discovery point for new /ark-* skills.
|
|
962
|
-
// Ark ships no install lifecycle script (a postinstall banner would be blocked by
|
|
963
|
-
// modern package managers' script-approval policy anyway, so careful users never
|
|
964
|
-
// saw it — and it broke hardened installs). When a project has adopted Ark agent
|
|
965
|
-
// gates (AGENTS.md present) but a detected tool is missing
|
|
966
|
-
// skills this version ships, surface it here so agents and CI actually notice.
|
|
967
|
-
// Advisory only — never affects the exit code. Copilot has no reliable directory
|
|
968
|
-
// signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
|
|
969
|
-
export function detectCodexHomeGap(root) {
|
|
970
|
-
if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
|
|
971
|
-
if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
|
|
972
|
-
const skillNames = skillTemplateNames();
|
|
973
|
-
if (skillNames.length === 0) return null;
|
|
974
|
-
const dir = codexPromptsDir();
|
|
975
|
-
if (!fs.existsSync(dir)) return null;
|
|
976
|
-
const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
|
|
977
|
-
if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
|
|
978
|
-
const version = arkPackageVersion();
|
|
979
|
-
const missing = skillNames.length - present.length;
|
|
980
|
-
let stale = 0;
|
|
981
|
-
if (version) {
|
|
982
|
-
for (const name of present) {
|
|
983
|
-
const installed = installedSkillVersion(path.join(dir, `${name}.md`));
|
|
984
|
-
if (installed === null || isVersionOlder(installed, version)) stale += 1;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
return missing > 0 || stale > 0 ? { missing, stale } : null;
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
export function detectSkillGaps(root) {
|
|
991
|
-
if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return [];
|
|
992
|
-
// The Ark source tree keeps the skill templates at templates/skills/ — it's the
|
|
993
|
-
// producer, not a consumer, so it must not nag itself to "install" its own skills.
|
|
994
|
-
if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
|
|
995
|
-
const skillNames = skillTemplateNames();
|
|
996
|
-
if (skillNames.length === 0) return [];
|
|
997
|
-
const detected = [];
|
|
998
|
-
if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
|
|
999
|
-
if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
|
|
1000
|
-
if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
|
|
1001
|
-
if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
|
|
1002
|
-
if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
|
|
1003
|
-
if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
|
|
1004
|
-
detected.push('cline');
|
|
1005
|
-
}
|
|
1006
|
-
const version = arkPackageVersion();
|
|
1007
|
-
const gaps = [];
|
|
1008
|
-
for (const tool of detected) {
|
|
1009
|
-
const target = SKILL_TOOL_TARGETS[tool];
|
|
1010
|
-
if (!target) continue;
|
|
1011
|
-
let missing = 0;
|
|
1012
|
-
let stale = 0;
|
|
1013
|
-
for (const name of skillNames) {
|
|
1014
|
-
const file = path.join(root, target(name));
|
|
1015
|
-
if (!fs.existsSync(file)) {
|
|
1016
|
-
missing += 1;
|
|
1017
|
-
} else if (version) {
|
|
1018
|
-
// An installed skill with no stamp predates stamping (older Ark), or one
|
|
1019
|
-
// stamped behind the current version is left over from an older install.
|
|
1020
|
-
// Either way the shipped skill has moved on — offer a --force refresh.
|
|
1021
|
-
const installed = installedSkillVersion(file);
|
|
1022
|
-
if (installed === null || isVersionOlder(installed, version)) stale += 1;
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
|
|
1026
|
-
}
|
|
1027
|
-
return gaps;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// Files carrying an emitted Ark command whose runner (npx / pnpm exec / yarn) should match
|
|
1031
|
-
// the project's package manager. .mcp.json / .cursor/mcp.json hold it structurally
|
|
1032
|
-
// (command/args); the rest hold it as text ("npx ark-check …", incl. .claude/settings.json
|
|
1033
|
-
// hook strings and the package.json check:architecture script).
|
|
1034
|
-
const COMMAND_GATE_TEXT_FILES = [
|
|
1035
|
-
'.claude/settings.json', 'AGENTS.md', '.cursor/rules/ark.mdc', '.windsurf/rules/ark.md',
|
|
1036
|
-
'.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
|
|
1037
|
-
'.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
|
|
1038
|
-
'.grok/hooks/ark-write-gate.json', '.grok/config.toml',
|
|
1039
|
-
];
|
|
1040
|
-
const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
|
|
1041
|
-
// Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
|
|
1042
|
-
// before re-emitting a single preferred bin — otherwise a partial rename leaves
|
|
1043
|
-
// args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
|
|
1044
|
-
const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
|
|
1045
|
-
const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
|
|
1046
|
-
const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
|
|
1047
|
-
const PREFERRED_MCP_BIN = 'arkgate-mcp';
|
|
1048
|
-
const PREFERRED_CHECK_BIN = 'arkgate-check';
|
|
1049
|
-
const PREFERRED_CLI_BIN = 'arkgate';
|
|
1050
|
-
// Runner argv noise that is not a bin argument (pnpm exec form).
|
|
1051
|
-
const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
|
|
1052
|
-
// The runner token immediately before an ark command in a text command string.
|
|
1053
|
-
// Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
|
|
1054
|
-
// Longer bin names first so `arkgate-check` is not partially matched as `ark`.
|
|
1055
|
-
const RUNNER_BEFORE_ARK =
|
|
1056
|
-
/\b(?:npx|pnpm --config\.verify-deps-before-run=false exec|pnpm exec|yarn)(?= (?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)\b)/g;
|
|
1057
|
-
|
|
1058
|
-
/** Keep only MCP server flags from existing args (drop runner tokens + any ark* bin names). */
|
|
1059
|
-
export function stripMcpServerArgs(args) {
|
|
1060
|
-
if (!Array.isArray(args) || args.length === 0) {
|
|
1061
|
-
return ['--root', '.', '--config', 'ark.config.json'];
|
|
1062
|
-
}
|
|
1063
|
-
const kept = args.filter(
|
|
1064
|
-
(entry) =>
|
|
1065
|
-
typeof entry === 'string' &&
|
|
1066
|
-
!MCP_RUNNER_ARGV.has(entry) &&
|
|
1067
|
-
!ARK_MCP_BINS.has(entry) &&
|
|
1068
|
-
!ARK_CHECK_BINS.has(entry) &&
|
|
1069
|
-
!ARK_CLI_BINS.has(entry)
|
|
1070
|
-
);
|
|
1071
|
-
return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
|
|
1075
|
-
export function mcpArgsHaveDuplicateBins(args) {
|
|
1076
|
-
if (!Array.isArray(args)) return false;
|
|
1077
|
-
const hits = args.filter((entry) => ARK_MCP_BINS.has(entry));
|
|
1078
|
-
return hits.length > 1 || (hits.length === 1 && args.indexOf(hits[0]) !== args.lastIndexOf(hits[0]));
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
export function brokenMcpGateFiles(root) {
|
|
1082
|
-
const bad = [];
|
|
1083
|
-
for (const rel of COMMAND_GATE_JSON_FILES) {
|
|
1084
|
-
let json;
|
|
1085
|
-
try {
|
|
1086
|
-
json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
1087
|
-
} catch {
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1090
|
-
const ark = json?.mcpServers?.ark;
|
|
1091
|
-
if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
|
|
1092
|
-
}
|
|
1093
|
-
return bad;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
/**
|
|
1097
|
-
* Production deploy path quality (universal — any consumer repo).
|
|
1098
|
-
* Detects when the production build host runs ESLint / typecheck as part of
|
|
1099
|
-
* `build` (e.g. Next.js "Linting and checking validity of types") so failures
|
|
1100
|
-
* surface first on Vercel/Netlify/etc. unless CI/pre-merge runs the same checks.
|
|
1101
|
-
* Framework signals only (deps + scripts + config) — never project-specific.
|
|
1102
|
-
*
|
|
1103
|
-
* @returns {{
|
|
1104
|
-
* embedsLintInBuild: boolean,
|
|
1105
|
-
* embedsTypecheckInBuild: boolean,
|
|
1106
|
-
* engines: string[],
|
|
1107
|
-
* hasLintScript: boolean,
|
|
1108
|
-
* hasTypecheckScript: boolean,
|
|
1109
|
-
* ciRunsLint: boolean,
|
|
1110
|
-
* ciRunsTypecheck: boolean,
|
|
1111
|
-
* eslintIgnoreDuringBuilds: boolean,
|
|
1112
|
-
* }}
|
|
1113
|
-
*/
|
|
1114
|
-
export function detectDeployPathQuality(root) {
|
|
1115
|
-
const pkg = readPackageJson(root) || {};
|
|
1116
|
-
const deps = {
|
|
1117
|
-
...(pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}),
|
|
1118
|
-
...(pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {}),
|
|
1119
|
-
...(pkg.peerDependencies && typeof pkg.peerDependencies === 'object' ? pkg.peerDependencies : {}),
|
|
1120
|
-
};
|
|
1121
|
-
const scripts =
|
|
1122
|
-
pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
1123
|
-
const buildScript = typeof scripts.build === 'string' ? scripts.build : '';
|
|
1124
|
-
|
|
1125
|
-
const engines = [];
|
|
1126
|
-
// Next.js production build runs ESLint + typecheck by default (unless opted out).
|
|
1127
|
-
if (deps.next || /\bnext\s+build\b/.test(buildScript)) engines.push('next');
|
|
1128
|
-
// Nuxt 3+ can lint via modules; only flag when build clearly invokes nuxt build + eslint tooling present.
|
|
1129
|
-
if ((deps.nuxt || deps['nuxt3'] || /\bnuxt\s+build\b/.test(buildScript)) && (deps.eslint || hasEslintConfig(root))) {
|
|
1130
|
-
engines.push('nuxt');
|
|
1131
|
-
}
|
|
1132
|
-
// Create React App historically failed build on ESLint errors.
|
|
1133
|
-
if (deps['react-scripts'] || /\breact-scripts\s+build\b/.test(buildScript)) engines.push('cra');
|
|
1134
|
-
|
|
1135
|
-
const eslintIgnoreDuringBuilds = engines.includes('next') && nextIgnoresEslintDuringBuilds(root);
|
|
1136
|
-
const embedsLintInBuild = engines.length > 0 && !eslintIgnoreDuringBuilds;
|
|
1137
|
-
// Next still typechecks during build even when eslint.ignoreDuringBuilds is true.
|
|
1138
|
-
const embedsTypecheckInBuild = engines.includes('next') || engines.includes('nuxt');
|
|
1139
|
-
|
|
1140
|
-
const scriptHasLint = (s) =>
|
|
1141
|
-
Boolean(
|
|
1142
|
-
s &&
|
|
1143
|
-
((typeof s.lint === 'string' && s.lint.trim()) ||
|
|
1144
|
-
(typeof s.eslint === 'string' && s.eslint.trim()) ||
|
|
1145
|
-
(typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
|
|
1146
|
-
(typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
|
|
1147
|
-
);
|
|
1148
|
-
|
|
1149
|
-
let hasLintScript = scriptHasLint(scripts);
|
|
1150
|
-
let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
|
|
1151
|
-
const packageLintScripts = [];
|
|
1152
|
-
// Monorepo: package-level scripts count (apps/web, packages/ui, …).
|
|
1153
|
-
try {
|
|
1154
|
-
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1155
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1156
|
-
const candidates = [path.join(root, entry.name)];
|
|
1157
|
-
// one more level: packages/foo
|
|
1158
|
-
try {
|
|
1159
|
-
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
1160
|
-
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
1161
|
-
candidates.push(path.join(root, entry.name, child.name));
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
} catch {
|
|
1165
|
-
/* ignore */
|
|
1166
|
-
}
|
|
1167
|
-
for (const dir of candidates) {
|
|
1168
|
-
const pj = path.join(dir, 'package.json');
|
|
1169
|
-
if (!fs.existsSync(pj)) continue;
|
|
1170
|
-
try {
|
|
1171
|
-
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
1172
|
-
const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
|
|
1173
|
-
if (scriptHasLint(ns)) {
|
|
1174
|
-
hasLintScript = true;
|
|
1175
|
-
packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
|
|
1176
|
-
}
|
|
1177
|
-
if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
|
|
1178
|
-
const nd = {
|
|
1179
|
-
...(nested.dependencies || {}),
|
|
1180
|
-
...(nested.devDependencies || {}),
|
|
1181
|
-
};
|
|
1182
|
-
if (nd.next && !engines.includes('next')) engines.push('next');
|
|
1183
|
-
} catch {
|
|
1184
|
-
/* ignore */
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
} catch {
|
|
1189
|
-
/* ignore */
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
const ciTexts = collectCiWorkflowTexts(root);
|
|
1193
|
-
const ciJoined = ciTexts.join('\n');
|
|
1194
|
-
const ciRunsLint =
|
|
1195
|
-
ciTexts.length > 0 &&
|
|
1196
|
-
(/\bnpm\s+run\s+lint\b/i.test(ciJoined) ||
|
|
1197
|
-
/\bpnpm\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
1198
|
-
/\byarn\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
1199
|
-
/\bbun\s+run\s+lint\b/i.test(ciJoined) ||
|
|
1200
|
-
/\beslint\b/i.test(ciJoined) ||
|
|
1201
|
-
/\blint:ci\b/i.test(ciJoined) ||
|
|
1202
|
-
/\bcheck:lint\b/i.test(ciJoined) ||
|
|
1203
|
-
// package-level: working-directory + lint, or path/filter lint
|
|
1204
|
-
(packageLintScripts.length > 0 &&
|
|
1205
|
-
packageLintScripts.some((p) => ciJoined.includes(p) && /lint/i.test(ciJoined))));
|
|
1206
|
-
const ciRunsTypecheck =
|
|
1207
|
-
ciTexts.length > 0 &&
|
|
1208
|
-
(/\btypecheck\b/i.test(ciJoined) ||
|
|
1209
|
-
/\btype-check\b/i.test(ciJoined) ||
|
|
1210
|
-
/\bcheck:types\b/i.test(ciJoined) ||
|
|
1211
|
-
/\btsc\s+--noEmit\b/i.test(ciJoined));
|
|
1212
|
-
|
|
1213
|
-
return {
|
|
1214
|
-
embedsLintInBuild,
|
|
1215
|
-
embedsTypecheckInBuild,
|
|
1216
|
-
engines,
|
|
1217
|
-
hasLintScript,
|
|
1218
|
-
hasTypecheckScript,
|
|
1219
|
-
ciRunsLint,
|
|
1220
|
-
ciRunsTypecheck,
|
|
1221
|
-
eslintIgnoreDuringBuilds,
|
|
1222
|
-
hasCiWorkflows: ciTexts.length > 0,
|
|
1223
|
-
packageLintScripts,
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
function hasEslintConfig(root) {
|
|
1228
|
-
return [
|
|
1229
|
-
'eslint.config.mjs',
|
|
1230
|
-
'eslint.config.js',
|
|
1231
|
-
'eslint.config.cjs',
|
|
1232
|
-
'eslint.config.ts',
|
|
1233
|
-
'.eslintrc.json',
|
|
1234
|
-
'.eslintrc.cjs',
|
|
1235
|
-
'.eslintrc.js',
|
|
1236
|
-
'.eslintrc.yml',
|
|
1237
|
-
'.eslintrc.yaml',
|
|
1238
|
-
].some((f) => fs.existsSync(path.join(root, f)));
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
/** next.config.* eslint.ignoreDuringBuilds: true → production build will not fail on ESLint. */
|
|
1242
|
-
function nextIgnoresEslintDuringBuilds(root) {
|
|
1243
|
-
const names = [
|
|
1244
|
-
'next.config.ts',
|
|
1245
|
-
'next.config.mts',
|
|
1246
|
-
'next.config.js',
|
|
1247
|
-
'next.config.mjs',
|
|
1248
|
-
'next.config.cjs',
|
|
1249
|
-
];
|
|
1250
|
-
for (const name of names) {
|
|
1251
|
-
const file = path.join(root, name);
|
|
1252
|
-
if (!fs.existsSync(file)) continue;
|
|
1253
|
-
try {
|
|
1254
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
1255
|
-
// Common patterns: ignoreDuringBuilds: true | ignoreDuringBuilds: true,
|
|
1256
|
-
if (/ignoreDuringBuilds\s*:\s*true/.test(text)) return true;
|
|
1257
|
-
} catch {
|
|
1258
|
-
/* ignore */
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return false;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
function collectCiWorkflowTexts(root) {
|
|
1265
|
-
const texts = [];
|
|
1266
|
-
const pushFile = (rel) => {
|
|
1267
|
-
try {
|
|
1268
|
-
const full = path.join(root, rel);
|
|
1269
|
-
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
|
1270
|
-
texts.push(fs.readFileSync(full, 'utf8'));
|
|
1271
|
-
}
|
|
1272
|
-
} catch {
|
|
1273
|
-
/* ignore */
|
|
1274
|
-
}
|
|
1275
|
-
};
|
|
1276
|
-
pushFile('.gitlab-ci.yml');
|
|
1277
|
-
pushFile('bitbucket-pipelines.yml');
|
|
1278
|
-
pushFile('azure-pipelines.yml');
|
|
1279
|
-
pushFile('.circleci/config.yml');
|
|
1280
|
-
const wfDir = path.join(root, '.github', 'workflows');
|
|
1281
|
-
try {
|
|
1282
|
-
if (fs.existsSync(wfDir)) {
|
|
1283
|
-
for (const f of fs.readdirSync(wfDir)) {
|
|
1284
|
-
if (!/\.ya?ml$/i.test(f)) continue;
|
|
1285
|
-
pushFile(path.join('.github', 'workflows', f));
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
} catch {
|
|
1289
|
-
/* ignore */
|
|
1290
|
-
}
|
|
1291
|
-
return texts;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
/**
|
|
1295
|
-
* W5 — Write-path capability surface for doctor (stable additive JSON).
|
|
1296
|
-
*
|
|
1297
|
-
* Detects whether installed agent gates expose:
|
|
1298
|
-
* - MCP prepare-write / validate_code (autoPatch) tools
|
|
1299
|
-
* - PreToolUse hook in reject-only vs repair mode (--hook-repair / ARK_HOOK_REPAIR)
|
|
1300
|
-
*
|
|
1301
|
-
* Never claims silent apply; "repair" means host can re-inject a patch after hard deny.
|
|
1302
|
-
*
|
|
1303
|
-
* @returns {{
|
|
1304
|
-
* mode: 'repair' | 'reject-only' | 'mcp-only' | 'none',
|
|
1305
|
-
* prepareWrite: boolean,
|
|
1306
|
-
* autoPatch: boolean,
|
|
1307
|
-
* hookPresent: boolean,
|
|
1308
|
-
* hookRepair: boolean,
|
|
1309
|
-
* mcpPresent: boolean,
|
|
1310
|
-
* evidence: string[],
|
|
1311
|
-
* gap: null | { id: string, severity: string, message: string, fix: string },
|
|
1312
|
-
* }}
|
|
1313
|
-
*/
|
|
1314
|
-
export function detectWritePathCapabilities(root) {
|
|
1315
|
-
const evidence = [];
|
|
1316
|
-
let hookPresent = false;
|
|
1317
|
-
let hookRepair = false;
|
|
1318
|
-
|
|
1319
|
-
const hookFiles = [
|
|
1320
|
-
'.claude/settings.json',
|
|
1321
|
-
'.grok/hooks/ark-write-gate.json',
|
|
1322
|
-
];
|
|
1323
|
-
for (const rel of hookFiles) {
|
|
1324
|
-
const abs = path.join(root, rel);
|
|
1325
|
-
if (!fs.existsSync(abs)) continue;
|
|
1326
|
-
let text = '';
|
|
1327
|
-
try {
|
|
1328
|
-
text = fs.readFileSync(abs, 'utf8');
|
|
1329
|
-
} catch {
|
|
1330
|
-
continue;
|
|
1331
|
-
}
|
|
1332
|
-
// PreToolUse / write-gate command referencing ark(-gate)?-mcp --hook
|
|
1333
|
-
if (
|
|
1334
|
-
/--hook\b/.test(text) ||
|
|
1335
|
-
/\b(ark|arkgate)-mcp\b[\s\S]{0,80}--hook\b/.test(text) ||
|
|
1336
|
-
/\b--hook\b[\s\S]{0,80}\b(ark|arkgate)-mcp\b/.test(text)
|
|
1337
|
-
) {
|
|
1338
|
-
hookPresent = true;
|
|
1339
|
-
evidence.push(rel);
|
|
1340
|
-
}
|
|
1341
|
-
if (
|
|
1342
|
-
/--hook-repair\b/.test(text) ||
|
|
1343
|
-
/ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text)
|
|
1344
|
-
) {
|
|
1345
|
-
hookRepair = true;
|
|
1346
|
-
if (!evidence.includes(rel)) evidence.push(rel);
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
let mcpPresent = false;
|
|
1351
|
-
const mcpFiles = ['.mcp.json', '.cursor/mcp.json', '.grok/config.toml'];
|
|
1352
|
-
for (const rel of mcpFiles) {
|
|
1353
|
-
const abs = path.join(root, rel);
|
|
1354
|
-
if (!fs.existsSync(abs)) continue;
|
|
1355
|
-
let text = '';
|
|
1356
|
-
try {
|
|
1357
|
-
text = fs.readFileSync(abs, 'utf8');
|
|
1358
|
-
} catch {
|
|
1359
|
-
continue;
|
|
1360
|
-
}
|
|
1361
|
-
if (
|
|
1362
|
-
/\b(ark|arkgate)-mcp\b/.test(text) ||
|
|
1363
|
-
/mcp_servers\.ark\b/.test(text) ||
|
|
1364
|
-
/"ark"\s*:\s*\{/.test(text) ||
|
|
1365
|
-
/mcpServers[\s\S]*\bark\b/.test(text)
|
|
1366
|
-
) {
|
|
1367
|
-
mcpPresent = true;
|
|
1368
|
-
evidence.push(rel);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// Package tools when MCP is wired: ark_prepare_write + validate_code(autoPatch).
|
|
1373
|
-
// Hook repair emits machine-readable autoPatch without silent write.
|
|
1374
|
-
const prepareWrite = mcpPresent;
|
|
1375
|
-
const autoPatch = mcpPresent || hookRepair;
|
|
1376
|
-
|
|
1377
|
-
/** @type {'repair' | 'reject-only' | 'mcp-only' | 'none'} */
|
|
1378
|
-
let mode = 'none';
|
|
1379
|
-
if (hookPresent && hookRepair) mode = 'repair';
|
|
1380
|
-
else if (hookPresent && !hookRepair) mode = 'reject-only';
|
|
1381
|
-
else if (mcpPresent) mode = 'mcp-only';
|
|
1382
|
-
|
|
1383
|
-
let gap = null;
|
|
1384
|
-
if (mode === 'none') {
|
|
1385
|
-
gap = {
|
|
1386
|
-
id: 'write-path-none',
|
|
1387
|
-
severity: 'warn',
|
|
1388
|
-
message:
|
|
1389
|
-
'Write path is not installed — no PreToolUse hook and no Ark MCP. Agents write without architecture gate or prepare-write.',
|
|
1390
|
-
fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
|
|
1391
|
-
};
|
|
1392
|
-
} else if (mode === 'reject-only') {
|
|
1393
|
-
gap = {
|
|
1394
|
-
id: 'write-path-reject-only',
|
|
1395
|
-
severity: 'info',
|
|
1396
|
-
message: mcpPresent
|
|
1397
|
-
? '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.'
|
|
1398
|
-
: '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.',
|
|
1399
|
-
fix: arkCommand(
|
|
1400
|
-
root,
|
|
1401
|
-
'ark-check',
|
|
1402
|
-
'--install-agent-gates --tools claude,grok --force'
|
|
1403
|
-
),
|
|
1404
|
-
};
|
|
1405
|
-
} else if (mode === 'mcp-only') {
|
|
1406
|
-
gap = {
|
|
1407
|
-
id: 'write-path-mcp-only',
|
|
1408
|
-
severity: 'info',
|
|
1409
|
-
message:
|
|
1410
|
-
'MCP exposes prepare-write / autoPatch tools, but no PreToolUse write hook is installed — enforcement is advisory unless the agent calls tools.',
|
|
1411
|
-
fix: arkCommand(root, 'ark-check', '--install-agent-gates --tools claude,grok'),
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
return {
|
|
1416
|
-
mode,
|
|
1417
|
-
prepareWrite,
|
|
1418
|
-
autoPatch,
|
|
1419
|
-
hookPresent,
|
|
1420
|
-
hookRepair,
|
|
1421
|
-
mcpPresent,
|
|
1422
|
-
evidence: [...new Set(evidence)],
|
|
1423
|
-
gap,
|
|
1424
|
-
};
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
/**
|
|
1428
|
-
* Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
|
|
1429
|
-
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
|
|
1430
|
-
*/
|
|
1431
|
-
export function collectAdoptionGaps(root, config, coverage) {
|
|
1432
|
-
const gaps = [];
|
|
1433
|
-
const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
|
|
1434
|
-
const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
|
|
1435
|
-
|
|
1436
|
-
// --- Write path: prepare-write / autoPatch / reject-only (W5) ---
|
|
1437
|
-
const writePath = detectWritePathCapabilities(root);
|
|
1438
|
-
// Only surface write-path gaps when the project has adopted gates (or has partial install).
|
|
1439
|
-
// Producer package tree always has templates — still report capability for dogfood honesty.
|
|
1440
|
-
if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
|
|
1441
|
-
// Producer may be repair-capable via own templates; still useful. Skip "none" on pure
|
|
1442
|
-
// consumer repos with zero Ark files? missingGates already covers that.
|
|
1443
|
-
if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
|
|
1444
|
-
gaps.push(writePath.gap);
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
// --- Repo MCP dual-bin ---
|
|
1449
|
-
const dualMcp = brokenMcpGateFiles(root);
|
|
1450
|
-
const mcp = {
|
|
1451
|
-
dualBinFiles: dualMcp,
|
|
1452
|
-
ok: dualMcp.length === 0,
|
|
1453
|
-
};
|
|
1454
|
-
if (dualMcp.length > 0) {
|
|
1455
|
-
gaps.push({
|
|
1456
|
-
id: 'mcp-dual-bin',
|
|
1457
|
-
severity: 'warn',
|
|
1458
|
-
message: `Broken MCP argv in ${dualMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp`,
|
|
1459
|
-
fix: arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands'),
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// --- Host completeness (only when project already adopted gates) ---
|
|
1464
|
-
const hosts = [];
|
|
1465
|
-
if (adopted && !isProducer) {
|
|
1466
|
-
const skillNames = skillTemplateNames();
|
|
1467
|
-
const hostChecks = [
|
|
1468
|
-
{
|
|
1469
|
-
host: 'grok',
|
|
1470
|
-
dir: '.grok',
|
|
1471
|
-
skill: (n) => path.join(root, '.grok', 'skills', n, 'SKILL.md'),
|
|
1472
|
-
extras: [
|
|
1473
|
-
['.grok/hooks/ark-write-gate.json', 'write-gate hook'],
|
|
1474
|
-
['.grok/config.toml', 'project MCP config'],
|
|
1475
|
-
],
|
|
1476
|
-
toolsFlag: 'grok',
|
|
1477
|
-
},
|
|
1478
|
-
{
|
|
1479
|
-
host: 'claude',
|
|
1480
|
-
dir: '.claude',
|
|
1481
|
-
skill: (n) => path.join(root, '.claude', 'skills', n, 'SKILL.md'),
|
|
1482
|
-
extras: [['.claude/settings.json', 'settings/hooks']],
|
|
1483
|
-
toolsFlag: 'claude',
|
|
1484
|
-
},
|
|
1485
|
-
{
|
|
1486
|
-
host: 'cursor',
|
|
1487
|
-
dir: '.cursor',
|
|
1488
|
-
skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
|
|
1489
|
-
extras: [['.cursor/mcp.json', 'MCP config']],
|
|
1490
|
-
toolsFlag: 'cursor',
|
|
1491
|
-
},
|
|
1492
|
-
];
|
|
1493
|
-
for (const h of hostChecks) {
|
|
1494
|
-
if (!fs.existsSync(path.join(root, h.dir))) continue;
|
|
1495
|
-
const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
|
|
1496
|
-
const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
|
|
1497
|
-
const complete = missingSkills.length === 0 && missingExtras.length === 0;
|
|
1498
|
-
hosts.push({
|
|
1499
|
-
host: h.host,
|
|
1500
|
-
present: true,
|
|
1501
|
-
complete,
|
|
1502
|
-
missingSkills: missingSkills.length,
|
|
1503
|
-
missingExtras: missingExtras.map(([, label]) => label),
|
|
1504
|
-
});
|
|
1505
|
-
if (!complete) {
|
|
1506
|
-
gaps.push({
|
|
1507
|
-
id: `host-${h.host}-incomplete`,
|
|
1508
|
-
severity: 'warn',
|
|
1509
|
-
message: `${h.host} dir present but incomplete (${missingSkills.length} skill(s) missing${
|
|
1510
|
-
missingExtras.length ? `; missing ${missingExtras.map(([, l]) => l).join(', ')}` : ''
|
|
1511
|
-
})`,
|
|
1512
|
-
fix: arkCommand(
|
|
1513
|
-
root,
|
|
1514
|
-
'ark-check',
|
|
1515
|
-
`--install-agent-gates --tools ${h.toolsFlag} --force`
|
|
1516
|
-
),
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
// --- Codex home MCP (temp path / wrong root / multi-project) ---
|
|
1523
|
-
let codexHome = null;
|
|
1524
|
-
if (adopted && !isProducer) {
|
|
1525
|
-
const codexFile = codexConfigPath();
|
|
1526
|
-
let toml = '';
|
|
1527
|
-
try {
|
|
1528
|
-
if (fs.existsSync(codexFile)) toml = fs.readFileSync(codexFile, 'utf8');
|
|
1529
|
-
} catch {
|
|
1530
|
-
toml = '';
|
|
1531
|
-
}
|
|
1532
|
-
if (toml.includes('[mcp_servers.ark]')) {
|
|
1533
|
-
const assessed = assessCodexHomeMcp(toml, root);
|
|
1534
|
-
codexHome = {
|
|
1535
|
-
file: codexFile,
|
|
1536
|
-
root: assessed.root,
|
|
1537
|
-
tempPath: assessed.tempPath,
|
|
1538
|
-
wrongRoot: assessed.wrongRoot,
|
|
1539
|
-
preferredBin: assessed.preferredBin,
|
|
1540
|
-
needsRewrite: assessed.needsRewrite,
|
|
1541
|
-
multiProject: assessed.multiProject,
|
|
1542
|
-
scopedTable: assessed.scopedTable,
|
|
1543
|
-
};
|
|
1544
|
-
if (assessed.gap) {
|
|
1545
|
-
gaps.push({
|
|
1546
|
-
id: assessed.gap.id,
|
|
1547
|
-
severity: assessed.gap.severity,
|
|
1548
|
-
message: assessed.gap.message,
|
|
1549
|
-
fix: arkCommand(root, 'ark-check', assessed.gap.fixArgs),
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
// --- Core layers optional but populated ---
|
|
1556
|
-
const coreOptional = [];
|
|
1557
|
-
const layerRows = coverage?.layers ?? [];
|
|
1558
|
-
const countByName = new Map(layerRows.map((r) => [r.name, r.files]));
|
|
1559
|
-
for (const layer of config?.layers ?? []) {
|
|
1560
|
-
if (!CORE_LAYER_NAMES.has(layer.name)) continue;
|
|
1561
|
-
if (layer.optional !== true) continue;
|
|
1562
|
-
const files = countByName.get(layer.name) ?? 0;
|
|
1563
|
-
if (files > 0) {
|
|
1564
|
-
coreOptional.push({ layer: layer.name, files });
|
|
1565
|
-
gaps.push({
|
|
1566
|
-
id: `core-optional-${layer.name}`,
|
|
1567
|
-
severity: 'info',
|
|
1568
|
-
message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
|
|
1569
|
-
fix: `${arkCommand(root, 'ark-check', '--ratchet-cores')} (when architecture is green: 0 active violations)`,
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// --- Origin report ---
|
|
1575
|
-
const originJson = path.join(root, '.ark', 'reports', 'origin.json');
|
|
1576
|
-
const originReport = {
|
|
1577
|
-
present: fs.existsSync(originJson),
|
|
1578
|
-
path: '.ark/reports/origin.json',
|
|
1579
|
-
};
|
|
1580
|
-
if (adopted && !originReport.present && (coverage?.governed?.percent ?? 0) >= 50) {
|
|
1581
|
-
gaps.push({
|
|
1582
|
-
id: 'origin-report-missing',
|
|
1583
|
-
severity: 'info',
|
|
1584
|
-
message: 'No origin architecture snapshot under .ark/reports/ yet',
|
|
1585
|
-
fix: arkCommand(root, 'ark-check', '--report ark-report.html'),
|
|
1586
|
-
});
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
// --- Baseline policy ---
|
|
1590
|
-
const baselinePath = path.join(root, '.ark-baseline.json');
|
|
1591
|
-
const baselineExists = fs.existsSync(baselinePath);
|
|
1592
|
-
let frozenKeys = 0;
|
|
1593
|
-
if (baselineExists) {
|
|
1594
|
-
try {
|
|
1595
|
-
const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
1596
|
-
frozenKeys = Array.isArray(raw.violations) ? raw.violations.length : 0;
|
|
1597
|
-
} catch {
|
|
1598
|
-
frozenKeys = 0;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
let primaryPathUsesBaseline = false;
|
|
1602
|
-
try {
|
|
1603
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
1604
|
-
const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
1605
|
-
primaryPathUsesBaseline = Object.values(scripts).some(
|
|
1606
|
-
(s) => typeof s === 'string' && s.includes('--baseline')
|
|
1607
|
-
);
|
|
1608
|
-
} catch {
|
|
1609
|
-
/* no package.json */
|
|
1610
|
-
}
|
|
1611
|
-
if (!primaryPathUsesBaseline) {
|
|
1612
|
-
try {
|
|
1613
|
-
const wfDir = path.join(root, '.github', 'workflows');
|
|
1614
|
-
if (fs.existsSync(wfDir)) {
|
|
1615
|
-
for (const f of fs.readdirSync(wfDir)) {
|
|
1616
|
-
if (!/\.ya?ml$/i.test(f)) continue;
|
|
1617
|
-
const text = fs.readFileSync(path.join(wfDir, f), 'utf8');
|
|
1618
|
-
if (text.includes('--baseline') && (text.includes('ark-check') || text.includes('arkgate-check'))) {
|
|
1619
|
-
primaryPathUsesBaseline = true;
|
|
1620
|
-
break;
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
} catch {
|
|
1625
|
-
/* ignore */
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
const baseline = {
|
|
1629
|
-
exists: baselineExists,
|
|
1630
|
-
frozenKeys,
|
|
1631
|
-
primaryPathUsesBaseline,
|
|
1632
|
-
signal: baselineExists
|
|
1633
|
-
? frozenKeys === 0
|
|
1634
|
-
? 'keep-empty'
|
|
1635
|
-
: 'active-ratchet'
|
|
1636
|
-
: 'absent',
|
|
1637
|
-
};
|
|
1638
|
-
if (adopted && baselineExists && frozenKeys === 0 && !primaryPathUsesBaseline) {
|
|
1639
|
-
gaps.push({
|
|
1640
|
-
id: 'baseline-unused',
|
|
1641
|
-
severity: 'info',
|
|
1642
|
-
message:
|
|
1643
|
-
'Empty .ark-baseline.json exists but primary scripts/CI do not pass --baseline (policy unclear)',
|
|
1644
|
-
fix: 'Either add --baseline .ark-baseline.json to check:architecture / CI, or remove the unused baseline file',
|
|
1645
|
-
});
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// --- Educational layer balance (not a violation) ---
|
|
1649
|
-
let layerBalance = null;
|
|
1650
|
-
const total = layerRows.reduce((s, r) => s + (r.files || 0), 0);
|
|
1651
|
-
if (total >= 20) {
|
|
1652
|
-
const presentation = layerRows.find((r) => r.name === 'PresentationAdapters');
|
|
1653
|
-
const domain = layerRows.find((r) => r.name === 'DomainModel');
|
|
1654
|
-
if (presentation && domain) {
|
|
1655
|
-
const pShare = presentation.files / total;
|
|
1656
|
-
const dShare = domain.files / total;
|
|
1657
|
-
if (pShare >= 0.5 && dShare < 0.1) {
|
|
1658
|
-
layerBalance = {
|
|
1659
|
-
kind: 'presentation-heavy-thin-domain',
|
|
1660
|
-
presentationFiles: presentation.files,
|
|
1661
|
-
domainFiles: domain.files,
|
|
1662
|
-
totalFiles: total,
|
|
1663
|
-
educational:
|
|
1664
|
-
'Presentation holds most of the tree while DomainModel is thin — common for UI apps; consider extracting domain types/use-cases as the product grows. Educational only (not a gate failure).',
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
// --- Empty scope: contract matches no TS/JS ---
|
|
1671
|
-
if (!isProducer && (coverage?.governed?.totalFiles ?? coverage?.totalFiles) === 0) {
|
|
1672
|
-
gaps.push({
|
|
1673
|
-
id: 'empty-scope',
|
|
1674
|
-
severity: 'warn',
|
|
1675
|
-
message:
|
|
1676
|
-
'Empty scope: include paths match 0 TypeScript/JS files — checks are not governing this tree',
|
|
1677
|
-
fix: `${arkCommand(root, 'ark-check', '--suggest-include')} then ${arkCommand(root, 'ark-check', '--adopt-contract --write')}`,
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
// --- Deploy-path quality (ESLint/types that production build hosts run) ---
|
|
1682
|
-
// Universal: any Next/CRA/Nuxt (etc.) consumer. Not architecture — still adoption.
|
|
1683
|
-
// Skip pure library producer (this monorepo) to avoid self-noise.
|
|
1684
|
-
let deployPath = null;
|
|
1685
|
-
if (!isProducer) {
|
|
1686
|
-
deployPath = detectDeployPathQuality(root);
|
|
1687
|
-
const eng =
|
|
1688
|
-
deployPath.engines.length > 0 ? deployPath.engines.join('/') : 'production';
|
|
1689
|
-
if (deployPath.embedsLintInBuild && !deployPath.hasLintScript) {
|
|
1690
|
-
gaps.push({
|
|
1691
|
-
id: 'deploy-path-lint-script-missing',
|
|
1692
|
-
severity: 'warn',
|
|
1693
|
-
message: `${eng} production build runs ESLint — no package.json lint script, so failures often surface first on the deploy host`,
|
|
1694
|
-
fix: 'Add a package.json "lint" script (e.g. eslint .) matching production ESLint config; run it in CI and before merge',
|
|
1695
|
-
});
|
|
1696
|
-
} else if (
|
|
1697
|
-
deployPath.embedsLintInBuild &&
|
|
1698
|
-
deployPath.hasLintScript &&
|
|
1699
|
-
deployPath.hasCiWorkflows &&
|
|
1700
|
-
!deployPath.ciRunsLint
|
|
1701
|
-
) {
|
|
1702
|
-
gaps.push({
|
|
1703
|
-
id: 'deploy-path-lint-not-in-ci',
|
|
1704
|
-
severity: 'warn',
|
|
1705
|
-
message: `${eng} production build runs ESLint — CI workflows exist but do not run lint, so deploy hosts may be the first fail`,
|
|
1706
|
-
fix: 'Add a CI step that runs your package.json lint script (npm run lint / pnpm lint / yarn lint) and require it before deploy',
|
|
1707
|
-
});
|
|
1708
|
-
} else if (
|
|
1709
|
-
deployPath.embedsLintInBuild &&
|
|
1710
|
-
deployPath.hasLintScript &&
|
|
1711
|
-
!deployPath.hasCiWorkflows
|
|
1712
|
-
) {
|
|
1713
|
-
gaps.push({
|
|
1714
|
-
id: 'deploy-path-lint-no-ci',
|
|
1715
|
-
severity: 'info',
|
|
1716
|
-
message: `${eng} production build runs ESLint — no CI workflows detected; push-to-host builds may be the first lint fail`,
|
|
1717
|
-
fix: 'Add CI (or a pre-push hook) that runs lint before the deploy host builds; keep branch protection required when using GitHub',
|
|
1718
|
-
});
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
if (deployPath.embedsTypecheckInBuild && !deployPath.hasTypecheckScript) {
|
|
1722
|
-
gaps.push({
|
|
1723
|
-
id: 'deploy-path-typecheck-script-missing',
|
|
1724
|
-
severity: 'info',
|
|
1725
|
-
message: `${eng} production build typechecks — no package.json typecheck script for local/CI parity`,
|
|
1726
|
-
fix: 'Add "typecheck": "tsc --noEmit" (or framework equivalent) and run it in CI alongside lint',
|
|
1727
|
-
});
|
|
1728
|
-
} else if (
|
|
1729
|
-
deployPath.embedsTypecheckInBuild &&
|
|
1730
|
-
deployPath.hasTypecheckScript &&
|
|
1731
|
-
deployPath.hasCiWorkflows &&
|
|
1732
|
-
!deployPath.ciRunsTypecheck
|
|
1733
|
-
) {
|
|
1734
|
-
gaps.push({
|
|
1735
|
-
id: 'deploy-path-typecheck-not-in-ci',
|
|
1736
|
-
severity: 'info',
|
|
1737
|
-
message: `${eng} production build typechecks — CI does not run typecheck; type errors may appear first on the deploy host`,
|
|
1738
|
-
fix: 'Add a CI step for npm run typecheck (or your typecheck script) and require it before deploy',
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// --- False-green contract (field-install detector; doctor skillGaps already cover missing skills) ---
|
|
1744
|
-
let contractFalseGreen = null;
|
|
1745
|
-
if (!isProducer && config) {
|
|
1746
|
-
const gap = falseGreenAdoptionGap(root, config, coverage);
|
|
1747
|
-
if (gap) {
|
|
1748
|
-
contractFalseGreen = { risk: true, message: gap.message, fix: gap.fix };
|
|
1749
|
-
gaps.push(gap);
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
return {
|
|
1754
|
-
gaps,
|
|
1755
|
-
hosts,
|
|
1756
|
-
mcp,
|
|
1757
|
-
codexHome,
|
|
1758
|
-
coreOptional,
|
|
1759
|
-
originReport,
|
|
1760
|
-
baseline,
|
|
1761
|
-
layerBalance,
|
|
1762
|
-
deployPath,
|
|
1763
|
-
contractFalseGreen,
|
|
1764
|
-
writePath,
|
|
1765
|
-
};
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
// Gate files whose Ark command runner doesn't match this project's package manager — the
|
|
1769
|
-
// advisory (and --migrate-commands) target. Returns [] for npm/unknown projects (npx is right)
|
|
1770
|
-
// so the check is silent unless there's a real mismatch.
|
|
1771
|
-
export function staleRunnerGateFiles(root) {
|
|
1772
|
-
const want = execRunner(root);
|
|
1773
|
-
if (want === 'npx') return [];
|
|
1774
|
-
const stale = [];
|
|
1775
|
-
for (const rel of COMMAND_GATE_TEXT_FILES) {
|
|
1776
|
-
let text;
|
|
1777
|
-
try {
|
|
1778
|
-
text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
1779
|
-
} catch {
|
|
1780
|
-
continue;
|
|
1781
|
-
}
|
|
1782
|
-
RUNNER_BEFORE_ARK.lastIndex = 0;
|
|
1783
|
-
let match;
|
|
1784
|
-
while ((match = RUNNER_BEFORE_ARK.exec(text))) {
|
|
1785
|
-
if (match[0] !== want) {
|
|
1786
|
-
stale.push(rel);
|
|
1787
|
-
break;
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
for (const rel of COMMAND_GATE_JSON_FILES) {
|
|
1792
|
-
let json;
|
|
1793
|
-
try {
|
|
1794
|
-
json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
1795
|
-
} catch {
|
|
1796
|
-
continue;
|
|
1797
|
-
}
|
|
1798
|
-
const ark = json?.mcpServers?.ark;
|
|
1799
|
-
if (ark && ark.command && ark.command !== want.split(' ')[0]) stale.push(rel);
|
|
1800
|
-
}
|
|
1801
|
-
return stale;
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// When more than one lockfile is present the project is ambiguous. detectPackageManager()
|
|
1805
|
-
// resolves it (package-lock.json wins so a stray pnpm-lock.yaml can't hijack an npm project),
|
|
1806
|
-
// but the user should know it happened and how to make it explicit — otherwise a leftover
|
|
1807
|
-
// lockfile silently steers which runner every emitted command uses.
|
|
1808
|
-
export function warnLockfileConflict(root) {
|
|
1809
|
-
const locks = presentLockfiles(root);
|
|
1810
|
-
if (locks.length <= 1) return;
|
|
1811
|
-
const chosen = detectPackageManager(root);
|
|
1812
|
-
const files = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
|
|
1813
|
-
console.log('');
|
|
1814
|
-
console.log(
|
|
1815
|
-
`Note: multiple lockfiles present (${locks.map((pm) => files[pm]).join(', ')}). Treating this`
|
|
1816
|
-
);
|
|
1817
|
-
console.log(
|
|
1818
|
-
`as a ${chosen} project — Ark commands use "${execRunner(root)}". If that's wrong, set`
|
|
1819
|
-
);
|
|
1820
|
-
console.log(
|
|
1821
|
-
'"packageManager" in package.json (e.g. "pnpm@9") to declare it, or remove the stray lockfile.'
|
|
1822
|
-
);
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// --migrate-commands: rewrite ONLY the Ark command runner in existing gate files to the
|
|
1826
|
-
// project's package manager (no --force clobber). Closes the upgrade gap where a repo that
|
|
1827
|
-
// adopted before the package-manager-aware templates keeps a stale `npx`.
|
|
1828
|
-
// Also normalizes MCP JSON to a single preferred bin (arkgate-mcp), stripping any dual
|
|
1829
|
-
// ark-mcp + arkgate-mcp residue left by partial renames during package identity cutover.
|
|
1830
|
-
export function runMigrateCommands(root) {
|
|
1831
|
-
const runner = execRunner(root);
|
|
1832
|
-
const changed = [];
|
|
1833
|
-
for (const rel of COMMAND_GATE_TEXT_FILES) {
|
|
1834
|
-
const full = path.join(root, rel);
|
|
1835
|
-
let text;
|
|
1836
|
-
try {
|
|
1837
|
-
text = fs.readFileSync(full, 'utf8');
|
|
1838
|
-
} catch {
|
|
1839
|
-
continue;
|
|
1840
|
-
}
|
|
1841
|
-
let next = text.replace(RUNNER_BEFORE_ARK, runner);
|
|
1842
|
-
// Prefer primary product bins in command strings (aliases still work if left alone).
|
|
1843
|
-
next = next
|
|
1844
|
-
.replace(/\bark-mcp\b/g, PREFERRED_MCP_BIN)
|
|
1845
|
-
.replace(/\bark-check\b/g, PREFERRED_CHECK_BIN);
|
|
1846
|
-
// Do not blanket-replace bare `ark` — it appears in prose ("Ark check", product name).
|
|
1847
|
-
if (next !== text) {
|
|
1848
|
-
fs.writeFileSync(full, next);
|
|
1849
|
-
changed.push(rel);
|
|
1850
|
-
}
|
|
1851
|
-
}
|
|
1852
|
-
for (const rel of COMMAND_GATE_JSON_FILES) {
|
|
1853
|
-
const full = path.join(root, rel);
|
|
1854
|
-
let json;
|
|
1855
|
-
try {
|
|
1856
|
-
json = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
1857
|
-
} catch {
|
|
1858
|
-
continue;
|
|
1859
|
-
}
|
|
1860
|
-
const ark = json?.mcpServers?.ark;
|
|
1861
|
-
if (!ark) continue;
|
|
1862
|
-
const binArgs = stripMcpServerArgs(ark.args);
|
|
1863
|
-
const parts = execCommandParts(root, PREFERRED_MCP_BIN, binArgs);
|
|
1864
|
-
if (ark.command !== parts.command || JSON.stringify(ark.args) !== JSON.stringify(parts.args)) {
|
|
1865
|
-
json.mcpServers.ark = { ...ark, ...parts };
|
|
1866
|
-
fs.writeFileSync(full, `${JSON.stringify(json, null, 2)}\n`);
|
|
1867
|
-
changed.push(rel);
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
const pm = runner === 'pnpm exec' || runner.startsWith('pnpm ') ? 'pnpm' : runner;
|
|
1871
|
-
console.log(`Migrated ArkGate command runners to "${pm}" and normalized MCP bins in gate files.`);
|
|
1872
|
-
if (changed.length === 0) {
|
|
1873
|
-
console.log(' Nothing to change — runners and MCP bins already look correct.');
|
|
1874
|
-
} else {
|
|
1875
|
-
for (const rel of changed) console.log(` updated ${rel}`);
|
|
1876
|
-
console.log(
|
|
1877
|
-
` (runner + single MCP bin \`${PREFERRED_MCP_BIN}\`; customized non-command content is untouched.)`
|
|
1878
|
-
);
|
|
1879
|
-
}
|
|
1880
|
-
warnLockfileConflict(root);
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
export function runInstallAgentGates(args) {
|
|
1884
|
-
const root = args.root;
|
|
1885
|
-
if (args.migrateCommands) {
|
|
1886
|
-
runMigrateCommands(root);
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
if (args.tools != null) {
|
|
1890
|
-
const list = normalizeToolsList(args.tools);
|
|
1891
|
-
args.tools = list;
|
|
1892
|
-
const unknown = list.filter((tool) => !KNOWN_TOOLS.includes(tool));
|
|
1893
|
-
if (list.length === 0 || unknown.length > 0) {
|
|
1894
|
-
console.error(
|
|
1895
|
-
`--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
|
|
1896
|
-
(unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
|
|
1897
|
-
);
|
|
1898
|
-
process.exitCode = 2;
|
|
1899
|
-
return;
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
const pm = packageManager(root);
|
|
1903
|
-
const hasCheckScript = hasCheckArchitectureScript(root);
|
|
1904
|
-
const { tools, source } = resolveTools(args);
|
|
1905
|
-
const toolSource =
|
|
1906
|
-
source === 'explicit'
|
|
1907
|
-
? 'from --tools'
|
|
1908
|
-
: source === 'detected'
|
|
1909
|
-
? 'auto-detected from config dirs'
|
|
1910
|
-
: 'default set — no agent config dirs found';
|
|
1911
|
-
console.log(`Agent gates for: ${[...tools].sort().join(', ')} (${toolSource})`);
|
|
1912
|
-
const templates = [];
|
|
1913
|
-
// --skills-only refreshes just the canonical /ark-* skills, which are safe to
|
|
1914
|
-
// overwrite (they track the package). The gate/instruction files (AGENTS.md,
|
|
1915
|
-
// settings.json, CI workflow, rules) are the ones users customize, so a plain
|
|
1916
|
-
// `--force` clobbers them — this is the safe way to pick up new skill versions.
|
|
1917
|
-
// Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
|
|
1918
|
-
if (!args.skillsOnly) {
|
|
1919
|
-
// Bootstrap typecheck before CI template so generated workflow includes the step.
|
|
1920
|
-
const typecheckBootstrap = ensureTypecheckScript(root, { write: true });
|
|
1921
|
-
if (typecheckBootstrap.changed && !args.json) {
|
|
1922
|
-
console.log(
|
|
1923
|
-
`Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
1926
|
-
// Base gates: tool-agnostic contract + CI backstop, always written.
|
|
1927
|
-
templates.push(['AGENTS.md', agentInstructions(root)]);
|
|
1928
|
-
templates.push(['.mcp.json', mcpJson(root)]);
|
|
1929
|
-
templates.push([
|
|
1930
|
-
'.github/workflows/ark-check.yml',
|
|
1931
|
-
(() => {
|
|
1932
|
-
const deploy = detectDeployPathQuality(root);
|
|
1933
|
-
return githubWorkflow(pm, detectCiNode(root), {
|
|
1934
|
-
hasLintScript: deploy.hasLintScript,
|
|
1935
|
-
hasTypecheckScript: deploy.hasTypecheckScript,
|
|
1936
|
-
});
|
|
1937
|
-
})(),
|
|
1938
|
-
]);
|
|
1939
|
-
if (tools.has('cursor')) {
|
|
1940
|
-
templates.push(['.cursor/mcp.json', mcpJson(root)]);
|
|
1941
|
-
templates.push(['.cursor/rules/ark.mdc', cursorRule(root)]);
|
|
1942
|
-
}
|
|
1943
|
-
if (tools.has('claude')) {
|
|
1944
|
-
templates.push(['.claude/settings.json', claudeSettings(root)]);
|
|
1945
|
-
}
|
|
1946
|
-
if (tools.has('codex')) {
|
|
1947
|
-
templates.push(['docs/ark-codex-config.toml', codexTomlSnippet(root)]);
|
|
1948
|
-
}
|
|
1949
|
-
if (tools.has('grok')) {
|
|
1950
|
-
templates.push(['.grok/config.toml', grokProjectConfig(root)]);
|
|
1951
|
-
templates.push(['.grok/hooks/ark-write-gate.json', grokHooks(root)]);
|
|
1952
|
-
}
|
|
1953
|
-
// Instruction-tier hosts: one shared rule text, host-specific path.
|
|
1954
|
-
if (tools.has('windsurf')) {
|
|
1955
|
-
templates.push(['.windsurf/rules/ark.md', instructionRule(root)]);
|
|
1956
|
-
}
|
|
1957
|
-
if (tools.has('cline')) {
|
|
1958
|
-
templates.push(['.clinerules/ark.md', instructionRule(root)]);
|
|
1959
|
-
}
|
|
1960
|
-
if (tools.has('copilot')) {
|
|
1961
|
-
templates.push(['.github/copilot-instructions.md', instructionRule(root)]);
|
|
1962
|
-
}
|
|
1963
|
-
if (tools.has('kiro')) {
|
|
1964
|
-
templates.push(['.kiro/steering/ark.md', instructionRule(root)]);
|
|
1965
|
-
}
|
|
1966
|
-
if (tools.has('roo')) {
|
|
1967
|
-
templates.push(['.roo/rules/ark.md', instructionRule(root)]);
|
|
1968
|
-
}
|
|
1969
|
-
if (tools.has('continue')) {
|
|
1970
|
-
templates.push(['.continue/rules/ark.md', instructionRule(root)]);
|
|
1971
|
-
}
|
|
1972
|
-
// Gemini CLI reads GEMINI.md as its primary project context (it also reads
|
|
1973
|
-
// AGENTS.md, but GEMINI.md wins when both are present), so the rule lives there.
|
|
1974
|
-
if (tools.has('gemini')) {
|
|
1975
|
-
templates.push(['GEMINI.md', instructionRule(root)]);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
// /ark-* skills for every detected tool that supports project-level commands.
|
|
1979
|
-
// Stamp each with the shipping version so a later ark-check can flag skills
|
|
1980
|
-
// left behind by an older Ark (see detectSkillGaps) without nagging about
|
|
1981
|
-
// user edits to the body.
|
|
1982
|
-
const version = arkPackageVersion();
|
|
1983
|
-
const skills = skillTemplates().map(([name, content]) => [name, stampSkill(content, version)]);
|
|
1984
|
-
const skillPaths = new Set();
|
|
1985
|
-
for (const tool of tools) {
|
|
1986
|
-
const target = SKILL_TOOL_TARGETS[tool];
|
|
1987
|
-
if (!target) continue;
|
|
1988
|
-
for (const [name, content] of skills) {
|
|
1989
|
-
const relativePath = target(name);
|
|
1990
|
-
skillPaths.add(relativePath);
|
|
1991
|
-
templates.push([relativePath, content]);
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
const results = templates.map(([relativePath, content]) =>
|
|
1996
|
-
writeTemplate(root, relativePath, content, args.force)
|
|
1997
|
-
);
|
|
41
|
+
export {
|
|
42
|
+
readJson,
|
|
43
|
+
readPackageJson,
|
|
44
|
+
hasCheckArchitectureScript,
|
|
45
|
+
packageScriptsHaveTypecheck,
|
|
46
|
+
treeHasTypecheckScript,
|
|
47
|
+
ensureTypecheckScript,
|
|
48
|
+
REQUIRED_GATE_FILES,
|
|
49
|
+
hasArkWorkflow,
|
|
50
|
+
missingGates,
|
|
51
|
+
ensureDirForFile,
|
|
52
|
+
isArkAgentsContent,
|
|
53
|
+
isSelfHostedLibraryAgents,
|
|
54
|
+
writeTemplate,
|
|
55
|
+
} from './gate-files.mjs';
|
|
56
|
+
|
|
57
|
+
export { loadTypeScript } from './typescript-host.mjs';
|
|
1998
58
|
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
let note = '';
|
|
2015
|
-
if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
|
|
2016
|
-
const installed = installedSkillVersion(path.join(root, result.relativePath));
|
|
2017
|
-
if (installed === null || isVersionOlder(installed, version)) {
|
|
2018
|
-
staleSkipped += 1;
|
|
2019
|
-
note = ` (stale: ${installed ?? 'no stamp'} < ${version})`;
|
|
2020
|
-
} else {
|
|
2021
|
-
note = ' (up to date)';
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
|
|
2025
|
-
}
|
|
2026
|
-
if (staleSkipped > 0 && !args.skillsOnly) {
|
|
2027
|
-
console.log('');
|
|
2028
|
-
console.log(
|
|
2029
|
-
` ${staleSkipped} skill(s) are outdated but were left untouched. Refresh them with:`
|
|
2030
|
-
);
|
|
2031
|
-
console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`);
|
|
2032
|
-
}
|
|
59
|
+
export {
|
|
60
|
+
checkArgsForRoot,
|
|
61
|
+
packageManager,
|
|
62
|
+
arkCheckCommand,
|
|
63
|
+
checkArchitectureScriptSnippet,
|
|
64
|
+
layerPlacementTable,
|
|
65
|
+
agentInstructions,
|
|
66
|
+
mcpJson,
|
|
67
|
+
codexTomlSnippet,
|
|
68
|
+
instructionRule,
|
|
69
|
+
cursorRule,
|
|
70
|
+
detectNodeMajorFromWorkflows,
|
|
71
|
+
detectCiNode,
|
|
72
|
+
githubWorkflow,
|
|
73
|
+
} from './ci-and-commands.mjs';
|
|
2033
74
|
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
if (homeResults.length === 0) {
|
|
2050
|
-
for (const [name, content] of skills) {
|
|
2051
|
-
const file = path.join(dir, `${name}.md`);
|
|
2052
|
-
if (fs.existsSync(file) && !args.force) {
|
|
2053
|
-
const installed = installedSkillVersion(file);
|
|
2054
|
-
const behind = installed === null || (version && isVersionOlder(installed, version));
|
|
2055
|
-
const note = behind
|
|
2056
|
-
? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
|
|
2057
|
-
: ' (up to date)';
|
|
2058
|
-
console.log(` ${'skipped'.padEnd(7)} ${name}.md${note}`);
|
|
2059
|
-
homeResults.push({ status: 'skipped' });
|
|
2060
|
-
continue;
|
|
2061
|
-
}
|
|
2062
|
-
try {
|
|
2063
|
-
fs.writeFileSync(file, content);
|
|
2064
|
-
console.log(` ${'wrote'.padEnd(7)} ${name}.md`);
|
|
2065
|
-
homeResults.push({ status: 'written' });
|
|
2066
|
-
} catch (error) {
|
|
2067
|
-
console.log(` ${'FAILED'.padEnd(7)} ${name}.md (${error.message})`);
|
|
2068
|
-
homeResults.push({ status: 'failed' });
|
|
2069
|
-
}
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
75
|
+
export {
|
|
76
|
+
normalizeToolsList,
|
|
77
|
+
resolveTools,
|
|
78
|
+
KNOWN_TOOLS,
|
|
79
|
+
arkPackageVersion,
|
|
80
|
+
stampSkill,
|
|
81
|
+
installedSkillVersion,
|
|
82
|
+
isVersionOlder,
|
|
83
|
+
skillTemplates,
|
|
84
|
+
skillTemplateNames,
|
|
85
|
+
detectCodexHomeGap,
|
|
86
|
+
detectSkillGaps,
|
|
87
|
+
} from './skill-install.mjs';
|
|
88
|
+
|
|
89
|
+
export { detectDeployPathQuality } from './deploy-path.mjs';
|
|
2073
90
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
if (tools.has('codex') || args.codexHome) {
|
|
2081
|
-
codexMcp = wireCodexMcp(root, args.force);
|
|
2082
|
-
console.log('');
|
|
2083
|
-
console.log(`Codex MCP registration (${codexMcp.file}):`);
|
|
2084
|
-
if (codexMcp.status === 'written-multi') {
|
|
2085
|
-
console.log(
|
|
2086
|
-
` ${'wrote'.padEnd(7)} [mcp_servers.${codexMcp.table}] (multi-project — primary [mcp_servers.ark] left unchanged; --force rebinds primary)`
|
|
2087
|
-
);
|
|
2088
|
-
} else if (codexMcp.status === 'skipped') {
|
|
2089
|
-
console.log(` ${'skipped'.padEnd(7)} [mcp_servers.ark] already present (use --force to overwrite)`);
|
|
2090
|
-
} else if (codexMcp.status === 'failed') {
|
|
2091
|
-
console.log(` ${'FAILED'.padEnd(7)} [mcp_servers.ark] (${codexMcp.message})`);
|
|
2092
|
-
} else {
|
|
2093
|
-
const verb = codexMcp.status === 'updated' ? 'updated' : 'wrote';
|
|
2094
|
-
console.log(` ${verb.padEnd(7)} [mcp_servers.ark] with absolute paths`);
|
|
2095
|
-
console.log(' RESTART Codex — it does not hot-load MCP servers.');
|
|
2096
|
-
console.log(' Then expect: resource ark://manifest + tools validate_code, ark_check, ark_coverage, ark_place.');
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
91
|
+
export {
|
|
92
|
+
stripMcpServerArgs,
|
|
93
|
+
mcpArgsHaveDuplicateBins,
|
|
94
|
+
brokenMcpGateFiles,
|
|
95
|
+
collectAdoptionGaps,
|
|
96
|
+
} from './mcp-adoption.mjs';
|
|
2099
97
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
console.log('');
|
|
2107
|
-
console.log('Next steps:');
|
|
2108
|
-
console.log(' 1. Review the generated files and commit the ones that match your tools.');
|
|
2109
|
-
console.log(` 2. Run: ${arkCheckCommand(root)}`);
|
|
2110
|
-
if (!hasCheckScript) {
|
|
2111
|
-
console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
|
|
2112
|
-
console.log(` ${checkArchitectureScriptSnippet(root)}`);
|
|
2113
|
-
}
|
|
2114
|
-
if ((tools.has('codex') || args.codexHome)) {
|
|
2115
|
-
console.log('');
|
|
2116
|
-
if (codexMcp && codexMcp.status !== 'failed') {
|
|
2117
|
-
console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
|
|
2118
|
-
}
|
|
2119
|
-
if (args.codexHome) {
|
|
2120
|
-
console.log(` Codex: refreshed the /ark-* skills in ${codexPromptsDir()} — Codex loads them from there.`);
|
|
2121
|
-
} else if (skills.length > 0) {
|
|
2122
|
-
console.log(' Codex loads slash-command prompts from $CODEX_HOME/prompts (~/.codex/prompts),');
|
|
2123
|
-
console.log(' not the repo. Install the /ark-* skills there with:');
|
|
2124
|
-
console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`);
|
|
2125
|
-
console.log(' (writes to your home dir; agents driving this setup should offer to run it).');
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
warnLockfileConflict(root);
|
|
2129
|
-
}
|
|
98
|
+
export {
|
|
99
|
+
staleRunnerGateFiles,
|
|
100
|
+
warnLockfileConflict,
|
|
101
|
+
runMigrateCommands,
|
|
102
|
+
runInstallAgentGates,
|
|
103
|
+
} from './install-migrate.mjs';
|