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
@@ -1,12 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import {
5
+ DEFAULT_ARK_CONFIG_RULES,
6
+ withArkConfigMetadata,
7
+ } from './lib/config-contract.mjs';
4
8
 
5
9
  /**
6
- * Default layer rule matrix + intent-prefix map, shared by both CLIs and by the ark-mcp
7
- * write-path gate so they enforce identically. These mirror the elevenLayerProfile in
8
- * src/kernel/layers/ArchitectureProfile.ts; kept here (not imported from dist) because the
9
- * CLIs run standalone with only `typescript` present, no build step.
10
+ * Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
11
+ * matrix comes from the generated Domain config contract above. Prefixes mirror the runtime
12
+ * profile but stay in this standalone CLI module because the CLIs run without a build step.
10
13
  */
11
14
  export const DEFAULT_INTENT_PREFIXES = [
12
15
  { layer: 'DomainModel', prefixes: ['Domain.'] },
@@ -41,35 +44,7 @@ export const DEFAULT_LAYER_DIRECTORIES = {
41
44
  Kernel: ['kernel'],
42
45
  };
43
46
 
44
- const DEFAULT_ALLOWED_FLOWS = [
45
- { from: 'PresentationAdapters', to: 'ApplicationOrchestration' },
46
- { from: 'ApplicationOrchestration', to: 'DomainModel' },
47
- { from: 'WorkflowSagaEngine', to: 'ApplicationOrchestration' },
48
- { from: 'WorkflowSagaEngine', to: 'DomainModel' },
49
- { from: 'BackgroundJobsScheduling', to: 'ApplicationOrchestration' },
50
- ];
51
-
52
- function flowKey(from, to) {
53
- return `${from}->${to}`;
54
- }
55
-
56
- function createStrictDenyRules(layers, allowedFlows) {
57
- const allowed = new Set(allowedFlows.map((flow) => flowKey(flow.from, flow.to)));
58
- const rules = [];
59
- for (const from of layers) {
60
- for (const to of layers) {
61
- if (from.layer === to.layer) continue;
62
- if (allowed.has(flowKey(from.layer, to.layer))) continue;
63
- rules.push({ from: from.layer, to: to.layer, allowed: false });
64
- }
65
- }
66
- return rules;
67
- }
68
-
69
- export const DEFAULT_RULES = createStrictDenyRules(
70
- DEFAULT_INTENT_PREFIXES,
71
- DEFAULT_ALLOWED_FLOWS
72
- );
47
+ export const DEFAULT_RULES = DEFAULT_ARK_CONFIG_RULES;
73
48
 
74
49
  /**
75
50
  * Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
@@ -82,7 +57,7 @@ export function createElevenLayerConfig(options = {}) {
82
57
  const rootDir = options.rootDir ?? 'src';
83
58
  const optional = options.optionalLayers ?? true;
84
59
  const prefix = rootDir === '.' ? '' : `${rootDir}/`;
85
- const config = {
60
+ const config = withArkConfigMetadata({
86
61
  include: options.include ?? [rootDir],
87
62
  layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
88
63
  name: entry.layer,
@@ -96,7 +71,7 @@ export function createElevenLayerConfig(options = {}) {
96
71
  : {}),
97
72
  })),
98
73
  rules: DEFAULT_RULES,
99
- };
74
+ });
100
75
  // When a project root is known, overlay Nest/Next/express filename conventions so a
101
76
  // flat framework starter is governed on day one (not "0% governed / false green").
102
77
  if (options.root) return applyFrameworkLayoutOverlays(config, options.root);
@@ -134,7 +109,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
134
109
  try {
135
110
  signals = collectRepoShapeSignals(root);
136
111
  } catch {
137
- return config;
112
+ return withArkConfigMetadata(config);
138
113
  }
139
114
 
140
115
  const next = {
@@ -344,7 +319,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
344
319
  : 'library';
345
320
  }
346
321
 
347
- return next;
322
+ return withArkConfigMetadata(next);
348
323
  }
349
324
 
350
325
  /**
@@ -398,40 +373,97 @@ export function resolveOperatingMode({
398
373
  return 'suggest';
399
374
  }
400
375
 
376
+ function singleFileTypeChecker(ts, sourceFile) {
377
+ const options = {
378
+ noLib: true,
379
+ noResolve: true,
380
+ target: ts.ScriptTarget.Latest,
381
+ };
382
+ const host = ts.createCompilerHost(options, true);
383
+ host.getSourceFile = (fileName) =>
384
+ fileName === sourceFile.fileName ? sourceFile : undefined;
385
+ host.fileExists = (fileName) => fileName === sourceFile.fileName;
386
+ host.readFile = (fileName) =>
387
+ fileName === sourceFile.fileName ? sourceFile.text : undefined;
388
+ return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker();
389
+ }
390
+
391
+ function propertyAccessPath(ts, node) {
392
+ const segments = [];
393
+ let current = node;
394
+ while (ts.isPropertyAccessExpression(current)) {
395
+ segments.unshift(current.name.text);
396
+ current = current.expression;
397
+ }
398
+ if (!ts.isIdentifier(current)) return undefined;
399
+ segments.unshift(current.text);
400
+ return { root: current, segments };
401
+ }
402
+
403
+ function isRuntimeIdentifierReference(ts, node) {
404
+ if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) return false;
405
+ return (
406
+ (ts.isExpressionNode(node) && !ts.isInTypeQuery(node)) ||
407
+ (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node)
408
+ );
409
+ }
410
+
411
+ function hasLocalDeclaration(ts, checker, sourceFile, node) {
412
+ const shorthand =
413
+ ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node;
414
+ const symbol = shorthand
415
+ ? checker.getShorthandAssignmentValueSymbol(node.parent)
416
+ : checker.getSymbolAtLocation(node);
417
+ return Boolean(
418
+ symbol?.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile)
419
+ );
420
+ }
421
+
401
422
  /**
402
423
  * Find uses of forbidden ambient globals in a TypeScript source file.
403
424
  *
404
- * Detection is deliberately positional, not scope-aware (kept in sync with
405
- * `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts the CLIs must not
406
- * import from dist):
407
- * - a dotted entry ("Date.now") flags `Date.now` property accesses
408
- * - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
409
- * direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
410
- * Bare identifier mentions in other positions (types, shadowed locals, import names) are
411
- * NOT flagged, trading a little recall for near-zero false positives without a type checker.
425
+ * A no-lib, no-resolution TypeScript program binds declarations in this file only. An
426
+ * identifier with a symbol is therefore local (parameter, variable, import, etc.); an
427
+ * unbound runtime identifier is ambient. Dotted entries use AST property chains and
428
+ * explicit `globalThis` access is normalized to the configured global name.
412
429
  *
430
+ * Kept in sync with `analyzeForbiddenGlobals` in
431
+ * src/kernel/ai-gate/AICodeGate.ts — the standalone CLIs must not import from dist.
413
432
  * Returns [{ name, node }] where `name` is the matched forbidden entry.
414
433
  */
415
434
  export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
416
435
  const entries = new Set(forbidden ?? []);
417
436
  if (entries.size === 0) return [];
437
+ const checker = singleFileTypeChecker(ts, sourceFile);
418
438
  const uses = [];
419
439
 
420
440
  const visit = (node) => {
421
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
422
- const dotted = `${node.expression.text}.${node.name.text}`;
423
- if (entries.has(dotted)) {
424
- uses.push({ name: dotted, node });
425
- } else if (entries.has(node.expression.text)) {
426
- uses.push({ name: node.expression.text, node });
441
+ const nestedPropertyAccess =
442
+ ts.isPropertyAccessExpression(node) &&
443
+ ts.isPropertyAccessExpression(node.parent) &&
444
+ node.parent.expression === node;
445
+ if (ts.isPropertyAccessExpression(node) && !nestedPropertyAccess) {
446
+ const path = propertyAccessPath(ts, node);
447
+ if (path && !hasLocalDeclaration(ts, checker, sourceFile, path.root)) {
448
+ const explicitGlobalThis = path.segments[0] === 'globalThis';
449
+ const normalized = explicitGlobalThis ? path.segments.slice(1) : path.segments;
450
+ let match;
451
+ for (let length = normalized.length; length >= (explicitGlobalThis ? 1 : 2); length -= 1) {
452
+ const candidate = normalized.slice(0, length).join('.');
453
+ if (entries.has(candidate)) {
454
+ match = candidate;
455
+ break;
456
+ }
457
+ }
458
+ if (match) uses.push({ name: match, node });
427
459
  }
428
460
  } else if (
429
- (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
430
- node.expression &&
431
- ts.isIdentifier(node.expression) &&
432
- entries.has(node.expression.text)
461
+ ts.isIdentifier(node) &&
462
+ entries.has(node.text) &&
463
+ isRuntimeIdentifierReference(ts, node) &&
464
+ !hasLocalDeclaration(ts, checker, sourceFile, node)
433
465
  ) {
434
- uses.push({ name: node.expression.text, node });
466
+ uses.push({ name: node.text, node });
435
467
  }
436
468
  ts.forEachChild(node, visit);
437
469
  };
package/bin/ark.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  resolveOperatingMode,
20
20
  } from './ark-shared.mjs';
21
21
  import { pinArkgateDevDependency, FALSE_GREEN_GAP_ID } from './lib/field-install.mjs';
22
+ import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
22
23
 
23
24
  const here = path.dirname(fileURLToPath(import.meta.url));
24
25
  const arkCheck = path.join(here, 'ark-check.mjs');
@@ -57,6 +58,7 @@ function parseArgs(argv) {
57
58
  force: false,
58
59
  strict: true,
59
60
  install: true,
61
+ requireWriteHook: undefined,
60
62
  help: false,
61
63
  version: false,
62
64
  };
@@ -81,6 +83,9 @@ function parseArgs(argv) {
81
83
  else if (arg === '--preset') args.preset = requireValue(arg, i++);
82
84
  else if (arg === '--archetype') args.archetype = requireValue(arg, i++);
83
85
  else if (arg === '--tools') args.tools = requireValue(arg, i++);
86
+ else if (arg === '--require-write-hook') {
87
+ args.requireWriteHook = requireValue(arg, i++).trim().toLowerCase();
88
+ }
84
89
  else if (arg === '--help' || arg === '-h' || arg === 'help') args.help = true;
85
90
  else if (arg === '--version' || arg === '-V') args.version = true;
86
91
  else if (!arg.startsWith('-') && args.command === undefined) args.command = arg;
@@ -92,9 +97,9 @@ function parseArgs(argv) {
92
97
 
93
98
  function usage() {
94
99
  return `Usage:
95
- ark start [--root <project>] [--yes]
100
+ ark start [--root <project>] [--tools <list>] [--require-write-hook <host>] [--yes]
96
101
  ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture]
97
- [--archetype <playbook-id>] [--tools <list>] [--yes] [--force] [--no-strict]
102
+ [--archetype <playbook-id>] [--tools <list>] [--require-write-hook <host>] [--yes] [--force] [--no-strict]
98
103
  ark upgrade [--root <project>] [--no-install] [--no-strict]
99
104
 
100
105
  Commands:
@@ -119,6 +124,9 @@ Options:
119
124
  vertical-slice-product, ddd-bounded-contexts.
120
125
  --tools Comma-separated agents to gate (claude,cursor,codex,grok,windsurf,cline,copilot,kiro,roo,continue,gemini).
121
126
  Omit to auto-detect from each tool's config dir, falling back to claude+cursor+codex+grok.
127
+ --require-write-hook <host>
128
+ Require and verify a hard local write hook for Claude or Grok. Cursor/Codex are
129
+ advisory-write plus hard CI merge only; impossible requests fail before any write.
122
130
 
123
131
  Interactive mode (TTY, no --yes): asks what application shape you are building and maps it to a preset.
124
132
  Non-interactive (no TTY): uses the same defaults as --yes — never calls readline on a null interface.
@@ -203,7 +211,7 @@ async function upgrade(args) {
203
211
  }
204
212
  console.log('\n4/4 Verifying architecture…');
205
213
  return runArkCheck(
206
- ['--root', root, '--config', 'ark.config.json', '--strict-config'],
214
+ ['--root', root, '--config', 'ark.config.json', '--strict-merge'],
207
215
  { cwd: root }
208
216
  );
209
217
  }
@@ -365,6 +373,9 @@ async function init(args) {
365
373
  if (installGates) {
366
374
  const gateArgs = ['--root', root, '--install-agent-gates'];
367
375
  if (args.tools) gateArgs.push('--tools', args.tools);
376
+ if (args.requireWriteHook) {
377
+ gateArgs.push('--require-write-hook', args.requireWriteHook);
378
+ }
368
379
  if (args.force) gateArgs.push('--force');
369
380
  const status = runArkCheck(gateArgs, { cwd: root });
370
381
  if (status !== 0) return status;
@@ -374,14 +385,15 @@ async function init(args) {
374
385
  args.strict &&
375
386
  (nonInteractive || (await askYesNo(rl, 'Run strict architecture check now?', true)));
376
387
  if (runStrict) {
377
- return runArkCheck(
378
- ['--root', root, '--config', 'ark.config.json', '--strict-config'],
379
- { cwd: root }
380
- );
388
+ const strictArgs = ['--root', root, '--config', 'ark.config.json', '--strict-merge'];
389
+ if (args.requireWriteHook) {
390
+ strictArgs.push('--require-write-hook', args.requireWriteHook);
391
+ }
392
+ return runArkCheck(strictArgs, { cwd: root });
381
393
  }
382
394
 
383
395
  console.log(
384
- `Ark init complete. Run \`${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}\` before merging.`
396
+ `Ark init complete. Run \`${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-merge')}\` before merging.`
385
397
  );
386
398
  if (archetype) {
387
399
  console.log(`Shape: ${archetype}. Plan: ${arkCommand(root, 'ark-check', '--recommend')}`);
@@ -526,8 +538,12 @@ async function start(args) {
526
538
  {
527
539
  const gateArgs = ['--root', root, '--install-agent-gates'];
528
540
  if (args.tools) gateArgs.push('--tools', args.tools);
541
+ if (args.requireWriteHook) {
542
+ gateArgs.push('--require-write-hook', args.requireWriteHook);
543
+ }
529
544
  if (args.force) gateArgs.push('--force');
530
- runArkCheck(gateArgs, { cwd: root });
545
+ const status = runArkCheck(gateArgs, { cwd: root });
546
+ if (status !== 0) return status;
531
547
  }
532
548
 
533
549
  // 6) Show the plan: what's safe to auto-fix vs what needs a decision.
@@ -664,7 +680,7 @@ async function start(args) {
664
680
  console.log(' → explore first, dual plan (remediation + pattern bets), safe fixes, leave gates on.');
665
681
  }
666
682
  console.log(` 2. Status anytime: ${arkCommand(root, 'ark-check', '--doctor')}`);
667
- console.log(` 3. After edits: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
683
+ console.log(` 3. After edits: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-merge')}`);
668
684
  if (mode === 'adapt' && planOk && !falseGreenGap) {
669
685
  console.log(
670
686
  ` 4. When green but cores still optional: ${arkCommand(root, 'ark-check', '--ratchet-cores')} → honest ENFORCE`
@@ -696,6 +712,25 @@ async function main() {
696
712
  return 0;
697
713
  }
698
714
 
715
+ if (args.requireWriteHook && !['start', 'init'].includes(args.command)) {
716
+ console.error('--require-write-hook is supported by ark start and ark init.');
717
+ return 2;
718
+ }
719
+ const enforcement = validateHardWriteRequest({
720
+ root: args.root,
721
+ host: args.requireWriteHook,
722
+ tools: args.tools,
723
+ force: args.force,
724
+ });
725
+ if (!enforcement.ok) {
726
+ console.error(enforcement.error);
727
+ return 2;
728
+ }
729
+ if (enforcement.host) {
730
+ args.requireWriteHook = enforcement.host;
731
+ if (!args.tools) args.tools = enforcement.tools.join(',');
732
+ }
733
+
699
734
  if (args.command === 'start') {
700
735
  try {
701
736
  return await start(args);
@@ -76,6 +76,8 @@ export {
76
76
  normalizeToolsList,
77
77
  resolveTools,
78
78
  KNOWN_TOOLS,
79
+ detectActiveAgentHost,
80
+ codexConcernIsActive,
79
81
  arkPackageVersion,
80
82
  stampSkill,
81
83
  installedSkillVersion,
@@ -95,6 +97,16 @@ export {
95
97
  collectAdoptionGaps,
96
98
  } from './mcp-adoption.mjs';
97
99
 
100
+ export {
101
+ detectPreCommitArk,
102
+ detectCiEnforcement,
103
+ detectConfigGateDrift,
104
+ jobIdsThatRunArkCheck,
105
+ isArkRequiredStatusCheck,
106
+ reportGithubBranchProtection,
107
+ collectWeakestLinkGaps,
108
+ } from './weakest-link.mjs';
109
+
98
110
  export {
99
111
  staleRunnerGateFiles,
100
112
  warnLockfileConflict,
@@ -92,6 +92,14 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
92
92
  }
93
93
  }
94
94
 
95
+ if (
96
+ ts.isImportEqualsDeclaration(node) &&
97
+ ts.isExternalModuleReference(node.moduleReference)
98
+ ) {
99
+ const specifier = stringLiteralText(ts, node.moduleReference.expression);
100
+ if (specifier) checkModuleEdge(specifier, node, 'require');
101
+ }
102
+
95
103
  if (ts.isCallExpression(node)) {
96
104
  const moduleCall = moduleSpecifierFromCall(ts, node);
97
105
  if (moduleCall) {
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_LAYER_DIRECTORIES,
13
13
  } from '../ark-shared.mjs';
14
14
  import { falseGreenAdoptionGap } from './field-install.mjs';
15
+ import { renderHostSupportMatrixMarkdown } from './host-support-matrix.mjs';
15
16
  import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
16
17
  import { readPackageJson } from './gate-files.mjs';
17
18
 
@@ -30,7 +31,7 @@ export function checkArgsForRoot(root, { requireGates = false } = {}) {
30
31
  const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
31
32
  ? ' --baseline .ark-baseline.json'
32
33
  : '';
33
- const profile = requireGates ? '--strict' : '--strict-config';
34
+ const profile = requireGates ? '--strict-merge' : '--strict-config';
34
35
  return `--root . --config ark.config.json ${profile}${baselineFlag}`;
35
36
  }
36
37
 
@@ -140,6 +141,10 @@ export function agentInstructions(root) {
140
141
  Skills are **dual-engine**: deterministic CLI sensors + exploratory read of *this* repo — not JSON-only wrappers.
141
142
  When a skill says **STOP — do not continue this skill as complete**, stop and invoke the named handoff skill.
142
143
 
144
+ ## Host enforcement support
145
+
146
+ ${renderHostSupportMatrixMarkdown()}
147
+
143
148
  ### Subagent fan-out
144
149
  If the host supports **parallel subagents**, skills may ask you to fan out **read-only**
145
150
  scouts (disjoint path scopes) and merge in the parent. If the host does **not**,
@@ -177,8 +182,9 @@ an ungoverned location:
177
182
 
178
183
  ${layerPlacementTable()}
179
184
 
180
- The project is only considered Ark-enforced when the write gate and CI gate pass
181
- (runtime path only if this project opted into the kernel).
185
+ The project is only considered Ark-enforced when its host-appropriate write path is configured
186
+ and the CI check passes. Only Claude/Grok provide a hard local write boundary; Cursor/Codex use
187
+ advisory MCP plus CI. The experimental runtime is not required.
182
188
  `;
183
189
  }
184
190
 
@@ -22,6 +22,13 @@ export function codexConfigPath() {
22
22
  return path.join(base, 'config.toml');
23
23
  }
24
24
 
25
+ /** True when CODEX_HOME is unset/empty or resolves to the real default ~/.codex. */
26
+ export function usesDefaultCodexHome(env = process.env, homeDir = os.homedir()) {
27
+ const configured = env?.CODEX_HOME;
28
+ if (typeof configured !== 'string' || configured.trim() === '') return true;
29
+ return path.resolve(configured) === path.resolve(homeDir, '.codex');
30
+ }
31
+
25
32
  /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
26
33
  export function isTempOrUpgradeRoot(p) {
27
34
  if (!p || typeof p !== 'string') return false;