ark-runtime-kernel 1.3.0 → 1.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.
package/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  # 🏛️ Ark — Architectural Runtime Kernel
4
4
 
5
5
  **Stop AI agents (and humans) from quietly breaking your architecture.**<br/>
6
- One machine-readable contract — enforced at write time, merge time, and (optionally) runtime.
6
+ One machine-readable contract — enforced at write time, merge time, and (optionally) runtime.<br/>
7
+ Ships a complete 11-layer architecture you can adopt one layer at a time.
8
+ Gates **Claude Code, Cursor, and Codex** natively — plus rule files for Windsurf, Cline, Copilot, Kiro, and Gemini CLI.
7
9
 
8
10
  [![CI](https://github.com/pedroknigge/ark-runtime-kernel/actions/workflows/ci.yml/badge.svg)](https://github.com/pedroknigge/ark-runtime-kernel/actions/workflows/ci.yml)
9
11
  [![npm](https://img.shields.io/npm/v/ark-runtime-kernel?color=cb3837&label=npm)](https://www.npmjs.com/package/ark-runtime-kernel)
@@ -12,7 +14,7 @@ One machine-readable contract — enforced at write time, merge time, and (optio
12
14
  ![TypeScript](https://img.shields.io/badge/TypeScript-first-3178c6?logo=typescript&logoColor=white)
13
15
  ![Zero deps](https://img.shields.io/badge/dependencies-0-success)
14
16
 
15
- [2-Minute Setup](#2-minute-setup) · [Why Ark](#why-ark-and-not-just-a-linter) · [AI Write Gate](#the-ai-write-gate) · [CI Gate](#ark-check--the-ci-gate) · [Runtime Kernel](#the-runtime-kernel-opt-in) · [Docs](#documentation)
17
+ [2-Minute Setup](#2-minute-setup) · [Why Ark](#why-ark-and-not-just-a-linter) · [11 Layers](#batteries-included-the-11-layer-profile-all-optional) · [AI Write Gate](#the-ai-write-gate) · [CI Gate](#ark-check--the-ci-gate) · [Runtime Kernel](#the-runtime-kernel-opt-in) · [Docs](#documentation)
16
18
 
17
19
  </div>
18
20
 
@@ -124,6 +126,8 @@ If you only need import-boundary linting in CI, [dependency-cruiser](https://git
124
126
  | Cross-layer import checks in CI | ✅ (TS resolver) | ✅ | ✅ | ✅ |
125
127
  | Blocks AI agents **before** code lands (MCP + hook) | ✅ | ❌ | ❌ | ❌ |
126
128
  | Machine-readable contract for agents (`ark://manifest`) | ✅ | ❌ | ❌ | ❌ |
129
+ | Injects the contract into agent context at session start | ✅ | ❌ | ❌ | ❌ |
130
+ | Forbidden ambient globals per layer (`Date.now` in domain, ...) | ✅ | ❌ | ➖ (generic ESLint) | ❌ |
127
131
  | Event/intent governance (who may publish what) | ✅ | ❌ | ❌ | ❌ |
128
132
  | Baseline ratchet for existing codebases | ✅ | ❌ | ➖ (via ESLint) | ❌ |
129
133
  | Optional runtime enforcement | ✅ | ❌ | ❌ | ❌ |
@@ -133,19 +137,57 @@ If you only need import-boundary linting in CI, [dependency-cruiser](https://git
133
137
 
134
138
  | Gate | Tool | When it runs | What it enforces |
135
139
  |--------------|---------------|-------------------------------|-----------------------------------------------|
136
- | **Write** | `ark-mcp` | Agent PreToolUse (Write/Edit) | Layer rules, unknown intents, forbidden patterns |
137
- | **Merge** | `ark-check` | CI (GitHub Actions etc.) | Cross-layer imports + intent references (real TS resolver) |
140
+ | **Write** | `ark-mcp` | Agent PreToolUse (Write/Edit) | Layer rules, unknown intents, forbidden patterns + globals |
141
+ | **Merge** | `ark-check` | CI (GitHub Actions etc.) | Cross-layer imports + intent references (real TS resolver) + forbidden globals |
138
142
  | **Runtime** | `createArkKernel()` | Running process (opt-in) | Intent registry, event contracts, observed layer flow, policies |
139
143
 
144
+ ## Batteries included: the 11-layer profile (all optional)
145
+
146
+ You don't have to design a layer model before adopting Ark — it ships a complete,
147
+ production-shaped division for Hexagonal + Event-Driven + DDD systems. Every layer is
148
+ **optional by design**: on a fresh project the strict check passes immediately, and each
149
+ layer starts being enforced the moment its directory gains source files. Adopt two
150
+ layers or all eleven; `ark.config.json` is always authoritative and you can rename,
151
+ remove, or re-map any of it.
152
+
153
+ | Layer | Conventional directories | Intent prefixes |
154
+ |-------|--------------------------|-----------------|
155
+ | DomainModel | `domain/` | `Domain.` |
156
+ | ApplicationOrchestration | `application/`, `app/` | `Application.` |
157
+ | PersistenceAdapters | `adapters/persistence/`, `repositories/`, ... | `Adapter.Persistence.`, `Adapter.Repository.` |
158
+ | IntegrationAdapters | `adapters/integration/`, `integrations/`, ... | `Adapter.Integration.`, `Adapter.External.` |
159
+ | WorkflowSagaEngine | `workflows/`, `sagas/` | `Workflow.` |
160
+ | BackgroundJobsScheduling | `jobs/`, `schedules/` | `Job.` |
161
+ | PresentationAdapters | `presentation/`, `adapters/api/`, ... | `Presentation.`, `Adapter.Api.`, ... |
162
+ | ReportingReadModels | `reporting/`, `read-models/`, `projections/` | `Reporting.` |
163
+ | ExtensibilityMetadata | `metadata/`, `extensions/` | `Metadata.` |
164
+ | SecurityAuditObservability | `security/`, `audit/`, `observability/` | `Security.`, `Audit.`, `Observability.` |
165
+ | Kernel | `kernel/` | `Kernel.` |
166
+
167
+ The default rule matrix is strict-deny: only the classic flows are open
168
+ (Presentation→Application, Application→Domain, Workflow→Application/Domain,
169
+ Jobs→Application) and everything else is a violation until you allow it explicitly.
170
+ The profile isn't just for the linter — agents get it too: `ark://manifest` lists the
171
+ layers your project hasn't adopted yet as `suggestedLayers`, so when an agent needs to
172
+ create its first saga or background job, it puts it in the conventional place and adds
173
+ the layer to the config instead of inventing an ungoverned location.
174
+
175
+ ```bash
176
+ npx ark-check --print-config eleven-layer > ark.config.json # the full profile, ready to edit
177
+ ```
178
+
140
179
  ## The AI Write Gate
141
180
 
142
181
  `ark-mcp` is a zero-dependency MCP server + one-shot hook:
143
182
 
144
183
  - **`ark-mcp --hook`** — PreToolUse gate: computes the **post-edit** file content, validates it against your layers, exits 2 with the violations when the write must be blocked. The agent self-corrects.
184
+ - **`ark-mcp --session-context`** — SessionStart injection: prints a compact contract summary (layers, forbidden globals, baseline state) into the agent's context, so it knows the architecture from the first token instead of learning by rejection. Silent no-op outside Ark projects, so it can't leak into other repos.
145
185
  - **`validate_code` tool** — on-demand validation of a snippet, for runtimes without hooks.
146
- - **`ark://manifest` resource** — the architecture as JSON, so agents read the rules *before* generating code instead of learning by rejection.
186
+ - **`ark://manifest` resource** — the architecture as JSON, so agents read the rules *before* generating code.
147
187
 
148
- Copy-paste setups for **Claude Code, Cursor, and OpenAI Codex**: [docs/ai-gates.md](docs/ai-gates.md).
188
+ Copy-paste setups for **Claude Code, Cursor, and OpenAI Codex**, plus instruction-tier
189
+ rule files for **Windsurf, Cline, GitHub Copilot, and Kiro** (Gemini CLI reads the
190
+ generated `AGENTS.md` directly): [docs/ai-gates.md](docs/ai-gates.md).
149
191
 
150
192
  ## `ark-check` — The CI Gate
151
193
 
@@ -161,6 +203,7 @@ npx ark-check --baseline # ratchet mode
161
203
  - String intent references across forbidden layers
162
204
  - Raw `publish()` calls that bypass registered intent creators
163
205
  - Missing / mismatched publish `source` metadata
206
+ - Forbidden ambient globals per layer (`fetch`, `Date.now`, `Math.random`, ...) — see below
164
207
 
165
208
  Violations come with the layer edge, the resolved target, and a fix hint:
166
209
 
@@ -171,6 +214,35 @@ Violations come with the layer edge, the resolved target, and a fix hint:
171
214
  fix: Depend on a port/interface owned by an inner layer instead, or move this code.
172
215
  ```
173
216
 
217
+ ### Domain purity: `forbiddenGlobals`
218
+
219
+ Import rules can't catch code that reaches for an ambient global — an agent can call
220
+ `fetch()` or `Date.now()` in your domain layer without importing anything. Declare the
221
+ globals a layer must not touch and both the write gate and CI enforce it:
222
+
223
+ ```jsonc
224
+ // ark.config.json
225
+ {
226
+ "name": "DomainModel",
227
+ "patterns": ["src/domain/**"],
228
+ "intentPrefixes": ["Domain."],
229
+ "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"]
230
+ }
231
+ ```
232
+
233
+ ```
234
+ ✖ FORBIDDEN_GLOBAL src/domain/order.ts:12
235
+ DomainModel must not use the ambient global "Date.now".
236
+ fix: Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort).
237
+ ```
238
+
239
+ Entries are either dotted (`"Date.now"` flags exactly that property access) or bare
240
+ (`"console"` flags `console.*`, `fetch(...)`, `new WebSocket(...)`). Detection is
241
+ positional, not scope-aware: mentions in types or import names are never flagged.
242
+ `npx ark init` seeds the domain layer with `["fetch", "process", "Date.now", "Math.random"]`
243
+ (a pure domain does no I/O and is deterministic); add `"console"` or any other global per
244
+ project. Violations participate in the `--baseline` ratchet like every other rule.
245
+
174
246
  ### GitHub Action
175
247
 
176
248
  ```yaml
@@ -189,7 +261,15 @@ import ark from 'ark-runtime-kernel/eslint';
189
261
  export default [ark.configs.recommended];
190
262
  ```
191
263
 
192
- Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source`.
264
+ Rules: `ark/no-domain-infra-imports`, `ark/no-raw-event-publish`, `ark/require-publish-source`,
265
+ `ark/no-forbidden-globals` (not in `recommended` — scope it to your layer directories):
266
+
267
+ ```js
268
+ {
269
+ files: ['src/domain/**'],
270
+ rules: { 'ark/no-forbidden-globals': ['error', { globals: ['fetch', 'process', 'Date.now', 'Math.random'] }] },
271
+ }
272
+ ```
193
273
 
194
274
  ## The Runtime Kernel (opt-in)
195
275
 
package/bin/ark-check.mjs CHANGED
@@ -2,9 +2,11 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {
5
+ DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
5
6
  DEFAULT_INTENT_PREFIXES,
6
7
  DEFAULT_LAYER_DIRECTORIES,
7
8
  DEFAULT_RULES,
9
+ collectForbiddenGlobalUses,
8
10
  createElevenLayerConfig,
9
11
  globToRegExp,
10
12
  layerForFile,
@@ -96,7 +98,8 @@ function usage() {
96
98
  '{',
97
99
  ' "include": ["src"],',
98
100
  ' "layers": [',
99
- ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."] }',
101
+ ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
102
+ ' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
100
103
  ' ],',
101
104
  ' "rules": [{ "from": "DomainModel", "to": "PersistenceAdapters", "allowed": false }]',
102
105
  '}',
@@ -109,9 +112,13 @@ function usage() {
109
112
  'with --strict-config to enforce gate presence and architecture in one run.',
110
113
  '',
111
114
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
112
- 'project, plus tool-specific templates. Pass --tools claude,cursor,codex to pick',
113
- 'which tool configs to write; otherwise they are auto-detected from .claude/, .cursor/,',
114
- 'and .codex/ (all are written when nothing is detected).',
115
+ 'project, plus tool-specific templates. Known tools: claude, cursor, codex (full',
116
+ 'MCP/hook gates) and windsurf, cline, copilot, kiro (instruction-tier rule files',
117
+ 'derived from the same contract; Gemini CLI needs no template — it reads AGENTS.md).',
118
+ 'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
119
+ 'from their config directories (.claude/, .cursor/, .codex/, .windsurf/, .clinerules/,',
120
+ '.kiro/; copilot is explicit-only). claude+cursor+codex are written when nothing is',
121
+ 'detected.',
115
122
  '',
116
123
  'Generate a starter 11-layer config:',
117
124
  ' ark-check --print-config eleven-layer > ark.config.json',
@@ -192,6 +199,9 @@ function detectConfig(root) {
192
199
  name: entry.layer,
193
200
  patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
194
201
  intentPrefixes: entry.prefixes,
202
+ ...(entry.layer === 'DomainModel'
203
+ ? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
204
+ : {}),
195
205
  });
196
206
  }
197
207
 
@@ -415,6 +425,25 @@ args = ["ark-mcp", "--root", ".", "--config", "ark.config.json"]
415
425
  `;
416
426
  }
417
427
 
428
+ /**
429
+ * Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
430
+ * Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
431
+ * Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
432
+ * can never drift; points at AGENTS.md for the full placement table.
433
+ */
434
+ function instructionRule() {
435
+ const steps = AGENT_CONTRACT.steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
436
+ return `# Ark architecture contract
437
+
438
+ This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
439
+ Before writing or editing TypeScript or JavaScript source files:
440
+
441
+ ${steps}
442
+
443
+ See \`AGENTS.md\` for the full contract and the layer placement table.
444
+ `;
445
+ }
446
+
418
447
  function cursorRule() {
419
448
  return `---
420
449
  description: Ark architecture contract
@@ -460,6 +489,20 @@ ${setupSteps ? `${setupSteps}\n` : ''} - run: ${pm.install}
460
489
  function claudeSettings() {
461
490
  return `${JSON.stringify({
462
491
  hooks: {
492
+ // Inject the contract at session start so the agent knows the architecture from
493
+ // the first token. Project-scoped by design; --session-context is also a silent
494
+ // no-op when no ark.config.json exists, so it can never leak into other projects.
495
+ SessionStart: [
496
+ {
497
+ hooks: [
498
+ {
499
+ type: 'command',
500
+ command:
501
+ 'npx ark-mcp --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json',
502
+ },
503
+ ],
504
+ },
505
+ ],
463
506
  PreToolUse: [
464
507
  {
465
508
  matcher: 'Write|Edit|MultiEdit',
@@ -484,10 +527,17 @@ function resolveTools(args) {
484
527
  const detected = new Set();
485
528
  if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
486
529
  if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
487
- if (fs.existsSync(path.join(root, '.codex'))) {
488
- detected.add('codex');
530
+ if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
531
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
532
+ // .clinerules can also be a single FILE (older Cline convention); only a directory
533
+ // can receive .clinerules/ark.md, so a file must not trigger detection.
534
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
535
+ detected.add('cline');
489
536
  }
490
- // No signal at all: fall back to writing every tool's templates so a fresh
537
+ if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
538
+ // copilot has no reliable directory signal (.github exists in most repos),
539
+ // so it is explicit-only via --tools.
540
+ // No signal at all: fall back to writing the primary tools' templates so a fresh
491
541
  // project still gets a complete, reviewable starter set.
492
542
  if (detected.size === 0) {
493
543
  return new Set(['claude', 'cursor', 'codex']);
@@ -495,7 +545,7 @@ function resolveTools(args) {
495
545
  return detected;
496
546
  }
497
547
 
498
- const KNOWN_TOOLS = ['claude', 'cursor', 'codex'];
548
+ const KNOWN_TOOLS = ['claude', 'cursor', 'codex', 'windsurf', 'cline', 'copilot', 'kiro'];
499
549
 
500
550
  function runInstallAgentGates(args) {
501
551
  const root = args.root;
@@ -529,6 +579,19 @@ function runInstallAgentGates(args) {
529
579
  if (tools.has('codex')) {
530
580
  templates.push(['docs/ark-codex-config.toml', codexTomlSnippet()]);
531
581
  }
582
+ // Instruction-tier hosts: one shared rule text, host-specific path.
583
+ if (tools.has('windsurf')) {
584
+ templates.push(['.windsurf/rules/ark.md', instructionRule()]);
585
+ }
586
+ if (tools.has('cline')) {
587
+ templates.push(['.clinerules/ark.md', instructionRule()]);
588
+ }
589
+ if (tools.has('copilot')) {
590
+ templates.push(['.github/copilot-instructions.md', instructionRule()]);
591
+ }
592
+ if (tools.has('kiro')) {
593
+ templates.push(['.kiro/steering/ark.md', instructionRule()]);
594
+ }
532
595
 
533
596
  const results = templates.map(([relativePath, content]) =>
534
597
  writeTemplate(root, relativePath, content, args.force)
@@ -652,6 +715,20 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
652
715
  if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
653
716
  seenLayers.add(layer.name);
654
717
 
718
+ if (
719
+ layer.forbiddenGlobals !== undefined &&
720
+ (!Array.isArray(layer.forbiddenGlobals) ||
721
+ layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
722
+ ) {
723
+ warnings.push(
724
+ configWarning(
725
+ 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
726
+ `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
727
+ { layer: layer.name }
728
+ )
729
+ );
730
+ }
731
+
655
732
  const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
656
733
  if (patterns.length === 0) {
657
734
  warnings.push(
@@ -931,7 +1008,7 @@ function publishHasSource(ts, node) {
931
1008
  );
932
1009
  }
933
1010
 
934
- // ponytail: baseline keys exclude the line number so unrelated edits that shift lines
1011
+ // Baseline keys exclude the line number so unrelated edits that shift lines
935
1012
  // don't resurrect frozen violations; the trade-off is that N identical violations in one
936
1013
  // file collapse to one key.
937
1014
  function baselineKey(violation) {
@@ -981,6 +1058,8 @@ const FIX_HINTS = {
981
1058
  'Add metadata.source (the publishing intent name) to the publish call.',
982
1059
  PUBLISH_SOURCE_LAYER_MISMATCH:
983
1060
  'Use a source intent that belongs to the same layer as the publishing file, or move the file.',
1061
+ FORBIDDEN_GLOBAL:
1062
+ 'Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort) instead of reaching for the ambient global.',
984
1063
  };
985
1064
 
986
1065
  function printViolation(violation) {
@@ -1093,6 +1172,21 @@ async function main() {
1093
1172
  const sourceLayer = layerForFile(root, file, config.layers);
1094
1173
  if (!sourceLayer) continue;
1095
1174
 
1175
+ const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
1176
+ const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
1177
+ ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
1178
+ : [];
1179
+ for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
1180
+ violations.push({
1181
+ ruleId: 'FORBIDDEN_GLOBAL',
1182
+ file: normalize(path.relative(root, file)),
1183
+ line: lineOf(sourceFile, use.node.getStart(sourceFile)),
1184
+ fromLayer: sourceLayer,
1185
+ target: use.name,
1186
+ message: `${sourceLayer} must not use the ambient global "${use.name}".`,
1187
+ });
1188
+ }
1189
+
1096
1190
  const checkModuleEdge = (specifier, node, kind) => {
1097
1191
  const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
1098
1192
  const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
package/bin/ark-mcp.mjs CHANGED
@@ -21,6 +21,11 @@
21
21
  * stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
22
22
  * 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
23
23
  * the copy-paste integration for agent runtimes whose hooks run shell commands.
24
+ *
25
+ * --session-context runs one-shot and prints a compact contract summary (layers, rule
26
+ * count, forbidden globals, baseline state, check command) to stdout. Bind it to a
27
+ * SessionStart hook so the agent has the architecture in context from the first token,
28
+ * instead of learning it by rejection.
24
29
  */
25
30
  import fs from 'node:fs';
26
31
  import path from 'node:path';
@@ -39,10 +44,12 @@ function parseArgs(argv) {
39
44
  configExplicit: false,
40
45
  manifest: undefined,
41
46
  hook: false,
47
+ sessionContext: false,
42
48
  };
43
49
  for (let i = 2; i < argv.length; i += 1) {
44
50
  const a = argv[i];
45
51
  if (a === '--hook') args.hook = true;
52
+ else if (a === '--session-context') args.sessionContext = true;
46
53
  else if (a === '--root') args.root = path.resolve(argv[++i]);
47
54
  else if (a === '--config') {
48
55
  args.config = argv[++i];
@@ -168,7 +175,27 @@ function runHook(gate, config, args) {
168
175
  const result = gate.validate(source, { layer, filePath });
169
176
  if (result.valid) return;
170
177
 
171
- const lines = result.violations.map(
178
+ // Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
179
+ // when it ADDS violations relative to the file's current on-disk state. Otherwise a
180
+ // pre-existing violation — frozen in a baseline or predating Ark adoption — would make
181
+ // every subsequent edit to that file un-writable while CI passes. Keys ignore line
182
+ // numbers (edits shift them) and collapse duplicates, mirroring ark-check's baselineKey.
183
+ const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
184
+ let existingKeys = new Set();
185
+ try {
186
+ const current = fs.readFileSync(filePath, 'utf8');
187
+ existingKeys = new Set(
188
+ gate.validate(current, { layer, filePath }).violations.map(violationKey)
189
+ );
190
+ } catch {
191
+ // New file: nothing pre-exists, every violation is new.
192
+ }
193
+ const newViolations = result.violations.filter(
194
+ (violation) => !existingKeys.has(violationKey(violation))
195
+ );
196
+ if (newViolations.length === 0) return;
197
+
198
+ const lines = newViolations.map(
172
199
  (violation) =>
173
200
  `- [${violation.ruleId}] ${violation.message}${violation.line ? ` (line ${violation.line})` : ''}`
174
201
  );
@@ -182,12 +209,65 @@ function runHook(gate, config, args) {
182
209
  process.exitCode = 2;
183
210
  }
184
211
 
212
+ /**
213
+ * One-shot SessionStart context: a compact summary of the contract on stdout so the
214
+ * agent starts the session already knowing the architecture. Advisory — never blocks
215
+ * and never exits non-zero for missing optional inputs (e.g. no baseline file).
216
+ */
217
+ function printSessionContext(config, profile, forbiddenGlobals, args) {
218
+ const lines = ['Ark architecture contract governs this project (ark.config.json is authoritative).'];
219
+
220
+ const configLayers = Array.isArray(config.layers) ? config.layers : [];
221
+ if (configLayers.length > 0) {
222
+ lines.push('Layers:');
223
+ for (const layer of configLayers) {
224
+ const globals = forbiddenGlobals[layer.name];
225
+ const globalsNote = globals ? ` — forbidden globals: ${globals.join(', ')}` : '';
226
+ lines.push(` - ${layer.name}: ${(layer.patterns ?? []).join(', ')}${globalsNote}`);
227
+ }
228
+ } else {
229
+ lines.push(
230
+ `Layers: none configured — the default 11-layer profile applies to intent references.`
231
+ );
232
+ }
233
+
234
+ const denied = (profile.rules ?? []).filter((rule) => !rule.allowed).length;
235
+ lines.push(
236
+ `Rules: ${denied} denied layer edge(s). Full contract: ark://manifest MCP resource.`
237
+ );
238
+
239
+ // Advisory output: a malformed baseline must not abort the summary.
240
+ let baseline;
241
+ try {
242
+ baseline = readJson(path.join(args.root, '.ark-baseline.json'));
243
+ } catch {
244
+ baseline = undefined;
245
+ }
246
+ if (Array.isArray(baseline?.violations)) {
247
+ lines.push(
248
+ `Baseline: ${baseline.violations.length} frozen violation(s) — only NEW violations fail; do not add to them.`
249
+ );
250
+ }
251
+
252
+ lines.push('After edits run: npx ark-check --root . --config ark.config.json --strict-config');
253
+ lines.push('If Ark reports violations, fix the architecture instead of weakening the gate.');
254
+ process.stdout.write(`${lines.join('\n')}\n`);
255
+ }
256
+
185
257
  async function main() {
186
258
  const args = parseArgs(process.argv);
259
+ const configPath = resolveInRoot(args.root, args.config);
260
+
261
+ // SessionStart contract injection is only meaningful in Ark-governed projects. Bail
262
+ // out silently (before loading dist) when there is no config, so the hook is safe
263
+ // even if a user installs it in their GLOBAL settings instead of per-project.
264
+ if (args.sessionContext && !(configPath && fs.existsSync(configPath))) {
265
+ return;
266
+ }
267
+
187
268
  const ark = await loadArk();
188
269
  const ts = await loadOptionalTypeScript();
189
270
 
190
- const configPath = resolveInRoot(args.root, args.config);
191
271
  const config =
192
272
  (configPath ? readJson(configPath, { required: args.configExplicit }) : undefined) ?? {
193
273
  include: ['src'],
@@ -248,11 +328,28 @@ async function main() {
248
328
  });
249
329
  }
250
330
 
331
+ // Layer → forbidden ambient globals, straight from ark.config.json. Enforced by the
332
+ // gate only when the target file's layer is known (same data ark-check enforces in CI).
333
+ const forbiddenGlobals = Object.fromEntries(
334
+ configLayers
335
+ .filter(
336
+ (layer) =>
337
+ layer.name &&
338
+ Array.isArray(layer.forbiddenGlobals) &&
339
+ layer.forbiddenGlobals.some((entry) => typeof entry === 'string')
340
+ )
341
+ .map((layer) => [
342
+ layer.name,
343
+ layer.forbiddenGlobals.filter((entry) => typeof entry === 'string'),
344
+ ])
345
+ );
346
+
251
347
  const gate = ark.createAICodeGate({
252
348
  architectureProfile: profile,
253
349
  intents,
254
350
  enforceIntentAllowlist: intents.length > 0,
255
351
  typescript: ts,
352
+ forbiddenGlobals,
256
353
  });
257
354
 
258
355
  if (args.hook) {
@@ -260,6 +357,11 @@ async function main() {
260
357
  return;
261
358
  }
262
359
 
360
+ if (args.sessionContext) {
361
+ printSessionContext(config, profile, forbiddenGlobals, args);
362
+ return;
363
+ }
364
+
263
365
  const SERVER_INFO = { name: 'ark-runtime-kernel', version: ark.version };
264
366
  const DEFAULT_PROTOCOL = '2024-11-05';
265
367
 
@@ -342,6 +444,7 @@ async function main() {
342
444
  name: profile.name,
343
445
  layers: profile.layers,
344
446
  rules: profile.rules,
447
+ ...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
345
448
  ...(suggestions.length > 0
346
449
  ? {
347
450
  suggestedLayers: suggestions,
@@ -69,6 +69,13 @@ export const DEFAULT_RULES = createStrictDenyRules(
69
69
  DEFAULT_ALLOWED_FLOWS
70
70
  );
71
71
 
72
+ /**
73
+ * Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
74
+ * deterministic. `console` is deliberately omitted (too common during adoption); add it per
75
+ * project via the layer's `forbiddenGlobals` in ark.config.json.
76
+ */
77
+ export const DEFAULT_DOMAIN_FORBIDDEN_GLOBALS = ['fetch', 'process', 'Date.now', 'Math.random'];
78
+
72
79
  export function createElevenLayerConfig(options = {}) {
73
80
  const rootDir = options.rootDir ?? 'src';
74
81
  const optional = options.optionalLayers ?? true;
@@ -82,11 +89,55 @@ export function createElevenLayerConfig(options = {}) {
82
89
  ),
83
90
  intentPrefixes: entry.prefixes,
84
91
  optional,
92
+ ...(entry.layer === 'DomainModel'
93
+ ? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
94
+ : {}),
85
95
  })),
86
96
  rules: DEFAULT_RULES,
87
97
  };
88
98
  }
89
99
 
100
+ /**
101
+ * Find uses of forbidden ambient globals in a TypeScript source file.
102
+ *
103
+ * Detection is deliberately positional, not scope-aware (kept in sync with
104
+ * `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts — the CLIs must not
105
+ * import from dist):
106
+ * - a dotted entry ("Date.now") flags `Date.now` property accesses
107
+ * - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
108
+ * direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
109
+ * Bare identifier mentions in other positions (types, shadowed locals, import names) are
110
+ * NOT flagged, trading a little recall for near-zero false positives without a type checker.
111
+ *
112
+ * Returns [{ name, node }] where `name` is the matched forbidden entry.
113
+ */
114
+ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
115
+ const entries = new Set(forbidden ?? []);
116
+ if (entries.size === 0) return [];
117
+ const uses = [];
118
+
119
+ const visit = (node) => {
120
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
121
+ const dotted = `${node.expression.text}.${node.name.text}`;
122
+ if (entries.has(dotted)) {
123
+ uses.push({ name: dotted, node });
124
+ } else if (entries.has(node.expression.text)) {
125
+ uses.push({ name: node.expression.text, node });
126
+ }
127
+ } else if (
128
+ (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
129
+ node.expression &&
130
+ ts.isIdentifier(node.expression) &&
131
+ entries.has(node.expression.text)
132
+ ) {
133
+ uses.push({ name: node.expression.text, node });
134
+ }
135
+ ts.forEachChild(node, visit);
136
+ };
137
+ visit(sourceFile);
138
+ return uses;
139
+ }
140
+
90
141
  const _regexpCache = new Map();
91
142
 
92
143
  function escapeLiteral(ch) {
@@ -122,10 +122,57 @@ var requirePublishSource = {
122
122
  };
123
123
  }
124
124
  };
125
+ var DEFAULT_FORBIDDEN_GLOBALS = ["fetch", "process", "Date.now", "Math.random"];
126
+ var noForbiddenGlobals = {
127
+ meta: {
128
+ type: "problem",
129
+ docs: {
130
+ description: 'Disallow ambient globals (e.g. fetch, Date.now) in architecture-governed code; scope the rule to layer directories via ESLint "files" patterns.'
131
+ },
132
+ messages: {
133
+ forbiddenGlobal: 'Ambient global "{{name}}" is forbidden here; inject the capability through a port instead.'
134
+ },
135
+ schema: [
136
+ {
137
+ type: "object",
138
+ properties: {
139
+ globals: { type: "array", items: { type: "string" } }
140
+ },
141
+ additionalProperties: false
142
+ }
143
+ ]
144
+ },
145
+ create(context) {
146
+ const option = context.options?.[0];
147
+ const globals = new Set(option?.globals ?? DEFAULT_FORBIDDEN_GLOBALS);
148
+ const report = (node, name) => context.report({ node, messageId: "forbiddenGlobal", data: { name } });
149
+ return {
150
+ // Same positional detection as ark-check's FORBIDDEN_GLOBAL: property accesses on a
151
+ // forbidden base (console.log, Date.now), direct calls, and constructions. Bare
152
+ // identifier mentions elsewhere are not flagged (avoids shadowed-local false positives).
153
+ MemberExpression(node) {
154
+ const base = node.object?.type === "Identifier" ? node.object.name : void 0;
155
+ if (!base) return;
156
+ const dotted = `${base}.${propertyName(node.property) ?? ""}`;
157
+ if (globals.has(dotted)) report(node, dotted);
158
+ else if (globals.has(base)) report(node, base);
159
+ },
160
+ CallExpression(node) {
161
+ const callee = node.callee?.type === "Identifier" ? node.callee.name : void 0;
162
+ if (callee && globals.has(callee)) report(node, callee);
163
+ },
164
+ NewExpression(node) {
165
+ const callee = node.callee?.type === "Identifier" ? node.callee.name : void 0;
166
+ if (callee && globals.has(callee)) report(node, callee);
167
+ }
168
+ };
169
+ }
170
+ };
125
171
  var rules = {
126
172
  "no-domain-infra-imports": noDomainInfraImports,
127
173
  "no-raw-event-publish": noRawEventPublish,
128
- "require-publish-source": requirePublishSource
174
+ "require-publish-source": requirePublishSource,
175
+ "no-forbidden-globals": noForbiddenGlobals
129
176
  };
130
177
  var plugin = { rules };
131
178
  plugin.configs = {
@@ -142,6 +189,7 @@ var eslint_default = plugin;
142
189
 
143
190
  exports.default = eslint_default;
144
191
  exports.noDomainInfraImports = noDomainInfraImports;
192
+ exports.noForbiddenGlobals = noForbiddenGlobals;
145
193
  exports.noRawEventPublish = noRawEventPublish;
146
194
  exports.plugin = plugin;
147
195
  exports.requirePublishSource = requirePublishSource;