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.
@@ -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) && !force) {
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: fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm 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 steps = AGENT_CONTRACT.steps(arkCheckCommand(root))
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
- Before editing TypeScript or JavaScript source files:
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, CI gate, and runtime path all pass.
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
- if (args.tools && args.tools.length > 0) {
522
- return { tools: new Set(args.tools), source: 'explicit' };
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 block = `[mcp_servers.ark]
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)) return true;
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 unknown = args.tools.filter((tool) => !KNOWN_TOOLS.includes(tool));
1340
- if (args.tools.length === 0 || unknown.length > 0) {
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' ? 'wrote' : result.status === 'failed' ? 'FAILED' : 'skipped';
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 === 'skipped') {
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})`);
@@ -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: 'starter shape / thin tree — expand layers as you grow',
357
- adapt: 'contract still needs to match real layout or raise coverage',
358
- enforce: 'contract governs enough code; gates can honestly hold the line',
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(ok, 'Hosts, MCP argv, core optionality, origin report, and baseline policy look complete');
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;