arkgate 2.6.0 → 2.6.1
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 +61 -0
- package/README.md +90 -67
- package/bin/ark-check.mjs +264 -51
- package/bin/ark-layer-match.mjs +29 -0
- package/bin/ark-mcp.mjs +102 -5
- package/bin/ark-shared.mjs +295 -6
- package/bin/ark.mjs +44 -34
- package/bin/lib/agent-gates.mjs +448 -15
- package/bin/lib/doctor-plan.mjs +11 -4
- package/bin/lib/presets.mjs +75 -4
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.js.map +1 -1
- 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/docs/agent-guide.md +63 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +43 -87
- package/templates/skills/ark-autopilot.md +39 -77
- package/templates/skills/ark-contract.md +43 -84
- package/templates/skills/ark-coverage.md +62 -83
- package/templates/skills/ark-fix.md +45 -90
- package/templates/skills/ark-loop.md +44 -66
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -83,9 +83,48 @@ export function ensureDirForFile(file) {
|
|
|
83
83
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* True when AGENTS.md is wholly Ark-owned (header is Ark Enforcement).
|
|
88
|
+
* Project guides that merely append an Ark section must remain non-Ark so --force
|
|
89
|
+
* never wipes them.
|
|
90
|
+
*/
|
|
91
|
+
export function isArkAgentsContent(text) {
|
|
92
|
+
if (typeof text !== 'string' || !text.trim()) return false;
|
|
93
|
+
const head = text.trimStart().slice(0, 120);
|
|
94
|
+
return /^#\s*Ark(Gate)?\s+Enforcement\b/.test(head);
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
export function writeTemplate(root, relativePath, content, force) {
|
|
87
98
|
const fullPath = path.join(root, relativePath);
|
|
88
|
-
if (fs.existsSync(fullPath)
|
|
99
|
+
if (relativePath === 'AGENTS.md' && fs.existsSync(fullPath)) {
|
|
100
|
+
let existing = '';
|
|
101
|
+
try {
|
|
102
|
+
existing = fs.readFileSync(fullPath, 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
existing = '';
|
|
105
|
+
}
|
|
106
|
+
if (existing && !isArkAgentsContent(existing)) {
|
|
107
|
+
// Never clobber a project-owned AGENTS.md — even with --force.
|
|
108
|
+
// If Ark section not present yet, merge once; subsequent runs leave it alone.
|
|
109
|
+
const hasArkSection =
|
|
110
|
+
/#\s*Ark(Gate)?\s+Enforcement\b/.test(existing) ||
|
|
111
|
+
/ark\.config\.json is authoritative/i.test(existing);
|
|
112
|
+
if (force && isArkAgentsContent(content) && !hasArkSection) {
|
|
113
|
+
try {
|
|
114
|
+
const merged = `${existing.replace(/\s*$/, '')}\n\n---\n\n${content}`;
|
|
115
|
+
ensureDirForFile(fullPath);
|
|
116
|
+
fs.writeFileSync(fullPath, merged);
|
|
117
|
+
return { relativePath, status: 'merged' };
|
|
118
|
+
} catch {
|
|
119
|
+
return { relativePath, status: 'failed' };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { relativePath, status: 'skipped-non-ark' };
|
|
123
|
+
}
|
|
124
|
+
if (!force && isArkAgentsContent(existing)) {
|
|
125
|
+
return { relativePath, status: 'skipped' };
|
|
126
|
+
}
|
|
127
|
+
} else if (fs.existsSync(fullPath) && !force) {
|
|
89
128
|
return { relativePath, status: 'skipped' };
|
|
90
129
|
}
|
|
91
130
|
try {
|
|
@@ -224,10 +263,18 @@ export function packageManager(root) {
|
|
|
224
263
|
run: `yarn ark-check ${checkArgs}`,
|
|
225
264
|
};
|
|
226
265
|
}
|
|
266
|
+
// Monorepo hosts (e.g. Next app under frontend/) often have a root package.json only for
|
|
267
|
+
// arkgate while real app deps live in frontend/package.json. Install both so CI can resolve
|
|
268
|
+
// the tree; ark-check itself only needs the root arkgate install.
|
|
269
|
+
const frontendPkg = fs.existsSync(path.join(root, 'frontend', 'package.json'));
|
|
270
|
+
const rootInstall = fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install';
|
|
271
|
+
const install = frontendPkg
|
|
272
|
+
? `${rootInstall} && (cd frontend && ${fs.existsSync(path.join(root, 'frontend', 'package-lock.json')) ? 'npm ci' : 'npm install'})`
|
|
273
|
+
: rootInstall;
|
|
227
274
|
return {
|
|
228
275
|
cache: 'npm',
|
|
229
276
|
setup: [],
|
|
230
|
-
install
|
|
277
|
+
install,
|
|
231
278
|
run: `npx ark-check ${checkArgs}`,
|
|
232
279
|
};
|
|
233
280
|
}
|
|
@@ -268,12 +315,27 @@ ${rows}`;
|
|
|
268
315
|
}
|
|
269
316
|
|
|
270
317
|
export function agentInstructions(root) {
|
|
271
|
-
const
|
|
318
|
+
const checkCmd = arkCheckCommand(root);
|
|
319
|
+
const startCmd = arkCommand(root, 'ark', 'start');
|
|
320
|
+
const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
|
|
321
|
+
const steps = AGENT_CONTRACT.steps(checkCmd)
|
|
272
322
|
.map((step, index) => `${index + 1}. ${step}`)
|
|
273
323
|
.join('\n');
|
|
274
324
|
return `# Ark Enforcement
|
|
275
325
|
|
|
276
|
-
|
|
326
|
+
## Default agent flow (if unsure, do only this)
|
|
327
|
+
|
|
328
|
+
1. If \`ark.config.json\` is missing: run \`${startCmd}\` once.
|
|
329
|
+
2. For adoption / cleanup / “make architecture sound”: run the **\`/ark-autopilot\`** skill
|
|
330
|
+
(origin report → adopt → plan → safe fixes → gates). Do **not** invent a parallel workflow
|
|
331
|
+
from the long skill list.
|
|
332
|
+
3. Status anytime: \`${doctorCmd}\` (status light + next action — not a mode picker).
|
|
333
|
+
4. After ordinary feature edits: run \`${checkCmd}\`. On violations → **\`/ark-fix\`** (or
|
|
334
|
+
\`/ark-place\` for new files, \`/ark-contract\` only if the contract itself is wrong).
|
|
335
|
+
|
|
336
|
+
Other \`/ark-*\` skills are optional escapes (adopt, coverage, runtime, …), not required steps.
|
|
337
|
+
|
|
338
|
+
## Before editing TypeScript or JavaScript source files
|
|
277
339
|
|
|
278
340
|
${steps}
|
|
279
341
|
|
|
@@ -286,7 +348,8 @@ an ungoverned location:
|
|
|
286
348
|
|
|
287
349
|
${layerPlacementTable()}
|
|
288
350
|
|
|
289
|
-
The project is only considered Ark-enforced when the write gate
|
|
351
|
+
The project is only considered Ark-enforced when the write gate and CI gate pass
|
|
352
|
+
(runtime path only if this project opted into the kernel).
|
|
290
353
|
`;
|
|
291
354
|
}
|
|
292
355
|
|
|
@@ -517,9 +580,28 @@ export function grokHooks(root) {
|
|
|
517
580
|
}, null, 2)}\n`;
|
|
518
581
|
}
|
|
519
582
|
|
|
583
|
+
/** Normalize --tools from array or comma-separated string (never character-split a string). */
|
|
584
|
+
export function normalizeToolsList(tools) {
|
|
585
|
+
if (tools == null) return [];
|
|
586
|
+
if (Array.isArray(tools)) {
|
|
587
|
+
return tools
|
|
588
|
+
.flatMap((t) => String(t).split(','))
|
|
589
|
+
.map((t) => t.trim().toLowerCase())
|
|
590
|
+
.filter(Boolean);
|
|
591
|
+
}
|
|
592
|
+
if (typeof tools === 'string') {
|
|
593
|
+
return tools
|
|
594
|
+
.split(',')
|
|
595
|
+
.map((t) => t.trim().toLowerCase())
|
|
596
|
+
.filter(Boolean);
|
|
597
|
+
}
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
|
|
520
601
|
export function resolveTools(args) {
|
|
521
|
-
|
|
522
|
-
|
|
602
|
+
const explicit = normalizeToolsList(args.tools);
|
|
603
|
+
if (explicit.length > 0) {
|
|
604
|
+
return { tools: new Set(explicit), source: 'explicit' };
|
|
523
605
|
}
|
|
524
606
|
const root = args.root;
|
|
525
607
|
const detected = new Set();
|
|
@@ -722,7 +804,8 @@ export function wireCodexMcp(root, force) {
|
|
|
722
804
|
esc(absConfig),
|
|
723
805
|
]);
|
|
724
806
|
const argsToml = args.map((value) => `"${value}"`).join(', ');
|
|
725
|
-
const
|
|
807
|
+
const makeBlock = (table) =>
|
|
808
|
+
`[mcp_servers.${table}]
|
|
726
809
|
command = "${command}"
|
|
727
810
|
args = [${argsToml}]`;
|
|
728
811
|
let existing = '';
|
|
@@ -733,11 +816,58 @@ args = [${argsToml}]`;
|
|
|
733
816
|
}
|
|
734
817
|
const tableRe = /(^|\n)\[mcp_servers\.ark\][^\n]*\n(?:(?!\[)[^\n]*\n?)*/;
|
|
735
818
|
const hasTable = tableRe.test(existing);
|
|
819
|
+
const existingRoot = hasTable ? extractCodexArkRootFromToml(existing) : null;
|
|
820
|
+
let differentProject = false;
|
|
821
|
+
try {
|
|
822
|
+
differentProject = Boolean(
|
|
823
|
+
existingRoot && path.resolve(existingRoot) !== absRoot
|
|
824
|
+
);
|
|
825
|
+
} catch {
|
|
826
|
+
differentProject = Boolean(existingRoot);
|
|
827
|
+
}
|
|
736
828
|
// Fail-closed: rewrite temp/upgrade roots and dual/wrong bins even without --force.
|
|
737
829
|
const mustRewrite = hasTable && codexArkBlockNeedsRewrite(existing, absRoot);
|
|
830
|
+
|
|
831
|
+
// Multi-project: another project's [mcp_servers.ark] is present. Without --force,
|
|
832
|
+
// add a project-scoped table so we do not steal the primary binding.
|
|
833
|
+
if (hasTable && differentProject && !force && !mustRewrite) {
|
|
834
|
+
const slug =
|
|
835
|
+
path
|
|
836
|
+
.basename(absRoot)
|
|
837
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
838
|
+
.slice(0, 48) || 'project';
|
|
839
|
+
const table = `ark_${slug}`;
|
|
840
|
+
const multiRe = new RegExp(
|
|
841
|
+
`(^|\\n)\\[mcp_servers\\.${table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\][^\\n]*\\n(?:(?!\\[)[^\\n]*\\n?)*`
|
|
842
|
+
);
|
|
843
|
+
const block = makeBlock(table);
|
|
844
|
+
let next;
|
|
845
|
+
if (multiRe.test(existing)) {
|
|
846
|
+
next = existing.replace(multiRe, (match) => `${match.startsWith('\n') ? '\n' : ''}${block}\n`);
|
|
847
|
+
} else {
|
|
848
|
+
const sep =
|
|
849
|
+
existing.length === 0
|
|
850
|
+
? ''
|
|
851
|
+
: existing.endsWith('\n\n')
|
|
852
|
+
? ''
|
|
853
|
+
: existing.endsWith('\n')
|
|
854
|
+
? '\n'
|
|
855
|
+
: '\n\n';
|
|
856
|
+
next = `${existing}${sep}${block}\n`;
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
860
|
+
fs.writeFileSync(file, next);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
return { status: 'failed', file, message: error.message };
|
|
863
|
+
}
|
|
864
|
+
return { status: 'written-multi', file, table, primaryUnchanged: true };
|
|
865
|
+
}
|
|
866
|
+
|
|
738
867
|
if (hasTable && !force && !mustRewrite) {
|
|
739
868
|
return { status: 'skipped', file };
|
|
740
869
|
}
|
|
870
|
+
const block = makeBlock('ark');
|
|
741
871
|
let next;
|
|
742
872
|
if (hasTable) {
|
|
743
873
|
next = existing.replace(tableRe, (match) => `${match.startsWith('\n') ? '\n' : ''}${block}\n`);
|
|
@@ -942,12 +1072,21 @@ export function codexArkBlockHasPreferredBin(tomlText) {
|
|
|
942
1072
|
return bins.length === 1 && bins[0] === PREFERRED_MCP_BIN;
|
|
943
1073
|
}
|
|
944
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* True when the primary [mcp_servers.ark] block is broken (temp root / dual bin)
|
|
1077
|
+
* and should be rewritten fail-closed. Different permanent project roots are NOT
|
|
1078
|
+
* "broken" — multi-project wiring uses a secondary table instead.
|
|
1079
|
+
*/
|
|
945
1080
|
export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
|
|
946
1081
|
if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
|
|
947
1082
|
const rootArg = extractCodexArkRootFromToml(tomlText);
|
|
948
1083
|
if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
|
|
1084
|
+
// Permanent different project: multi-project path handles this (do not steal primary).
|
|
949
1085
|
try {
|
|
950
|
-
if (path.resolve(rootArg) !== path.resolve(absRoot))
|
|
1086
|
+
if (path.resolve(rootArg) !== path.resolve(absRoot)) {
|
|
1087
|
+
if (!isTempOrUpgradeRoot(rootArg)) return false;
|
|
1088
|
+
return true;
|
|
1089
|
+
}
|
|
951
1090
|
} catch {
|
|
952
1091
|
return true;
|
|
953
1092
|
}
|
|
@@ -955,9 +1094,215 @@ export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
|
|
|
955
1094
|
return false;
|
|
956
1095
|
}
|
|
957
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Production deploy path quality (universal — any consumer repo).
|
|
1099
|
+
* Detects when the production build host runs ESLint / typecheck as part of
|
|
1100
|
+
* `build` (e.g. Next.js "Linting and checking validity of types") so failures
|
|
1101
|
+
* surface first on Vercel/Netlify/etc. unless CI/pre-merge runs the same checks.
|
|
1102
|
+
* Framework signals only (deps + scripts + config) — never project-specific.
|
|
1103
|
+
*
|
|
1104
|
+
* @returns {{
|
|
1105
|
+
* embedsLintInBuild: boolean,
|
|
1106
|
+
* embedsTypecheckInBuild: boolean,
|
|
1107
|
+
* engines: string[],
|
|
1108
|
+
* hasLintScript: boolean,
|
|
1109
|
+
* hasTypecheckScript: boolean,
|
|
1110
|
+
* ciRunsLint: boolean,
|
|
1111
|
+
* ciRunsTypecheck: boolean,
|
|
1112
|
+
* eslintIgnoreDuringBuilds: boolean,
|
|
1113
|
+
* }}
|
|
1114
|
+
*/
|
|
1115
|
+
export function detectDeployPathQuality(root) {
|
|
1116
|
+
const pkg = readPackageJson(root) || {};
|
|
1117
|
+
const deps = {
|
|
1118
|
+
...(pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}),
|
|
1119
|
+
...(pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {}),
|
|
1120
|
+
...(pkg.peerDependencies && typeof pkg.peerDependencies === 'object' ? pkg.peerDependencies : {}),
|
|
1121
|
+
};
|
|
1122
|
+
const scripts =
|
|
1123
|
+
pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
1124
|
+
const buildScript = typeof scripts.build === 'string' ? scripts.build : '';
|
|
1125
|
+
|
|
1126
|
+
const engines = [];
|
|
1127
|
+
// Next.js production build runs ESLint + typecheck by default (unless opted out).
|
|
1128
|
+
if (deps.next || /\bnext\s+build\b/.test(buildScript)) engines.push('next');
|
|
1129
|
+
// Nuxt 3+ can lint via modules; only flag when build clearly invokes nuxt build + eslint tooling present.
|
|
1130
|
+
if ((deps.nuxt || deps['nuxt3'] || /\bnuxt\s+build\b/.test(buildScript)) && (deps.eslint || hasEslintConfig(root))) {
|
|
1131
|
+
engines.push('nuxt');
|
|
1132
|
+
}
|
|
1133
|
+
// Create React App historically failed build on ESLint errors.
|
|
1134
|
+
if (deps['react-scripts'] || /\breact-scripts\s+build\b/.test(buildScript)) engines.push('cra');
|
|
1135
|
+
|
|
1136
|
+
const eslintIgnoreDuringBuilds = engines.includes('next') && nextIgnoresEslintDuringBuilds(root);
|
|
1137
|
+
const embedsLintInBuild = engines.length > 0 && !eslintIgnoreDuringBuilds;
|
|
1138
|
+
// Next still typechecks during build even when eslint.ignoreDuringBuilds is true.
|
|
1139
|
+
const embedsTypecheckInBuild = engines.includes('next') || engines.includes('nuxt');
|
|
1140
|
+
|
|
1141
|
+
const scriptHasLint = (s) =>
|
|
1142
|
+
Boolean(
|
|
1143
|
+
s &&
|
|
1144
|
+
((typeof s.lint === 'string' && s.lint.trim()) ||
|
|
1145
|
+
(typeof s.eslint === 'string' && s.eslint.trim()) ||
|
|
1146
|
+
(typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
|
|
1147
|
+
(typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
|
|
1148
|
+
);
|
|
1149
|
+
const scriptHasTypecheck = (s) =>
|
|
1150
|
+
Boolean(
|
|
1151
|
+
s &&
|
|
1152
|
+
((typeof s.typecheck === 'string' && s.typecheck.trim()) ||
|
|
1153
|
+
(typeof s['type-check'] === 'string' && s['type-check'].trim()) ||
|
|
1154
|
+
(typeof s['check:types'] === 'string' && s['check:types'].trim()) ||
|
|
1155
|
+
(typeof s.tsc === 'string' && /\btsc\b/.test(s.tsc)))
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
let hasLintScript = scriptHasLint(scripts);
|
|
1159
|
+
let hasTypecheckScript = scriptHasTypecheck(scripts);
|
|
1160
|
+
const packageLintScripts = [];
|
|
1161
|
+
// Monorepo: package-level scripts count (apps/web, packages/ui, …).
|
|
1162
|
+
try {
|
|
1163
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1164
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1165
|
+
const candidates = [path.join(root, entry.name)];
|
|
1166
|
+
// one more level: packages/foo
|
|
1167
|
+
try {
|
|
1168
|
+
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
1169
|
+
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
1170
|
+
candidates.push(path.join(root, entry.name, child.name));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
} catch {
|
|
1174
|
+
/* ignore */
|
|
1175
|
+
}
|
|
1176
|
+
for (const dir of candidates) {
|
|
1177
|
+
const pj = path.join(dir, 'package.json');
|
|
1178
|
+
if (!fs.existsSync(pj)) continue;
|
|
1179
|
+
try {
|
|
1180
|
+
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
1181
|
+
const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
|
|
1182
|
+
if (scriptHasLint(ns)) {
|
|
1183
|
+
hasLintScript = true;
|
|
1184
|
+
packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
|
|
1185
|
+
}
|
|
1186
|
+
if (scriptHasTypecheck(ns)) hasTypecheckScript = true;
|
|
1187
|
+
const nd = {
|
|
1188
|
+
...(nested.dependencies || {}),
|
|
1189
|
+
...(nested.devDependencies || {}),
|
|
1190
|
+
};
|
|
1191
|
+
if (nd.next && !engines.includes('next')) engines.push('next');
|
|
1192
|
+
} catch {
|
|
1193
|
+
/* ignore */
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
} catch {
|
|
1198
|
+
/* ignore */
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const ciTexts = collectCiWorkflowTexts(root);
|
|
1202
|
+
const ciJoined = ciTexts.join('\n');
|
|
1203
|
+
const ciRunsLint =
|
|
1204
|
+
ciTexts.length > 0 &&
|
|
1205
|
+
(/\bnpm\s+run\s+lint\b/i.test(ciJoined) ||
|
|
1206
|
+
/\bpnpm\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
1207
|
+
/\byarn\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
1208
|
+
/\bbun\s+run\s+lint\b/i.test(ciJoined) ||
|
|
1209
|
+
/\beslint\b/i.test(ciJoined) ||
|
|
1210
|
+
/\blint:ci\b/i.test(ciJoined) ||
|
|
1211
|
+
/\bcheck:lint\b/i.test(ciJoined) ||
|
|
1212
|
+
// package-level: working-directory + lint, or path/filter lint
|
|
1213
|
+
(packageLintScripts.length > 0 &&
|
|
1214
|
+
packageLintScripts.some((p) => ciJoined.includes(p) && /lint/i.test(ciJoined))));
|
|
1215
|
+
const ciRunsTypecheck =
|
|
1216
|
+
ciTexts.length > 0 &&
|
|
1217
|
+
(/\btypecheck\b/i.test(ciJoined) ||
|
|
1218
|
+
/\btype-check\b/i.test(ciJoined) ||
|
|
1219
|
+
/\bcheck:types\b/i.test(ciJoined) ||
|
|
1220
|
+
/\btsc\s+--noEmit\b/i.test(ciJoined));
|
|
1221
|
+
|
|
1222
|
+
return {
|
|
1223
|
+
embedsLintInBuild,
|
|
1224
|
+
embedsTypecheckInBuild,
|
|
1225
|
+
engines,
|
|
1226
|
+
hasLintScript,
|
|
1227
|
+
hasTypecheckScript,
|
|
1228
|
+
ciRunsLint,
|
|
1229
|
+
ciRunsTypecheck,
|
|
1230
|
+
eslintIgnoreDuringBuilds,
|
|
1231
|
+
hasCiWorkflows: ciTexts.length > 0,
|
|
1232
|
+
packageLintScripts,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function hasEslintConfig(root) {
|
|
1237
|
+
return [
|
|
1238
|
+
'eslint.config.mjs',
|
|
1239
|
+
'eslint.config.js',
|
|
1240
|
+
'eslint.config.cjs',
|
|
1241
|
+
'eslint.config.ts',
|
|
1242
|
+
'.eslintrc.json',
|
|
1243
|
+
'.eslintrc.cjs',
|
|
1244
|
+
'.eslintrc.js',
|
|
1245
|
+
'.eslintrc.yml',
|
|
1246
|
+
'.eslintrc.yaml',
|
|
1247
|
+
].some((f) => fs.existsSync(path.join(root, f)));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** next.config.* eslint.ignoreDuringBuilds: true → production build will not fail on ESLint. */
|
|
1251
|
+
function nextIgnoresEslintDuringBuilds(root) {
|
|
1252
|
+
const names = [
|
|
1253
|
+
'next.config.ts',
|
|
1254
|
+
'next.config.mts',
|
|
1255
|
+
'next.config.js',
|
|
1256
|
+
'next.config.mjs',
|
|
1257
|
+
'next.config.cjs',
|
|
1258
|
+
];
|
|
1259
|
+
for (const name of names) {
|
|
1260
|
+
const file = path.join(root, name);
|
|
1261
|
+
if (!fs.existsSync(file)) continue;
|
|
1262
|
+
try {
|
|
1263
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
1264
|
+
// Common patterns: ignoreDuringBuilds: true | ignoreDuringBuilds: true,
|
|
1265
|
+
if (/ignoreDuringBuilds\s*:\s*true/.test(text)) return true;
|
|
1266
|
+
} catch {
|
|
1267
|
+
/* ignore */
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function collectCiWorkflowTexts(root) {
|
|
1274
|
+
const texts = [];
|
|
1275
|
+
const pushFile = (rel) => {
|
|
1276
|
+
try {
|
|
1277
|
+
const full = path.join(root, rel);
|
|
1278
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
|
1279
|
+
texts.push(fs.readFileSync(full, 'utf8'));
|
|
1280
|
+
}
|
|
1281
|
+
} catch {
|
|
1282
|
+
/* ignore */
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
pushFile('.gitlab-ci.yml');
|
|
1286
|
+
pushFile('bitbucket-pipelines.yml');
|
|
1287
|
+
pushFile('azure-pipelines.yml');
|
|
1288
|
+
pushFile('.circleci/config.yml');
|
|
1289
|
+
const wfDir = path.join(root, '.github', 'workflows');
|
|
1290
|
+
try {
|
|
1291
|
+
if (fs.existsSync(wfDir)) {
|
|
1292
|
+
for (const f of fs.readdirSync(wfDir)) {
|
|
1293
|
+
if (!/\.ya?ml$/i.test(f)) continue;
|
|
1294
|
+
pushFile(path.join('.github', 'workflows', f));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
} catch {
|
|
1298
|
+
/* ignore */
|
|
1299
|
+
}
|
|
1300
|
+
return texts;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
958
1303
|
/**
|
|
959
1304
|
* Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
|
|
960
|
-
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null }}
|
|
1305
|
+
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null }}
|
|
961
1306
|
*/
|
|
962
1307
|
export function collectAdoptionGaps(root, config, coverage) {
|
|
963
1308
|
const gaps = [];
|
|
@@ -1202,6 +1547,79 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
1202
1547
|
}
|
|
1203
1548
|
}
|
|
1204
1549
|
|
|
1550
|
+
// --- Empty scope: contract matches no TS/JS ---
|
|
1551
|
+
if (!isProducer && (coverage?.governed?.totalFiles ?? coverage?.totalFiles) === 0) {
|
|
1552
|
+
gaps.push({
|
|
1553
|
+
id: 'empty-scope',
|
|
1554
|
+
severity: 'warn',
|
|
1555
|
+
message:
|
|
1556
|
+
'Empty scope: include paths match 0 TypeScript/JS files — checks are not governing this tree',
|
|
1557
|
+
fix: `${arkCommand(root, 'ark-check', '--suggest-include')} then ${arkCommand(root, 'ark-check', '--adopt-contract --write')}`,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// --- Deploy-path quality (ESLint/types that production build hosts run) ---
|
|
1562
|
+
// Universal: any Next/CRA/Nuxt (etc.) consumer. Not architecture — still adoption.
|
|
1563
|
+
// Skip pure library producer (this monorepo) to avoid self-noise.
|
|
1564
|
+
let deployPath = null;
|
|
1565
|
+
if (!isProducer) {
|
|
1566
|
+
deployPath = detectDeployPathQuality(root);
|
|
1567
|
+
const eng =
|
|
1568
|
+
deployPath.engines.length > 0 ? deployPath.engines.join('/') : 'production';
|
|
1569
|
+
if (deployPath.embedsLintInBuild && !deployPath.hasLintScript) {
|
|
1570
|
+
gaps.push({
|
|
1571
|
+
id: 'deploy-path-lint-script-missing',
|
|
1572
|
+
severity: 'warn',
|
|
1573
|
+
message: `${eng} production build runs ESLint — no package.json lint script, so failures often surface first on the deploy host`,
|
|
1574
|
+
fix: 'Add a package.json "lint" script (e.g. eslint .) matching production ESLint config; run it in CI and before merge',
|
|
1575
|
+
});
|
|
1576
|
+
} else if (
|
|
1577
|
+
deployPath.embedsLintInBuild &&
|
|
1578
|
+
deployPath.hasLintScript &&
|
|
1579
|
+
deployPath.hasCiWorkflows &&
|
|
1580
|
+
!deployPath.ciRunsLint
|
|
1581
|
+
) {
|
|
1582
|
+
gaps.push({
|
|
1583
|
+
id: 'deploy-path-lint-not-in-ci',
|
|
1584
|
+
severity: 'warn',
|
|
1585
|
+
message: `${eng} production build runs ESLint — CI workflows exist but do not run lint, so deploy hosts may be the first fail`,
|
|
1586
|
+
fix: 'Add a CI step that runs your package.json lint script (npm run lint / pnpm lint / yarn lint) and require it before deploy',
|
|
1587
|
+
});
|
|
1588
|
+
} else if (
|
|
1589
|
+
deployPath.embedsLintInBuild &&
|
|
1590
|
+
deployPath.hasLintScript &&
|
|
1591
|
+
!deployPath.hasCiWorkflows
|
|
1592
|
+
) {
|
|
1593
|
+
gaps.push({
|
|
1594
|
+
id: 'deploy-path-lint-no-ci',
|
|
1595
|
+
severity: 'info',
|
|
1596
|
+
message: `${eng} production build runs ESLint — no CI workflows detected; push-to-host builds may be the first lint fail`,
|
|
1597
|
+
fix: 'Add CI (or a pre-push hook) that runs lint before the deploy host builds; keep branch protection required when using GitHub',
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (deployPath.embedsTypecheckInBuild && !deployPath.hasTypecheckScript) {
|
|
1602
|
+
gaps.push({
|
|
1603
|
+
id: 'deploy-path-typecheck-script-missing',
|
|
1604
|
+
severity: 'info',
|
|
1605
|
+
message: `${eng} production build typechecks — no package.json typecheck script for local/CI parity`,
|
|
1606
|
+
fix: 'Add "typecheck": "tsc --noEmit" (or framework equivalent) and run it in CI alongside lint',
|
|
1607
|
+
});
|
|
1608
|
+
} else if (
|
|
1609
|
+
deployPath.embedsTypecheckInBuild &&
|
|
1610
|
+
deployPath.hasTypecheckScript &&
|
|
1611
|
+
deployPath.hasCiWorkflows &&
|
|
1612
|
+
!deployPath.ciRunsTypecheck
|
|
1613
|
+
) {
|
|
1614
|
+
gaps.push({
|
|
1615
|
+
id: 'deploy-path-typecheck-not-in-ci',
|
|
1616
|
+
severity: 'info',
|
|
1617
|
+
message: `${eng} production build typechecks — CI does not run typecheck; type errors may appear first on the deploy host`,
|
|
1618
|
+
fix: 'Add a CI step for npm run typecheck (or your typecheck script) and require it before deploy',
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1205
1623
|
return {
|
|
1206
1624
|
gaps,
|
|
1207
1625
|
hosts,
|
|
@@ -1211,6 +1629,7 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
1211
1629
|
originReport,
|
|
1212
1630
|
baseline,
|
|
1213
1631
|
layerBalance,
|
|
1632
|
+
deployPath,
|
|
1214
1633
|
};
|
|
1215
1634
|
}
|
|
1216
1635
|
|
|
@@ -1335,9 +1754,11 @@ export function runInstallAgentGates(args) {
|
|
|
1335
1754
|
runMigrateCommands(root);
|
|
1336
1755
|
return;
|
|
1337
1756
|
}
|
|
1338
|
-
if (args.tools) {
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1757
|
+
if (args.tools != null) {
|
|
1758
|
+
const list = normalizeToolsList(args.tools);
|
|
1759
|
+
args.tools = list;
|
|
1760
|
+
const unknown = list.filter((tool) => !KNOWN_TOOLS.includes(tool));
|
|
1761
|
+
if (list.length === 0 || unknown.length > 0) {
|
|
1341
1762
|
console.error(
|
|
1342
1763
|
`--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
|
|
1343
1764
|
(unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
|
|
@@ -1433,7 +1854,15 @@ export function runInstallAgentGates(args) {
|
|
|
1433
1854
|
let staleSkipped = 0;
|
|
1434
1855
|
for (const result of results) {
|
|
1435
1856
|
const marker =
|
|
1436
|
-
result.status === 'written'
|
|
1857
|
+
result.status === 'written'
|
|
1858
|
+
? 'wrote'
|
|
1859
|
+
: result.status === 'merged'
|
|
1860
|
+
? 'merged'
|
|
1861
|
+
: result.status === 'skipped-non-ark'
|
|
1862
|
+
? 'kept'
|
|
1863
|
+
: result.status === 'failed'
|
|
1864
|
+
? 'FAILED'
|
|
1865
|
+
: 'skipped';
|
|
1437
1866
|
// A skipped skill reads as "you're fine" — but it may be a version behind.
|
|
1438
1867
|
// Say which, so the user isn't left guessing (and knows the safe refresh cmd).
|
|
1439
1868
|
let note = '';
|
|
@@ -1506,7 +1935,11 @@ export function runInstallAgentGates(args) {
|
|
|
1506
1935
|
codexMcp = wireCodexMcp(root, args.force);
|
|
1507
1936
|
console.log('');
|
|
1508
1937
|
console.log(`Codex MCP registration (${codexMcp.file}):`);
|
|
1509
|
-
if (codexMcp.status === '
|
|
1938
|
+
if (codexMcp.status === 'written-multi') {
|
|
1939
|
+
console.log(
|
|
1940
|
+
` ${'wrote'.padEnd(7)} [mcp_servers.${codexMcp.table}] (multi-project — primary [mcp_servers.ark] left unchanged; --force rebinds primary)`
|
|
1941
|
+
);
|
|
1942
|
+
} else if (codexMcp.status === 'skipped') {
|
|
1510
1943
|
console.log(` ${'skipped'.padEnd(7)} [mcp_servers.ark] already present (use --force to overwrite)`);
|
|
1511
1944
|
} else if (codexMcp.status === 'failed') {
|
|
1512
1945
|
console.log(` ${'FAILED'.padEnd(7)} [mcp_servers.ark] (${codexMcp.message})`);
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -351,11 +351,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
351
351
|
});
|
|
352
352
|
console.log('');
|
|
353
353
|
console.log(color.bold('Operating mode'));
|
|
354
|
+
// Modes are detected states, not user-picked settings. Plain-language "what you do next".
|
|
354
355
|
const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
|
|
355
356
|
const modeHelp = {
|
|
356
|
-
suggest:
|
|
357
|
-
|
|
358
|
-
|
|
357
|
+
suggest:
|
|
358
|
+
'Setup — Ark proposes a starting architecture shape. You do not pick this mode; it means the tree is thin or new. Next: accept the shape (ark start / ark init) and add real layers as you grow.',
|
|
359
|
+
adapt:
|
|
360
|
+
'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
|
|
361
|
+
enforce:
|
|
362
|
+
'Guard — contract governs enough real code and edges are clean enough for gates to protect you. You do not pick this mode; you arrived here. Next: keep CI/write gates on; only NEW violations should fail.',
|
|
359
363
|
};
|
|
360
364
|
line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
|
|
361
365
|
if (emptyScope) {
|
|
@@ -466,7 +470,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
466
470
|
console.log('');
|
|
467
471
|
console.log(color.bold('Adoption (separate from fitness score)'));
|
|
468
472
|
if (adoption.gaps.length === 0 && !adoption.layerBalance) {
|
|
469
|
-
line(
|
|
473
|
+
line(
|
|
474
|
+
ok,
|
|
475
|
+
'Hosts, MCP argv, core optionality, origin report, baseline policy, and deploy-path lint/types look complete'
|
|
476
|
+
);
|
|
470
477
|
} else {
|
|
471
478
|
for (const gap of adoption.gaps) {
|
|
472
479
|
const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
|