arkgate 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +26 -16
  3. package/SECURITY.md +9 -8
  4. package/bin/ark-check.mjs +599 -53
  5. package/bin/ark-shared.mjs +53 -0
  6. package/bin/ark.mjs +20 -5
  7. package/dist/index.cjs +1 -1
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/nestjs/index.cjs +1 -1
  14. package/dist/nestjs/index.cjs.map +1 -1
  15. package/dist/nestjs/index.js +1 -1
  16. package/dist/nestjs/index.js.map +1 -1
  17. package/docs/agent-guide.md +11 -5
  18. package/docs/ai-gates.md +11 -7
  19. package/docs/brownfield-adoption.md +14 -13
  20. package/docs/demos/03-copilot-autopilot.md +5 -3
  21. package/docs/enthusiast/README.md +4 -3
  22. package/docs/enthusiast/how-to-agent-gates.md +14 -6
  23. package/docs/enthusiast/reference-commands.md +23 -8
  24. package/docs/migrate-from-ark-runtime-kernel.md +18 -0
  25. package/docs/typescript-support.md +142 -0
  26. package/package.json +12 -3
  27. package/server.json +2 -2
  28. package/templates/skills/ark-autopilot.md +6 -4
  29. package/templates/skills/ark-explain.md +7 -2
  30. package/templates/skills/ark-fix.md +16 -12
  31. package/templates/skills/ark-loop.md +14 -4
  32. package/templates/skills/ark-upgrade.md +26 -1
  33. package/templates/tests/ark-adoption-gaps.test.ts +68 -0
  34. package/tests/fixtures/ts-consumer/ark.config.json +11 -0
  35. package/tests/fixtures/ts-consumer/src/app/types.ts +1 -0
  36. package/tests/fixtures/ts-consumer/src/domain/bad.ts +1 -0
  37. package/tests/fixtures/ts-consumer/src/domain/ok.ts +1 -0
  38. package/tests/fixtures/ts-consumer/src/domain/user.ts +1 -0
  39. package/tests/fixtures/ts-consumer/tsconfig.json +16 -0
package/bin/ark-check.mjs CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  resolveIntentLayer,
38
38
  resolveOperatingMode,
39
39
  shouldShowNewHereNudge,
40
+ usableTypescript,
41
+ typescriptUsabilityHint,
40
42
  } from './ark-shared.mjs';
41
43
 
