arkgate 2.12.0 → 2.13.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 (70) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +55 -31
  3. package/bin/ark-check.mjs +95 -36
  4. package/bin/ark-mcp.mjs +11 -5
  5. package/bin/ark-shared.mjs +88 -56
  6. package/bin/ark.mjs +45 -10
  7. package/bin/lib/agent-gates.mjs +12 -0
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +9 -3
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/doctor-plan.mjs +43 -16
  13. package/bin/lib/enforcement-profiles.mjs +97 -0
  14. package/bin/lib/host-support-matrix.mjs +77 -0
  15. package/bin/lib/install-migrate.mjs +45 -14
  16. package/bin/lib/mcp-adoption.mjs +35 -3
  17. package/bin/lib/open-html.mjs +75 -0
  18. package/bin/lib/presets.mjs +3 -2
  19. package/bin/lib/safety-diagnostics.mjs +31 -11
  20. package/bin/lib/skill-install.mjs +64 -0
  21. package/bin/lib/ts-resolve.mjs +2 -1
  22. package/bin/lib/weakest-link.mjs +417 -0
  23. package/bin/lib/write-path-capabilities.mjs +182 -0
  24. package/bin/lib/write-path-detect.mjs +62 -99
  25. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  26. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  27. package/dist/eslint/index.cjs +375 -13
  28. package/dist/eslint/index.cjs.map +1 -1
  29. package/dist/eslint/index.d.cts +30 -20
  30. package/dist/eslint/index.d.ts +30 -20
  31. package/dist/eslint/index.js +375 -13
  32. package/dist/eslint/index.js.map +1 -1
  33. package/dist/index.cjs +723 -61
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +95 -5
  36. package/dist/index.d.ts +95 -5
  37. package/dist/index.js +716 -61
  38. package/dist/index.js.map +1 -1
  39. package/dist/nestjs/index.cjs +150 -42
  40. package/dist/nestjs/index.cjs.map +1 -1
  41. package/dist/nestjs/index.d.cts +2 -1
  42. package/dist/nestjs/index.d.ts +2 -1
  43. package/dist/nestjs/index.js +150 -42
  44. package/dist/nestjs/index.js.map +1 -1
  45. package/dist/runtime/index.cjs +723 -61
  46. package/dist/runtime/index.cjs.map +1 -1
  47. package/dist/runtime/index.d.cts +3 -2
  48. package/dist/runtime/index.d.ts +3 -2
  49. package/dist/runtime/index.js +716 -61
  50. package/dist/runtime/index.js.map +1 -1
  51. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  52. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  53. package/docs/agent-guide.md +32 -20
  54. package/docs/ai-gates.md +53 -18
  55. package/docs/configuration.md +97 -0
  56. package/docs/enthusiast/README.md +3 -3
  57. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  58. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  59. package/docs/package-surface.md +14 -9
  60. package/docs/production-hardening.md +15 -2
  61. package/docs/threat-model.md +65 -0
  62. package/docs/typescript-support.md +3 -3
  63. package/package.json +15 -2
  64. package/schemas/ark.config.schema.json +750 -0
  65. package/server.json +2 -2
  66. package/templates/hooks/pre-commit-ark +37 -0
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
@@ -14,6 +14,7 @@ import {
14
14
  codexPromptsDir,
15
15
  codexConfigPath,
16
16
  isTempOrUpgradeRoot,
17
+ usesDefaultCodexHome,
17
18
  wireCodexMcp,
18
19
  } from './codex-home.mjs';
19
20
  import {
@@ -40,9 +41,7 @@ import {
40
41
  checkArchitectureScriptSnippet,
41
42
  } from './ci-and-commands.mjs';
42
43
  import {
43
- normalizeToolsList,
44
44
  resolveTools,
45
- KNOWN_TOOLS,
46
45
  SKILL_TOOL_TARGETS,
47
46
  skillTemplates,
48
47
  stampSkill,
@@ -59,6 +58,11 @@ import {
59
58
  PREFERRED_CHECK_BIN,
60
59
  RUNNER_BEFORE_ARK,
61
60
  } from './mcp-adoption.mjs';
61
+ import {
62
+ hasHardWriteHook,
63
+ validateHardWriteRequest,
64
+ validateSelectedTools,
65
+ } from './enforcement-profiles.mjs';
62
66
 
63
67
  export function staleRunnerGateFiles(root) {
64
68
  const want = execRunner(root);
@@ -179,17 +183,35 @@ export function runInstallAgentGates(args) {
179
183
  return;
180
184
  }
181
185
  if (args.tools != null) {
182
- const list = normalizeToolsList(args.tools);
183
- args.tools = list;
184
- const unknown = list.filter((tool) => !KNOWN_TOOLS.includes(tool));
185
- if (list.length === 0 || unknown.length > 0) {
186
- console.error(
187
- `--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
188
- (unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
189
- );
186
+ const selection = validateSelectedTools(args.tools);
187
+ if (!selection.ok) {
188
+ console.error(selection.error);
190
189
  process.exitCode = 2;
191
190
  return;
192
191
  }
192
+ args.tools = selection.tools;
193
+ }
194
+ const writeRequest = validateHardWriteRequest({
195
+ root,
196
+ host: args.requireWriteHook,
197
+ tools: args.tools,
198
+ force: args.force,
199
+ });
200
+ if (!writeRequest.ok) {
201
+ console.error(writeRequest.error);
202
+ process.exitCode = 2;
203
+ return;
204
+ }
205
+ if (writeRequest.host && args.tools == null) {
206
+ args.tools = writeRequest.tools;
207
+ }
208
+ if (writeRequest.host && args.skillsOnly && !hasHardWriteHook(root, writeRequest.host)) {
209
+ console.error(
210
+ `--skills-only cannot install the requested ${writeRequest.host} hard-write hook. ` +
211
+ 'Remove --skills-only or omit --require-write-hook.'
212
+ );
213
+ process.exitCode = 2;
214
+ return;
193
215
  }
194
216
  const pm = packageManager(root);
195
217
  const hasCheckScript = hasCheckArchitectureScript(root);
@@ -370,16 +392,17 @@ export function runInstallAgentGates(args) {
370
392
  // without a manual copy step.
371
393
  //
372
394
  // Skip home-dir mutation when the project root is a temp/upgrade scratch *and*
373
- // CODEX_HOME is the default (~/.codex). Fixtures must not rewrite the developer's
374
- // real Codex config. When CODEX_HOME is redirected (unit tests set a temp home) or
375
- // --codex-home is explicit, wire as usual.
395
+ // CODEX_HOME resolves to the default (~/.codex). Codex itself may export that exact
396
+ // path, so presence alone does not prove isolation. Fixtures must not rewrite the
397
+ // developer's real config. A genuinely redirected CODEX_HOME or explicit
398
+ // --codex-home still wires as requested.
376
399
  let codexMcp = null;
377
400
  const wantCodexWire = tools.has('codex') || args.codexHome;
378
401
  const skipHomeWire =
379
402
  wantCodexWire &&
380
403
  isTempOrUpgradeRoot(root) &&
381
404
  !args.codexHome &&
382
- !process.env.CODEX_HOME;
405
+ usesDefaultCodexHome();
383
406
  if (wantCodexWire && !skipHomeWire) {
384
407
  codexMcp = wireCodexMcp(root, args.force);
385
408
  console.log('');
@@ -416,6 +439,14 @@ export function runInstallAgentGates(args) {
416
439
  `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --tools codex --force.`
417
440
  );
418
441
  }
442
+ if (writeRequest.host) {
443
+ if (!hasHardWriteHook(root, writeRequest.host)) {
444
+ console.error(`\nFailed to verify the ${writeRequest.host} hard-write hook after install.`);
445
+ process.exitCode = 1;
446
+ return;
447
+ }
448
+ console.log(`\nHard-write hook verified for ${writeRequest.host}.`);
449
+ }
419
450
  console.log('');
420
451
  console.log('Next steps:');
421
452
  console.log(' 1. Review the generated files and commit the ones that match your tools.');
@@ -9,8 +9,9 @@ import { CORE_LAYER_NAMES } from './core-layers.mjs';
9
9
  import { falseGreenAdoptionGap } from './field-install.mjs';
10
10
  import { assessCodexHomeMcp, codexConfigPath } from './codex-home.mjs';
11
11
  import { detectWritePathCapabilities } from './write-path-detect.mjs';
12
- import { skillTemplateNames } from './skill-install.mjs';
12
+ import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
13
13
  import { detectDeployPathQuality } from './deploy-path.mjs';
14
+ import { collectWeakestLinkGaps } from './weakest-link.mjs';
14
15
 
15
16
  export { detectDeployPathQuality };
16
17
 
@@ -195,10 +196,22 @@ export function collectAdoptionGaps(root, config, coverage) {
195
196
  scopedTable: assessed.scopedTable,
196
197
  };
197
198
  if (assessed.gap) {
199
+ // Temp/upgrade MCP roots stay urgent (fail-closed). Non-temp Codex-home debt
200
+ // is deferred only when the session host is known and is not Codex
201
+ // (Grok/Claude/Cursor…). Unknown host (CI/plain shell) keeps original severity.
202
+ const tempUrgent = Boolean(assessed.tempPath);
203
+ const activeHost = detectActiveAgentHost();
204
+ const deferred =
205
+ !tempUrgent && activeHost != null && activeHost !== 'codex';
206
+ const severity = deferred ? 'info' : assessed.gap.severity;
207
+ const message = deferred
208
+ ? `Deferred (fix when using Codex): ${assessed.gap.message}`
209
+ : assessed.gap.message;
198
210
  gaps.push({
199
211
  id: assessed.gap.id,
200
- severity: assessed.gap.severity,
201
- message: assessed.gap.message,
212
+ severity,
213
+ deferred,
214
+ message,
202
215
  fix: arkCommand(root, 'ark-check', assessed.gap.fixArgs),
203
216
  });
204
217
  }
@@ -403,6 +416,19 @@ export function collectAdoptionGaps(root, config, coverage) {
403
416
  }
404
417
  }
405
418
 
419
+ // --- Q3 weakest-link: CI / pre-commit / config drift (local FS; optional GH via ARK_DOCTOR_GITHUB=1) ---
420
+ const includeGithub =
421
+ process.env.ARK_DOCTOR_GITHUB === '1' ||
422
+ process.env.ARK_DOCTOR_GITHUB === 'true';
423
+ const weakest = collectWeakestLinkGaps(root, {
424
+ adopted,
425
+ isProducer,
426
+ includeGithub,
427
+ });
428
+ for (const g of weakest.gaps) {
429
+ gaps.push(g);
430
+ }
431
+
406
432
  return {
407
433
  gaps,
408
434
  hosts,
@@ -415,6 +441,12 @@ export function collectAdoptionGaps(root, config, coverage) {
415
441
  deployPath,
416
442
  contractFalseGreen,
417
443
  writePath,
444
+ enforcement: {
445
+ ci: weakest.ci,
446
+ preCommit: weakest.preCommit,
447
+ drift: weakest.drift,
448
+ github: weakest.github,
449
+ },
418
450
  };
419
451
  }
420
452
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Open a local HTML file in the default browser (macOS / Windows / Linux).
3
+ * Used after ark-check --report writes a showcase/beginner report.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ /**
11
+ * Whether report generation should open the browser.
12
+ * Default: yes for interactive local TTY; never in CI/Vitest unless forced.
13
+ *
14
+ * @param {{ force?: boolean, noOpen?: boolean, env?: NodeJS.ProcessEnv, isTty?: boolean }} [opts]
15
+ */
16
+ export function shouldOpenHtmlReport(opts = {}) {
17
+ const env = opts.env ?? process.env;
18
+ if (opts.noOpen) return false;
19
+ if (opts.force) return true;
20
+ const off = env.ARK_NO_OPEN_REPORT;
21
+ if (off === '1' || off === 'true' || off === 'yes') return false;
22
+ if (env.CI === 'true' || env.CI === '1') return false;
23
+ if (env.GITHUB_ACTIONS === 'true') return false;
24
+ if (env.VITEST) return false;
25
+ const tty = opts.isTty ?? Boolean(process.stdout.isTTY);
26
+ return tty;
27
+ }
28
+
29
+ /**
30
+ * Launch the OS default browser for a local file path (non-blocking).
31
+ * Errors are swallowed — report generation must not fail if the browser cannot open.
32
+ *
33
+ * @param {string} filePath
34
+ * @param {{ platform?: NodeJS.Platform, spawn?: typeof spawn }} [opts]
35
+ * @returns {{ ok: boolean, command?: string, reason?: string }}
36
+ */
37
+ export function openHtmlInBrowser(filePath, opts = {}) {
38
+ const abs = path.resolve(filePath);
39
+ if (!fs.existsSync(abs)) {
40
+ return { ok: false, reason: 'missing-file' };
41
+ }
42
+ const platform = opts.platform ?? process.platform;
43
+ const spawnFn = opts.spawn ?? spawn;
44
+
45
+ /** @type {string} */
46
+ let command;
47
+ /** @type {string[]} */
48
+ let args;
49
+ if (platform === 'darwin') {
50
+ command = 'open';
51
+ args = [abs];
52
+ } else if (platform === 'win32') {
53
+ command = 'rundll32.exe';
54
+ args = ['url.dll,FileProtocolHandler', pathToFileURL(abs).href];
55
+ } else {
56
+ command = 'xdg-open';
57
+ args = [abs];
58
+ }
59
+
60
+ try {
61
+ const child = spawnFn(command, args, {
62
+ detached: true,
63
+ stdio: 'ignore',
64
+ windowsHide: true,
65
+ shell: false,
66
+ });
67
+ child.on('error', () => {
68
+ /* browser missing (e.g. headless Linux) — ignore */
69
+ });
70
+ child.unref();
71
+ return { ok: true, command };
72
+ } catch (error) {
73
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
74
+ }
75
+ }
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_INTENT_PREFIXES,
9
9
  resolveIncludeRoots,
10
10
  } from '../ark-shared.mjs';
11
+ import { withArkConfigMetadata } from './config-contract.mjs';
11
12
 
12
13
  export function denyUpward(names) {
13
14
  const rules = [];
@@ -49,8 +50,8 @@ export function peerIsolationEdges(layerNames, sliceFolders, message) {
49
50
  // (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
50
51
  export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
51
52
  export function presetWithOverlays(baseConfig, root) {
52
- if (!root) return baseConfig;
53
- return applyFrameworkLayoutOverlays(baseConfig, root);
53
+ const config = root ? applyFrameworkLayoutOverlays(baseConfig, root) : baseConfig;
54
+ return withArkConfigMetadata(config);
54
55
  }
55
56
 
56
57
  export const ARCHITECTURE_PRESETS = {
@@ -146,15 +146,20 @@ export function collectSafetyDiagnostics(ts, root, config, files) {
146
146
  });
147
147
  }
148
148
 
149
- if (ts.isCallExpression(node) && node.expression?.kind === ts.SyntaxKind.ImportKeyword) {
149
+ if (ts.isCallExpression(node)) {
150
+ const dynamicImport = node.expression?.kind === ts.SyntaxKind.ImportKeyword;
151
+ const directRequire =
152
+ ts.isIdentifier(node.expression) && node.expression.text === 'require';
150
153
  const argument = node.arguments[0];
151
- if (!argument || !ts.isStringLiteralLike(argument)) {
152
- if (!matchesAny(relFile, dynamicAllowlist)) {
153
- report.nonLiteralDynamicImports.push({
154
- file: relFile,
155
- line: lineOf(sourceFile, node.getStart(sourceFile)),
156
- });
157
- }
154
+ const unresolved =
155
+ (dynamicImport || directRequire) &&
156
+ (!argument || !ts.isStringLiteralLike(argument));
157
+ if (unresolved && !matchesAny(relFile, dynamicAllowlist)) {
158
+ report.nonLiteralDynamicImports.push({
159
+ file: relFile,
160
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
161
+ kind: directRequire ? 'require' : 'import',
162
+ });
158
163
  }
159
164
  }
160
165
 
@@ -216,13 +221,28 @@ export function collectSafetyDiagnostics(ts, root, config, files) {
216
221
  }
217
222
 
218
223
  const warnings = [];
219
- if (report.nonLiteralDynamicImports.length > 0) {
220
- const first = report.nonLiteralDynamicImports[0];
224
+ const nonLiteralImports = report.nonLiteralDynamicImports.filter(
225
+ (entry) => entry.kind !== 'require'
226
+ );
227
+ if (nonLiteralImports.length > 0) {
228
+ const first = nonLiteralImports[0];
221
229
  warnings.push({
222
230
  ruleId: 'DYNAMIC_IMPORT_NOT_ALLOWLISTED',
223
231
  file: first.file,
224
232
  line: first.line,
225
- message: `${report.nonLiteralDynamicImports.length} non-literal dynamic import(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
233
+ message: `${nonLiteralImports.length} non-literal dynamic import(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
234
+ });
235
+ }
236
+ const nonLiteralRequires = report.nonLiteralDynamicImports.filter(
237
+ (entry) => entry.kind === 'require'
238
+ );
239
+ if (nonLiteralRequires.length > 0) {
240
+ const first = nonLiteralRequires[0];
241
+ warnings.push({
242
+ ruleId: 'DYNAMIC_REQUIRE_NOT_ALLOWLISTED',
243
+ file: first.file,
244
+ line: first.line,
245
+ message: `${nonLiteralRequires.length} non-literal require call(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
226
246
  });
227
247
  }
228
248
  if (report.tsSuppressions.length > maxTsSuppressions) {
@@ -23,6 +23,70 @@ export function normalizeToolsList(tools) {
23
23
  return [];
24
24
  }
25
25
 
26
+ function envTruthy(v) {
27
+ if (v == null || v === '') return false;
28
+ const s = String(v).trim().toLowerCase();
29
+ return s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
30
+ }
31
+
32
+ /**
33
+ * Best-effort active agent host for this process (session host).
34
+ * Prefer ARK_ACTIVE_HOST when set. Do NOT treat CODEX_HOME alone as Codex —
35
+ * that dir exists for anyone who installed Codex, even when running Grok/Claude.
36
+ *
37
+ * @param {NodeJS.ProcessEnv} [env]
38
+ * @returns {string|null} tool id (claude|cursor|codex|grok|…) or null if unknown
39
+ */
40
+ export function detectActiveAgentHost(env = process.env) {
41
+ const explicit = String(env.ARK_ACTIVE_HOST || '')
42
+ .trim()
43
+ .toLowerCase();
44
+ if (explicit) return explicit;
45
+
46
+ // Grok / xAI Build
47
+ if (
48
+ envTruthy(env.GROK_BUILD) ||
49
+ envTruthy(env.XAI_GROK) ||
50
+ env.GROK_WORKSPACE_ROOT ||
51
+ env.GROK_SESSION_ID
52
+ ) {
53
+ return 'grok';
54
+ }
55
+ // Claude Code
56
+ if (
57
+ env.CLAUDE_PROJECT_DIR ||
58
+ envTruthy(env.CLAUDE_CODE) ||
59
+ envTruthy(env.CLAUDECODE) ||
60
+ env.CLAUDE_CODE_ENTRYPOINT
61
+ ) {
62
+ return 'claude';
63
+ }
64
+ // Cursor agent
65
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT || envTruthy(env.CURSOR_AGENT_CLI)) {
66
+ return 'cursor';
67
+ }
68
+ // Codex session — never CODEX_HOME alone (see above)
69
+ if (
70
+ envTruthy(env.CODEX_SANDBOX) ||
71
+ env.CODEX_THREAD_ID ||
72
+ envTruthy(env.CODEX_CI) ||
73
+ env.CODEX_SESSION_ID
74
+ ) {
75
+ return 'codex';
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * True when Codex home / MCP / prompts should be treated as an urgent concern
82
+ * for this process. Non-Codex hosts (Grok, Claude, Cursor, …) defer Codex debt.
83
+ *
84
+ * @param {NodeJS.ProcessEnv} [env]
85
+ */
86
+ export function codexConcernIsActive(env = process.env) {
87
+ return detectActiveAgentHost(env) === 'codex';
88
+ }
89
+
26
90
  export function resolveTools(args) {
27
91
  const explicit = normalizeToolsList(args.tools);
28
92
  if (explicit.length > 0) {
@@ -128,9 +128,10 @@ export function scanCacheKey(root, args) {
128
128
  // warm cache from an older Ark can't feed stale entries to new logic. v2: typeOnly on edges.
129
129
  // v3: per-file exportsOnlyTypes. v4: typeOnlyExportNames + namedBindings.
130
130
  // v5: hasTopLevelSideEffects. v6: non-exported impure inits + non-export class statics.
131
+ // v7: scope-aware forbidden globals + import-equals dependency edges.
131
132
  return crypto
132
133
  .createHash('sha1')
133
- .update(`ark-check-cache-v6\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
134
+ .update(`ark-check-cache-v7\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
134
135
  .digest('hex');
135
136
  }
136
137