42
44
  function parseArgs(argv) {
@@ -1051,49 +1053,89 @@ function writeTemplate(root, relativePath, content, force) {
1051
1053
  }
1052
1054
 
1053
1055
  /**
1054
- * Normalize a required/imported TypeScript module. TS 5.x exposes `sys` on the root
1055
- * export; TS 7+ (and some ESM interop shapes) may not those are unusable for ark-check's
1056
- * module resolution host, so we reject them and fall through to Ark's own TypeScript.
1057
- */
1058
- function usableTypescript(mod) {
1059
- if (!mod) return null;
1060
- const ts = mod.default && mod.sys == null ? mod.default : mod;
1061
- if (ts && typeof ts === 'object' && ts.sys && typeof ts.sys.fileExists === 'function') {
1062
- return ts;
1063
- }
1064
- return null;
1065
- }
1066
-
1067
- /**
1068
- * Load a TypeScript module with a working `sys`. Prefer the project's install when it is
1069
- * API-compatible; otherwise Ark's dependency (or a bare import). Returns null only when
1070
- * nothing usable is available.
1056
+ * Load a TypeScript module with a working JS API host (`sys` + AST + resolve).
1057
+ * Prefer the project's install when API-compatible (TS 5/6 + any TS 7 that still
1058
+ * exposes the classic JS host). TypeScript 7.0.x main entry is version-only
1059
+ * (`{ version, versionMajorMinor }`); programmatic APIs live under
1060
+ * `typescript/unstable/*` and are not yet the gate's host — we fall through to
1061
+ * ArkGate's own `typescript` dependency (JS-API 5.x) or a bare import.
1062
+ * Returns `{ ts, source, version, fallbackReason? }` or null.
1071
1063
  */
1072
1064
  async function loadTypeScript(root) {
1065
+ const { createRequire } = await import('node:module');
1073
1066
  const loaders = [];
1074
1067
  try {
1075
- const { createRequire } = await import('node:module');
1076
1068
  const req = createRequire(path.join(root, 'package.json'));
1077
- loaders.push(() => req('typescript'));
1069
+ loaders.push({
1070
+ label: 'project',
1071
+ load: () => req('typescript'),
1072
+ resolvePath: () => {
1073
+ try {
1074
+ return req.resolve('typescript');
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ },
1079
+ });
1078
1080
  } catch {
1079
1081
  /* project has no package.json resolvable tree */
1080
1082
  }
1083
+ // Nested under arkgate (production dependency) — must work when project has only TS7.
1081
1084
  try {
1082
- const { createRequire } = await import('node:module');
1083
1085
  const req = createRequire(__arkCheckCli);
1084
- loaders.push(() => req('typescript'));
1086
+ loaders.push({
1087
+ label: 'arkgate',
1088
+ load: () => req('typescript'),
1089
+ resolvePath: () => {
1090
+ try {
1091
+ return req.resolve('typescript');
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ },
1096
+ });
1085
1097
  } catch {
1086
1098
  /* ark install tree unavailable */
1087
1099
  }
1088
- loaders.push(async () => {
1089
- const m = await import('typescript');
1090
- return m;
1100
+ loaders.push({
1101
+ label: 'import',
1102
+ load: async () => {
1103
+ const m = await import('typescript');
1104
+ return m;
1105
+ },
1106
+ resolvePath: () => null,
1091
1107
  });
1092
1108
 
1093
- for (const load of loaders) {
1109
+ let projectRejected = null;
1110
+ const triedPaths = new Set();
1111
+ for (const { label, load, resolvePath } of loaders) {
1094
1112
  try {
1095
- const ts = usableTypescript(await load());
1096
- if (ts) return ts;
1113
+ const resolved = typeof resolvePath === 'function' ? resolvePath() : null;
1114
+ if (resolved && triedPaths.has(resolved)) {
1115
+ // Same physical package already rejected (e.g. project === hoisted arkgate path).
1116
+ continue;
1117
+ }
1118
+ if (resolved) triedPaths.add(resolved);
1119
+
1120
+ const mod = await load();
1121
+ const ts = usableTypescript(mod);
1122
+ if (ts) {
1123
+ const version =
1124
+ typeof ts.version === 'string'
1125
+ ? ts.version
1126
+ : typeof mod?.version === 'string'
1127
+ ? mod.version
1128
+ : undefined;
1129
+ return {
1130
+ ts,
1131
+ source: label,
1132
+ version,
1133
+ ...(projectRejected ? { fallbackReason: projectRejected } : {}),
1134
+ };
1135
+ }
1136
+ if (label === 'project' && mod) {
1137
+ 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.`;
1138
+ }
1097
1139
  } catch {
1098
1140
  /* try next loader */
1099
1141
  }
@@ -1202,7 +1244,8 @@ function mcpJson(root) {
1202
1244
  mcpServers: {
1203
1245
  ark: {
1204
1246
  type: 'stdio',
1205
- ...execCommandParts(root, 'ark-mcp', ['--root', '.', '--config', 'ark.config.json']),
1247
+ // Prefer arkgate-mcp; ark-mcp alias still works for one major.
1248
+ ...execCommandParts(root, PREFERRED_MCP_BIN, ['--root', '.', '--config', 'ark.config.json']),
1206
1249
  },
1207
1250
  },
1208
1251
  }, null, 2)}\n`;
@@ -1213,7 +1256,7 @@ function mcpJson(root) {
1213
1256
  // it flags the two gotchas of hand-editing the global config: absolute paths (config.toml is
1214
1257
  // loaded without the project as cwd) and the required restart.
1215
1258
  function codexTomlSnippet(root) {
1216
- const { command, args } = execCommandParts(root, 'ark-mcp', [
1259
+ const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
1217
1260
  '--root',
1218
1261
  '/absolute/path/to/project',
1219
1262
  '--config',
@@ -1347,7 +1390,7 @@ function claudeSettings(root) {
1347
1390
  hooks: [
1348
1391
  {
1349
1392
  type: 'command',
1350
- command: `${runner} ark-mcp --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1393
+ command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1351
1394
  },
1352
1395
  ],
1353
1396
  },
@@ -1358,7 +1401,7 @@ function claudeSettings(root) {
1358
1401
  hooks: [
1359
1402
  {
1360
1403
  type: 'command',
1361
- command: `${runner} ark-mcp --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1404
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1362
1405
  },
1363
1406
  ],
1364
1407
  },
@@ -1370,7 +1413,7 @@ function claudeSettings(root) {
1370
1413
  // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
1371
1414
  // Codex's global config.toml, Grok loads .grok/config.toml from the project).
1372
1415
  function grokProjectConfig(root) {
1373
- const { command, args } = execCommandParts(root, 'ark-mcp', [
1416
+ const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
1374
1417
  '--root',
1375
1418
  '.',
1376
1419
  '--config',
@@ -1385,7 +1428,7 @@ args = [${argsToml}]
1385
1428
  `;
1386
1429
  }
1387
1430
 
1388
- // Grok Build hooks: same ark-mcp contracts as Claude. Grok sets CLAUDE_PROJECT_DIR as
1431
+ // Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets CLAUDE_PROJECT_DIR as
1389
1432
  // an alias for GROK_WORKSPACE_ROOT. Matcher keeps Claude names (Write|Edit|MultiEdit)
1390
1433
  // and Grok natives (write|search_replace) — Grok aliases both directions.
1391
1434
  function grokHooks(root) {
@@ -1398,7 +1441,7 @@ function grokHooks(root) {
1398
1441
  {
1399
1442
  type: 'command',
1400
1443
  timeout: 30,
1401
- command: `${runner} ark-mcp --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1444
+ command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1402
1445
  },
1403
1446
  ],
1404
1447
  },
@@ -1410,7 +1453,7 @@ function grokHooks(root) {
1410
1453
  {
1411
1454
  type: 'command',
1412
1455
  timeout: 30,
1413
- command: `${runner} ark-mcp --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1456
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1414
1457
  },
1415
1458
  ],
1416
1459
  },
@@ -1622,7 +1665,9 @@ function wireCodexMcp(root, force) {
1622
1665
  const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1623
1666
  const absRoot = path.resolve(root);
1624
1667
  const absConfig = path.join(absRoot, 'ark.config.json');
1625
- const { command, args } = execCommandParts(root, 'ark-mcp', [
1668
+ // Preferred product bin; absolute --root so Codex (cwd project) resolves correctly.
1669
+ const preferredBin = 'arkgate-mcp';
1670
+ const { command, args } = execCommandParts(root, preferredBin, [
1626
1671
  '--root',
1627
1672
  esc(absRoot),
1628
1673
  '--config',
@@ -1640,7 +1685,9 @@ args = [${argsToml}]`;
1640
1685
  }
1641
1686
  const tableRe = /(^|\n)\[mcp_servers\.ark\][^\n]*\n(?:(?!\[)[^\n]*\n?)*/;
1642
1687
  const hasTable = tableRe.test(existing);
1643
- if (hasTable && !force) {
1688
+ // Fail-closed: rewrite temp/upgrade roots and dual/wrong bins even without --force.
1689
+ const mustRewrite = hasTable && codexArkBlockNeedsRewrite(existing, absRoot);
1690
+ if (hasTable && !force && !mustRewrite) {
1644
1691
  return { status: 'skipped', file };
1645
1692
  }
1646
1693
  let next;
@@ -1656,7 +1703,11 @@ args = [${argsToml}]`;
1656
1703
  } catch (error) {
1657
1704
  return { status: 'failed', file, message: error.message };
1658
1705
  }
1659
- return { status: hasTable ? 'updated' : 'written', file };
1706
+ return {
1707
+ status: hasTable ? 'updated' : 'written',
1708
+ file,
1709
+ ...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
1710
+ };
1660
1711
  }
1661
1712
 
1662
1713
  // Detects stale/missing /ark-* skills in the Codex home prompts dir. Only nags
@@ -1735,10 +1786,385 @@ const COMMAND_GATE_TEXT_FILES = [
1735
1786
  '.grok/hooks/ark-write-gate.json', '.grok/config.toml',
1736
1787
  ];
1737
1788
  const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
1789
+ // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
1790
+ // before re-emitting a single preferred bin — otherwise a partial rename leaves
1791
+ // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
1792
+ const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
1793
+ const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
1794
+ const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
1795
+ const PREFERRED_MCP_BIN = 'arkgate-mcp';
1796
+ const PREFERRED_CHECK_BIN = 'arkgate-check';
1797
+ const PREFERRED_CLI_BIN = 'arkgate';
1798
+ // Runner argv noise that is not a bin argument (pnpm exec form).
1799
+ const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
1738
1800
  // The runner token immediately before an ark command in a text command string.
1739
1801
  // Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
1802
+ // Longer bin names first so `arkgate-check` is not partially matched as `ark`.
1740
1803
  const RUNNER_BEFORE_ARK =
1741
- /\b(?:npx|pnpm --config\.verify-deps-before-run=false exec|pnpm exec|yarn)(?= (?:ark-check|ark-mcp|ark)\b)/g;
1804
+ /\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;
1805
+
1806
+ /** Keep only MCP server flags from existing args (drop runner tokens + any ark* bin names). */
1807
+ function stripMcpServerArgs(args) {
1808
+ if (!Array.isArray(args) || args.length === 0) {
1809
+ return ['--root', '.', '--config', 'ark.config.json'];
1810
+ }
1811
+ const kept = args.filter(
1812
+ (entry) =>
1813
+ typeof entry === 'string' &&
1814
+ !MCP_RUNNER_ARGV.has(entry) &&
1815
+ !ARK_MCP_BINS.has(entry) &&
1816
+ !ARK_CHECK_BINS.has(entry) &&
1817
+ !ARK_CLI_BINS.has(entry)
1818
+ );
1819
+ return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
1820
+ }
1821
+
1822
+ /** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
1823
+ function mcpArgsHaveDuplicateBins(args) {
1824
+ if (!Array.isArray(args)) return false;
1825
+ const hits = args.filter((entry) => ARK_MCP_BINS.has(entry));
1826
+ return hits.length > 1 || (hits.length === 1 && args.indexOf(hits[0]) !== args.lastIndexOf(hits[0]));
1827
+ }
1828
+
1829
+ function brokenMcpGateFiles(root) {
1830
+ const bad = [];
1831
+ for (const rel of COMMAND_GATE_JSON_FILES) {
1832
+ let json;
1833
+ try {
1834
+ json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
1835
+ } catch {
1836
+ continue;
1837
+ }
1838
+ const ark = json?.mcpServers?.ark;
1839
+ if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
1840
+ }
1841
+ return bad;
1842
+ }
1843
+
1844
+ /** Core layers whose optionality matters once they match files (presets share these names). */
1845
+ const CORE_LAYER_NAMES = new Set([
1846
+ 'DomainModel',
1847
+ 'ApplicationOrchestration',
1848
+ 'PresentationAdapters',
1849
+ 'PersistenceAdapters',
1850
+ ]);
1851
+
1852
+ /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
1853
+ function isTempOrUpgradeRoot(p) {
1854
+ if (!p || typeof p !== 'string') return false;
1855
+ const n = p.replace(/\\/g, '/');
1856
+ return (
1857
+ /\/var\/folders\//i.test(n) ||
1858
+ /\/tmp\//i.test(n) ||
1859
+ /\/Temp\//i.test(n) ||
1860
+ /ark-upgrade/i.test(n) ||
1861
+ /\/T\/(?:ark-|grok-)/i.test(n) ||
1862
+ /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
1863
+ );
1864
+ }
1865
+
1866
+ /** Extract --root value from Codex [mcp_servers.ark] args array text. */
1867
+ function extractCodexArkRootFromToml(tomlText) {
1868
+ if (!tomlText || typeof tomlText !== 'string') return null;
1869
+ const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1870
+ if (start < 0) return null;
1871
+ const rest = tomlText.slice(start);
1872
+ const endMatch = rest.slice(1).search(/\n\[/);
1873
+ const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1874
+ // args = ["arkgate-mcp", "--root", "/abs/path", ...]
1875
+ const rootIdx = block.search(/"--root"\s*,\s*"/);
1876
+ if (rootIdx < 0) {
1877
+ // alternate: --root as adjacent string after any bin
1878
+ const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
1879
+ return m ? m[1] : null;
1880
+ }
1881
+ const m = block.slice(rootIdx).match(/"--root"\s*,\s*"([^"]+)"/);
1882
+ return m ? m[1] : null;
1883
+ }
1884
+
1885
+ function codexArkBlockHasPreferredBin(tomlText) {
1886
+ if (!tomlText) return false;
1887
+ const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1888
+ if (start < 0) return false;
1889
+ const rest = tomlText.slice(start);
1890
+ const endMatch = rest.slice(1).search(/\n\[/);
1891
+ const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1892
+ const bins = [...block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
1893
+ if (bins.length > 1) return false;
1894
+ return bins.length === 1 && bins[0] === PREFERRED_MCP_BIN;
1895
+ }
1896
+
1897
+ function codexArkBlockNeedsRewrite(tomlText, absRoot) {
1898
+ if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
1899
+ const rootArg = extractCodexArkRootFromToml(tomlText);
1900
+ if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
1901
+ try {
1902
+ if (path.resolve(rootArg) !== path.resolve(absRoot)) return true;
1903
+ } catch {
1904
+ return true;
1905
+ }
1906
+ if (!codexArkBlockHasPreferredBin(tomlText)) return true;
1907
+ return false;
1908
+ }
1909
+
1910
+ /**
1911
+ * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
1912
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null }}
1913
+ */
1914
+ function collectAdoptionGaps(root, config, coverage) {
1915
+ const gaps = [];
1916
+ const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
1917
+ const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
1918
+
1919
+ // --- Repo MCP dual-bin ---
1920
+ const dualMcp = brokenMcpGateFiles(root);
1921
+ const mcp = {
1922
+ dualBinFiles: dualMcp,
1923
+ ok: dualMcp.length === 0,
1924
+ };
1925
+ if (dualMcp.length > 0) {
1926
+ gaps.push({
1927
+ id: 'mcp-dual-bin',
1928
+ severity: 'warn',
1929
+ message: `Broken MCP argv in ${dualMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp`,
1930
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands'),
1931
+ });
1932
+ }
1933
+
1934
+ // --- Host completeness (only when project already adopted gates) ---
1935
+ const hosts = [];
1936
+ if (adopted && !isProducer) {
1937
+ const skillNames = skillTemplateNames();
1938
+ const hostChecks = [
1939
+ {
1940
+ host: 'grok',
1941
+ dir: '.grok',
1942
+ skill: (n) => path.join(root, '.grok', 'skills', n, 'SKILL.md'),
1943
+ extras: [
1944
+ ['.grok/hooks/ark-write-gate.json', 'write-gate hook'],
1945
+ ['.grok/config.toml', 'project MCP config'],
1946
+ ],
1947
+ toolsFlag: 'grok',
1948
+ },
1949
+ {
1950
+ host: 'claude',
1951
+ dir: '.claude',
1952
+ skill: (n) => path.join(root, '.claude', 'skills', n, 'SKILL.md'),
1953
+ extras: [['.claude/settings.json', 'settings/hooks']],
1954
+ toolsFlag: 'claude',
1955
+ },
1956
+ {
1957
+ host: 'cursor',
1958
+ dir: '.cursor',
1959
+ skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
1960
+ extras: [['.cursor/mcp.json', 'MCP config']],
1961
+ toolsFlag: 'cursor',
1962
+ },
1963
+ ];
1964
+ for (const h of hostChecks) {
1965
+ if (!fs.existsSync(path.join(root, h.dir))) continue;
1966
+ const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
1967
+ const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
1968
+ const complete = missingSkills.length === 0 && missingExtras.length === 0;
1969
+ hosts.push({
1970
+ host: h.host,
1971
+ present: true,
1972
+ complete,
1973
+ missingSkills: missingSkills.length,
1974
+ missingExtras: missingExtras.map(([, label]) => label),
1975
+ });
1976
+ if (!complete) {
1977
+ gaps.push({
1978
+ id: `host-${h.host}-incomplete`,
1979
+ severity: 'warn',
1980
+ message: `${h.host} dir present but incomplete (${missingSkills.length} skill(s) missing${
1981
+ missingExtras.length ? `; missing ${missingExtras.map(([, l]) => l).join(', ')}` : ''
1982
+ })`,
1983
+ fix: arkCommand(
1984
+ root,
1985
+ 'ark-check',
1986
+ `--install-agent-gates --tools ${h.toolsFlag} --force`
1987
+ ),
1988
+ });
1989
+ }
1990
+ }
1991
+ }
1992
+
1993
+ // --- Codex home MCP (temp path / wrong root / dual bin) ---
1994
+ let codexHome = null;
1995
+ if (adopted && !isProducer) {
1996
+ const codexFile = codexConfigPath();
1997
+ let toml = '';
1998
+ try {
1999
+ if (fs.existsSync(codexFile)) toml = fs.readFileSync(codexFile, 'utf8');
2000
+ } catch {
2001
+ toml = '';
2002
+ }
2003
+ if (toml.includes('[mcp_servers.ark]')) {
2004
+ const rootArg = extractCodexArkRootFromToml(toml);
2005
+ const absRoot = path.resolve(root);
2006
+ const temp = isTempOrUpgradeRoot(rootArg);
2007
+ let wrongRoot = false;
2008
+ try {
2009
+ wrongRoot = rootArg ? path.resolve(rootArg) !== absRoot : true;
2010
+ } catch {
2011
+ wrongRoot = true;
2012
+ }
2013
+ const preferredBin = codexArkBlockHasPreferredBin(toml);
2014
+ const needsRewrite = codexArkBlockNeedsRewrite(toml, absRoot);
2015
+ codexHome = {
2016
+ file: codexFile,
2017
+ root: rootArg,
2018
+ tempPath: temp,
2019
+ wrongRoot,
2020
+ preferredBin,
2021
+ needsRewrite,
2022
+ };
2023
+ if (needsRewrite) {
2024
+ gaps.push({
2025
+ id: 'codex-home-mcp',
2026
+ severity: temp || wrongRoot ? 'warn' : 'info',
2027
+ message: temp
2028
+ ? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
2029
+ : wrongRoot
2030
+ ? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${absRoot})`
2031
+ : `Codex home MCP should use a single ${PREFERRED_MCP_BIN} bin with absolute project paths`,
2032
+ fix: arkCommand(
2033
+ root,
2034
+ 'ark-check',
2035
+ '--install-agent-gates --codex-home --force'
2036
+ ),
2037
+ });
2038
+ }
2039
+ }
2040
+ }
2041
+
2042
+ // --- Core layers optional but populated ---
2043
+ const coreOptional = [];
2044
+ const layerRows = coverage?.layers ?? [];
2045
+ const countByName = new Map(layerRows.map((r) => [r.name, r.files]));
2046
+ for (const layer of config?.layers ?? []) {
2047
+ if (!CORE_LAYER_NAMES.has(layer.name)) continue;
2048
+ if (layer.optional !== true) continue;
2049
+ const files = countByName.get(layer.name) ?? 0;
2050
+ if (files > 0) {
2051
+ coreOptional.push({ layer: layer.name, files });
2052
+ gaps.push({
2053
+ id: `core-optional-${layer.name}`,
2054
+ severity: 'info',
2055
+ message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
2056
+ fix: `Edit ark.config.json: remove optional on ${layer.name} (or set false), then ${arkCommand(root, 'ark-check', '--strict-config')}`,
2057
+ });
2058
+ }
2059
+ }
2060
+
2061
+ // --- Origin report ---
2062
+ const originJson = path.join(root, '.ark', 'reports', 'origin.json');
2063
+ const originReport = {
2064
+ present: fs.existsSync(originJson),
2065
+ path: '.ark/reports/origin.json',
2066
+ };
2067
+ if (adopted && !originReport.present && (coverage?.governed?.percent ?? 0) >= 50) {
2068
+ gaps.push({
2069
+ id: 'origin-report-missing',
2070
+ severity: 'info',
2071
+ message: 'No origin architecture snapshot under .ark/reports/ yet',
2072
+ fix: arkCommand(root, 'ark-check', '--report ark-report.html'),
2073
+ });
2074
+ }
2075
+
2076
+ // --- Baseline policy ---
2077
+ const baselinePath = path.join(root, '.ark-baseline.json');
2078
+ const baselineExists = fs.existsSync(baselinePath);
2079
+ let frozenKeys = 0;
2080
+ if (baselineExists) {
2081
+ try {
2082
+ const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
2083
+ frozenKeys = Array.isArray(raw.violations) ? raw.violations.length : 0;
2084
+ } catch {
2085
+ frozenKeys = 0;
2086
+ }
2087
+ }
2088
+ let primaryPathUsesBaseline = false;
2089
+ try {
2090
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
2091
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
2092
+ primaryPathUsesBaseline = Object.values(scripts).some(
2093
+ (s) => typeof s === 'string' && s.includes('--baseline')
2094
+ );
2095
+ } catch {
2096
+ /* no package.json */
2097
+ }
2098
+ if (!primaryPathUsesBaseline) {
2099
+ try {
2100
+ const wfDir = path.join(root, '.github', 'workflows');
2101
+ if (fs.existsSync(wfDir)) {
2102
+ for (const f of fs.readdirSync(wfDir)) {
2103
+ if (!/\.ya?ml$/i.test(f)) continue;
2104
+ const text = fs.readFileSync(path.join(wfDir, f), 'utf8');
2105
+ if (text.includes('--baseline') && (text.includes('ark-check') || text.includes('arkgate-check'))) {
2106
+ primaryPathUsesBaseline = true;
2107
+ break;
2108
+ }
2109
+ }
2110
+ }
2111
+ } catch {
2112
+ /* ignore */
2113
+ }
2114
+ }
2115
+ const baseline = {
2116
+ exists: baselineExists,
2117
+ frozenKeys,
2118
+ primaryPathUsesBaseline,
2119
+ signal: baselineExists
2120
+ ? frozenKeys === 0
2121
+ ? 'keep-empty'
2122
+ : 'active-ratchet'
2123
+ : 'absent',
2124
+ };
2125
+ if (adopted && baselineExists && frozenKeys === 0 && !primaryPathUsesBaseline) {
2126
+ gaps.push({
2127
+ id: 'baseline-unused',
2128
+ severity: 'info',
2129
+ message:
2130
+ 'Empty .ark-baseline.json exists but primary scripts/CI do not pass --baseline (policy unclear)',
2131
+ fix: 'Either add --baseline .ark-baseline.json to check:architecture / CI, or remove the unused baseline file',
2132
+ });
2133
+ }
2134
+
2135
+ // --- Educational layer balance (not a violation) ---
2136
+ let layerBalance = null;
2137
+ const total = layerRows.reduce((s, r) => s + (r.files || 0), 0);
2138
+ if (total >= 20) {
2139
+ const presentation = layerRows.find((r) => r.name === 'PresentationAdapters');
2140
+ const domain = layerRows.find((r) => r.name === 'DomainModel');
2141
+ if (presentation && domain) {
2142
+ const pShare = presentation.files / total;
2143
+ const dShare = domain.files / total;
2144
+ if (pShare >= 0.5 && dShare < 0.1) {
2145
+ layerBalance = {
2146
+ kind: 'presentation-heavy-thin-domain',
2147
+ presentationFiles: presentation.files,
2148
+ domainFiles: domain.files,
2149
+ totalFiles: total,
2150
+ educational:
2151
+ '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).',
2152
+ };
2153
+ }
2154
+ }
2155
+ }
2156
+
2157
+ return {
2158
+ gaps,
2159
+ hosts,
2160
+ mcp,
2161
+ codexHome,
2162
+ coreOptional,
2163
+ originReport,
2164
+ baseline,
2165
+ layerBalance,
2166
+ };
2167
+ }
1742
2168
 
1743
2169
  // Gate files whose Ark command runner doesn't match this project's package manager — the
1744
2170
  // advisory (and --migrate-commands) target. Returns [] for npm/unknown projects (npx is right)
@@ -1800,6 +2226,8 @@ function warnLockfileConflict(root) {
1800
2226
  // --migrate-commands: rewrite ONLY the Ark command runner in existing gate files to the
1801
2227
  // project's package manager (no --force clobber). Closes the upgrade gap where a repo that
1802
2228
  // adopted before the package-manager-aware templates keeps a stale `npx`.
2229
+ // Also normalizes MCP JSON to a single preferred bin (arkgate-mcp), stripping any dual
2230
+ // ark-mcp + arkgate-mcp residue left by partial renames during package identity cutover.
1803
2231
  function runMigrateCommands(root) {
1804
2232
  const runner = execRunner(root);
1805
2233
  const changed = [];
@@ -1811,7 +2239,12 @@ function runMigrateCommands(root) {
1811
2239
  } catch {
1812
2240
  continue;
1813
2241
  }
1814
- const next = text.replace(RUNNER_BEFORE_ARK, runner);
2242
+ let next = text.replace(RUNNER_BEFORE_ARK, runner);
2243
+ // Prefer primary product bins in command strings (aliases still work if left alone).
2244
+ next = next
2245
+ .replace(/\bark-mcp\b/g, PREFERRED_MCP_BIN)
2246
+ .replace(/\bark-check\b/g, PREFERRED_CHECK_BIN);
2247
+ // Do not blanket-replace bare `ark` — it appears in prose ("Ark check", product name).
1815
2248
  if (next !== text) {
1816
2249
  fs.writeFileSync(full, next);
1817
2250
  changed.push(rel);
@@ -1827,23 +2260,23 @@ function runMigrateCommands(root) {
1827
2260
  }
1828
2261
  const ark = json?.mcpServers?.ark;
1829
2262
  if (!ark) continue;
1830
- const binArgs = Array.isArray(ark.args)
1831
- ? ark.args.filter((entry) => entry !== 'exec' && entry !== 'ark-mcp')
1832
- : ['--root', '.', '--config', 'ark.config.json'];
1833
- const parts = execCommandParts(root, 'ark-mcp', binArgs);
2263
+ const binArgs = stripMcpServerArgs(ark.args);
2264
+ const parts = execCommandParts(root, PREFERRED_MCP_BIN, binArgs);
1834
2265
  if (ark.command !== parts.command || JSON.stringify(ark.args) !== JSON.stringify(parts.args)) {
1835
2266
  json.mcpServers.ark = { ...ark, ...parts };
1836
2267
  fs.writeFileSync(full, `${JSON.stringify(json, null, 2)}\n`);
1837
2268
  changed.push(rel);
1838
2269
  }
1839
2270
  }
1840
- const pm = runner === 'pnpm exec' ? 'pnpm' : runner;
1841
- console.log(`Migrated the Ark command runner to "${pm}" in existing gate files.`);
2271
+ const pm = runner === 'pnpm exec' || runner.startsWith('pnpm ') ? 'pnpm' : runner;
2272
+ console.log(`Migrated ArkGate command runners to "${pm}" and normalized MCP bins in gate files.`);
1842
2273
  if (changed.length === 0) {
1843
- console.log(' Nothing to change — all Ark commands already use the right runner.');
2274
+ console.log(' Nothing to change — runners and MCP bins already look correct.');
1844
2275
  } else {
1845
2276
  for (const rel of changed) console.log(` updated ${rel}`);
1846
- console.log(' (only the command runner changed; customized content is untouched.)');
2277
+ console.log(
2278
+ ` (runner + single MCP bin \`${PREFERRED_MCP_BIN}\`; customized non-command content is untouched.)`
2279
+ );
1847
2280
  }
1848
2281
  warnLockfileConflict(root);
1849
2282
  }
@@ -2970,8 +3403,12 @@ function detectEnforcement(root) {
2970
3403
  (f) => has(f) && (fileIncludes(f, 'arkgate') || fileIncludes(f, 'ark-runtime-kernel'))
2971
3404
  );
2972
3405
  const writeGateFile =
2973
- (fileIncludes('.claude/settings.json', 'ark-mcp') && '.claude/settings.json') ||
3406
+ ((fileIncludes('.claude/settings.json', 'arkgate-mcp') ||
3407
+ fileIncludes('.claude/settings.json', 'ark-mcp')) &&
3408
+ '.claude/settings.json') ||
2974
3409
  (has('.cursor/mcp.json') && '.cursor/mcp.json') ||
3410
+ (fileIncludes('.grok/hooks/ark-write-gate.json', 'arkgate-mcp') &&
3411
+ '.grok/hooks/ark-write-gate.json') ||
2975
3412
  null;
2976
3413
  return [
2977
3414
  { name: 'Write gate', where: writeGateFile, what: 'blocks a bad edit as you type (PreToolUse hook / MCP)' },
@@ -3311,6 +3748,7 @@ function renderHtmlReport({
3311
3748
  originSnapshot = null,
3312
3749
  currentSnapshot = null,
3313
3750
  originJustCreated = false,
3751
+ adoption = null,
3314
3752
  }) {
3315
3753
  const layers = Array.isArray(config.layers) ? config.layers : [];
3316
3754
  const rules = Array.isArray(config.rules) ? config.rules : [];
@@ -3362,6 +3800,8 @@ function renderHtmlReport({
3362
3800
  scoreCaption,
3363
3801
  } = fitness;
3364
3802
 
3803
+ const adoptionView = adoption || collectAdoptionGaps(root, config, coverage);
3804
+
3365
3805
  // ── Senior diagnostics (coupling, purity, contract density) ──────────────
3366
3806
  const layerNames = ordered.map((l) => l.name);
3367
3807
  const pairCount = Math.max(1, layers.length * Math.max(0, layers.length - 1));
@@ -3867,6 +4307,45 @@ function renderHtmlReport({
3867
4307
  </div>
3868
4308
  </div>
3869
4309
 
4310
+ <div class="section card" id="adoption">
4311
+ <h2>Adoption</h2>
4312
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
4313
+ Co-pilot completeness — separate from the 0–100 fitness score above. Hosts, MCP health, origin snapshot, core optionality, baseline policy.
4314
+ </p>
4315
+ <div class="kpis" style="margin-bottom:.75rem">
4316
+ <div class="kpi"><b>${adoptionView.gaps.length === 0 ? 'OK' : adoptionView.gaps.length}</b><span>${adoptionView.gaps.length === 0 ? 'No adoption gaps' : 'Adoption gap(s)'}</span></div>
4317
+ <div class="kpi"><b>${adoptionView.originReport.present ? 'yes' : 'no'}</b><span>Origin report</span></div>
4318
+ <div class="kpi"><b>${esc(adoptionView.baseline.signal)}</b><span>Baseline policy</span></div>
4319
+ <div class="kpi"><b>${adoptionView.mcp.ok ? 'ok' : 'fix'}</b><span>Repo MCP argv</span></div>
4320
+ </div>
4321
+ ${
4322
+ adoptionView.gaps.length
4323
+ ? `<ul class="senior-list">${adoptionView.gaps
4324
+ .map(
4325
+ (g) =>
4326
+ `<li><b>${esc(g.id)}</b> — ${esc(g.message)}${
4327
+ g.fix ? `<br/><code>${esc(g.fix)}</code>` : ''
4328
+ }</li>`
4329
+ )
4330
+ .join('')}</ul>`
4331
+ : '<p class="clean-body">No adoption gaps detected for hosts, MCP, core optionality, or origin.</p>'
4332
+ }
4333
+ ${
4334
+ adoptionView.coreOptional.length
4335
+ ? `<p class="dim" style="margin-top:.65rem">Optional-but-populated cores: <code>${adoptionView.coreOptional
4336
+ .map((c) => `${esc(c.layer)} (${c.files})`)
4337
+ .join('</code>, <code>')}</code></p>`
4338
+ : ''
4339
+ }
4340
+ ${
4341
+ adoptionView.hosts.length
4342
+ ? `<p class="dim" style="margin-top:.4rem">Hosts: ${adoptionView.hosts
4343
+ .map((h) => `${esc(h.host)}${h.complete ? ' ✓' : ' incomplete'}`)
4344
+ .join(' · ')}</p>`
4345
+ : ''
4346
+ }
4347
+ </div>
4348
+
3870
4349
  <div class="section grid-2">
3871
4350
  <div class="card">
3872
4351
  <h2>Architecture map</h2>
@@ -4102,6 +4581,14 @@ function renderHtmlReport({
4102
4581
  : '<p class="dim">No <code>intentPrefixes</code> on layers — runtime intent governance and string-intent checks have less to bind to.</p>'
4103
4582
  }
4104
4583
 
4584
+ <h3>Layer balance (educational)</h3>
4585
+ ${
4586
+ adoptionView.layerBalance
4587
+ ? `<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">${esc(adoptionView.layerBalance.educational)}</p>
4588
+ <p class="meta">PresentationAdapters ${adoptionView.layerBalance.presentationFiles} · DomainModel ${adoptionView.layerBalance.domainFiles} · total ${adoptionView.layerBalance.totalFiles}</p>`
4589
+ : '<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">No presentation-heavy / thin-domain imbalance flagged (educational only when Presentation ≥50% and Domain &lt;10% of files).</p>'
4590
+ }
4591
+
4105
4592
  <h3>Pattern forensics</h3>
4106
4593
  <div class="grid-2" style="margin-top:.45rem">
4107
4594
  <div>
@@ -4431,6 +4918,7 @@ function runDoctor(root, config, files, rules, violations, asJson, options = {})
4431
4918
  const gatesMissing = missingGates(root);
4432
4919
  const skillGaps = detectSkillGaps(root);
4433
4920
  const staleRunners = staleRunnerGateFiles(root);
4921
+ const adoption = collectAdoptionGaps(root, config, cov);
4434
4922
  const baseline = readBaseline(root, '.ark-baseline.json');
4435
4923
  const currentKeys = new Set(violations.map(baselineKey));
4436
4924
  const suppressed = baseline.exists
@@ -4472,10 +4960,12 @@ function runDoctor(root, config, files, rules, violations, asJson, options = {})
4472
4960
  exists: baseline.exists,
4473
4961
  frozen: baseline.exists ? baseline.keys.size : 0,
4474
4962
  stale: staleBaseline,
4963
+ policy: adoption.baseline,
4475
4964
  },
4476
4965
  gatesMissing,
4477
4966
  skillGaps,
4478
4967
  staleRunnerFiles: staleRunners,
4968
+ adoption,
4479
4969
  newHere: showNewHere
4480
4970
  ? {
4481
4971
  show: true,
@@ -4617,6 +5107,37 @@ function runDoctor(root, config, files, rules, violations, asJson, options = {})
4617
5107
  actions.push(`migrate command runners (${arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands')})`);
4618
5108
  }
4619
5109
 
5110
+ // Adoption completeness (hosts, MCP health, codex home, core optionality, origin, baseline policy)
5111
+ console.log('');
5112
+ console.log(color.bold('Adoption (separate from fitness score)'));
5113
+ if (adoption.gaps.length === 0 && !adoption.layerBalance) {
5114
+ line(ok, 'Hosts, MCP argv, core optionality, origin report, and baseline policy look complete');
5115
+ } else {
5116
+ for (const gap of adoption.gaps) {
5117
+ const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
5118
+ line(mark, gap.message);
5119
+ if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
5120
+ actions.push(gap.fix || gap.message);
5121
+ }
5122
+ if (adoption.layerBalance) {
5123
+ line(warn, color.dim(adoption.layerBalance.educational));
5124
+ }
5125
+ }
5126
+ if (adoption.baseline) {
5127
+ line(
5128
+ ' ',
5129
+ color.dim(
5130
+ `Baseline policy: ${adoption.baseline.signal}` +
5131
+ (adoption.baseline.primaryPathUsesBaseline
5132
+ ? ' · primary path uses --baseline'
5133
+ : ' · primary path does not use --baseline')
5134
+ )
5135
+ );
5136
+ }
5137
+ if (adoption.originReport.present) {
5138
+ line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
5139
+ }
5140
+
4620
5141
  console.log('');
4621
5142
  if (actions.length === 0) {
4622
5143
  console.log(color.green('✔ Healthy — nothing to do.'));
@@ -4744,24 +5265,39 @@ async function main() {
4744
5265
 
4745
5266
  // Resolve TypeScript from the project first, then Ark's own install, then bare import.
4746
5267
  // --plan can still run honestly (coverage + empty violations) when TS is missing.
4747
- const ts = await loadTypeScript(root);
4748
- if (!ts) {
5268
+ // Early TypeScript 7 native builds may load but lack a JS `sys` host — we fall back.
5269
+ const loaded = await loadTypeScript(root);
5270
+ if (!loaded?.ts) {
4749
5271
  if (args.plan) {
4750
5272
  const cov = computeCoverage(root, config, files, rules);
4751
5273
  if (!args.json) {
4752
5274
  console.log(
4753
5275
  color.yellow(
4754
- `TypeScript not found — plan shows coverage honesty only (no import graph). Install with: ${installDevHint(root, 'typescript')}`
5276
+ `TypeScript not found — plan shows coverage honesty only (no import graph). Install with: ${installDevHint(root, 'typescript')} (supported: 5.x–7.x; see docs/typescript-support.md)`
4755
5277
  )
4756
5278
  );
4757
5279
  }
4758
5280
  runPlan(root, [], args.json, cov.governed.percent, cov.governed.totalFiles);
4759
5281
  return;
4760
5282
  }
4761
- console.error(`ark-check requires TypeScript. Install it with: ${installDevHint(root, 'typescript')}`);
5283
+ console.error(
5284
+ `ark-check requires a JS-API TypeScript (5.x–7.x with ts.sys). Install with: ${installDevHint(root, 'typescript')} — see docs/typescript-support.md`
5285
+ );
4762
5286
  process.exitCode = 2;
4763
5287
  return;
4764
5288
  }
5289
+ const { ts } = loaded;
5290
+ if (loaded.fallbackReason && !args.json) {
5291
+ console.log(color.yellow(loaded.fallbackReason));
5292
+ }
5293
+ if (process.env.ARK_DEBUG_TS === '1' && !args.json) {
5294
+ console.log(
5295
+ color.dim(
5296
+ `[ark-check] TypeScript ${loaded.version ?? '?'} via ${loaded.source}` +
5297
+ (loaded.fallbackReason ? ' (fallback)' : '')
5298
+ )
5299
+ );
5300
+ }
4765
5301
 
4766
5302
  const manifestIntentLayers = intentLayersFromManifest(manifest);
4767
5303
  const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
@@ -5218,6 +5754,16 @@ async function main() {
5218
5754
  );
5219
5755
  }
5220
5756
 
5757
+ const brokenMcp = brokenMcpGateFiles(root);
5758
+ if (brokenMcp.length > 0) {
5759
+ console.log(
5760
+ color.yellow(
5761
+ `Broken MCP argv in ${brokenMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp in args ` +
5762
+ `(stdio hosts get a double binary name). Fix: ${arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands')}`
5763
+ )
5764
+ );
5765
+ }
5766
+
5221
5767
  if (codexHomeGap) {
5222
5768
  const parts = [];
5223
5769
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);