ark-runtime-kernel 1.1.0 → 1.3.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 +59 -2
- package/bin/ark-check.mjs +411 -24
- package/bin/ark-mcp.mjs +42 -1
- package/bin/ark-postinstall.mjs +12 -0
- package/bin/ark-shared.mjs +2 -1
- package/bin/ark.mjs +129 -0
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +353 -0
- package/docs/ai-gates.md +177 -0
- package/docs/ark-check-example.json +87 -0
- package/docs/assets/ark-write-gate.svg +28 -0
- package/docs/production-hardening.md +59 -0
- package/package.json +4 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -30,10 +30,19 @@ No code changes. No new runtime. Just a config and a CI line.
|
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
npm install -D ark-runtime-kernel typescript
|
|
33
|
-
npx ark
|
|
33
|
+
npx ark init # asks before generating config, agent gates, and CI templates
|
|
34
34
|
npx ark-check # done: cross-layer imports now fail the check
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
`ark init` detects your existing layer directories and suggests the missing ones from
|
|
38
|
+
Ark's default 11-layer profile (with their conventional directories), so you see the
|
|
39
|
+
full division before deciding what to adopt. On an empty project it generates the
|
|
40
|
+
complete profile with every layer optional: the check passes immediately, and each
|
|
41
|
+
layer starts being enforced as soon as its directory gains source files. Agents get
|
|
42
|
+
the same guidance — the `ark://manifest` resource includes `suggestedLayers`, and the
|
|
43
|
+
generated `AGENTS.md` carries the placement table, so an agent asked for a saga or a
|
|
44
|
+
background job knows where it belongs before writing it.
|
|
45
|
+
|
|
37
46
|
Adopting on a codebase that already has violations? Freeze them and ratchet down:
|
|
38
47
|
|
|
39
48
|
```bash
|
|
@@ -41,7 +50,9 @@ npx ark-check --update-baseline # writes .ark-baseline.json — commit it
|
|
|
41
50
|
npx ark-check --baseline # only NEW violations fail from now on
|
|
42
51
|
```
|
|
43
52
|
|
|
44
|
-
Then gate your agents (Claude Code shown; [Cursor / Codex / others](docs/ai-gates.md))
|
|
53
|
+
Then gate your agents (Claude Code shown; [Cursor / Codex / others](docs/ai-gates.md)). If you use
|
|
54
|
+
Codex in an Ark project, register the MCP server early so `ark://manifest` is available during
|
|
55
|
+
generation:
|
|
45
56
|
|
|
46
57
|
```json
|
|
47
58
|
// .claude/settings.json
|
|
@@ -58,6 +69,52 @@ Then gate your agents (Claude Code shown; [Cursor / Codex / others](docs/ai-gate
|
|
|
58
69
|
|
|
59
70
|
> The same `ark.config.json` powers every gate.
|
|
60
71
|
|
|
72
|
+
Or generate the starter agent and CI gate files:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx ark-check --install-agent-gates
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This writes opt-in templates for MCP discovery, Claude/Cursor rules, Codex config notes,
|
|
79
|
+
GitHub Actions, and agent instructions. Existing files are skipped unless you pass
|
|
80
|
+
`--force`.
|
|
81
|
+
|
|
82
|
+
The package `postinstall` only prints the next command; it never prompts or writes files
|
|
83
|
+
during `npm install`. Use `npx ark init --yes` for non-interactive setup.
|
|
84
|
+
|
|
85
|
+
### Updating Ark
|
|
86
|
+
|
|
87
|
+
For projects that already use Ark:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install -D ark-runtime-kernel@latest
|
|
91
|
+
npx ark-check --root . --config ark.config.json --strict-config
|
|
92
|
+
npm run check:architecture
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This updates the local `ark`, `ark-check`, and `ark-mcp` binaries used by npm scripts
|
|
96
|
+
and CI. `npm run check:architecture` is the recommended alias, but it is optional:
|
|
97
|
+
the direct `npx ark-check --root . --config ark.config.json --strict-config` command
|
|
98
|
+
is the real check and works even if the alias has not been added yet.
|
|
99
|
+
|
|
100
|
+
The lockfile controls the version CI gets, so commit the updated `package-lock.json`,
|
|
101
|
+
`pnpm-lock.yaml`, or `yarn.lock`.
|
|
102
|
+
|
|
103
|
+
Generated setup files are intentionally not rewritten during package updates:
|
|
104
|
+
`AGENTS.md`, MCP config, Claude/Cursor settings, Codex notes, and GitHub Actions
|
|
105
|
+
templates stay under your project's control. To add any new starter templates:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx ark-check --install-agent-gates
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Existing files are skipped. To regenerate them from the latest templates, review
|
|
112
|
+
your local changes first, then run:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npx ark-check --install-agent-gates --force
|
|
116
|
+
```
|
|
117
|
+
|
|
61
118
|
## Why Ark (and not just a linter)?
|
|
62
119
|
|
|
63
120
|
If you only need import-boundary linting in CI, [dependency-cruiser](https://github.com/sverweij/dependency-cruiser), [eslint-plugin-boundaries](https://github.com/javierbrea/eslint-plugin-boundaries), and Nx module boundaries are solid tools. Ark's reason to exist is the **write-time, agent-native half** they don't cover:
|
package/bin/ark-check.mjs
CHANGED
|
@@ -21,7 +21,10 @@ function parseArgs(argv) {
|
|
|
21
21
|
tsconfig: undefined,
|
|
22
22
|
json: false,
|
|
23
23
|
strictConfig: false,
|
|
24
|
+
requireGates: false,
|
|
24
25
|
init: false,
|
|
26
|
+
installAgentGates: false,
|
|
27
|
+
tools: undefined,
|
|
25
28
|
force: false,
|
|
26
29
|
baseline: undefined,
|
|
27
30
|
updateBaseline: false,
|
|
@@ -30,7 +33,23 @@ function parseArgs(argv) {
|
|
|
30
33
|
const arg = argv[i];
|
|
31
34
|
if (arg === '--json') args.json = true;
|
|
32
35
|
else if (arg === '--strict-config') args.strictConfig = true;
|
|
36
|
+
else if (arg === '--require-gates') args.requireGates = true;
|
|
33
37
|
else if (arg === '--init') args.init = true;
|
|
38
|
+
else if (arg === '--install-agent-gates') args.installAgentGates = true;
|
|
39
|
+
else if (arg === '--tools') {
|
|
40
|
+
// Consume the next arg only when it isn't another flag (same rule as --baseline),
|
|
41
|
+
// so `--tools --force` can't silently eat --force as a "tool name".
|
|
42
|
+
const next = argv[i + 1];
|
|
43
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
44
|
+
i += 1;
|
|
45
|
+
args.tools = next
|
|
46
|
+
.split(',')
|
|
47
|
+
.map((tool) => tool.trim().toLowerCase())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
} else {
|
|
50
|
+
args.tools = []; // flag without a value — rejected in runInstallAgentGates
|
|
51
|
+
}
|
|
52
|
+
}
|
|
34
53
|
else if (arg === '--force') args.force = true;
|
|
35
54
|
else if (arg === '--baseline' || arg === '--update-baseline') {
|
|
36
55
|
if (arg === '--update-baseline') args.updateBaseline = true;
|
|
@@ -50,8 +69,9 @@ function parseArgs(argv) {
|
|
|
50
69
|
|
|
51
70
|
function usage() {
|
|
52
71
|
return [
|
|
53
|
-
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--json] [--baseline [file]]',
|
|
72
|
+
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]]',
|
|
54
73
|
' ark-check --init [--force]',
|
|
74
|
+
' ark-check --install-agent-gates [--tools claude,cursor,codex] [--force]',
|
|
55
75
|
' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
|
|
56
76
|
' ark-check --print-config eleven-layer',
|
|
57
77
|
'',
|
|
@@ -62,6 +82,10 @@ function usage() {
|
|
|
62
82
|
'--init scans the project for the built-in layer directory conventions (src/domain,',
|
|
63
83
|
'src/application, src/adapters/persistence, ...) and writes an ark.config.json covering',
|
|
64
84
|
'only the layers that actually exist, with the default rules filtered to those layers.',
|
|
85
|
+
'Undetected profile layers are printed as suggestions with their conventional',
|
|
86
|
+
'directories. When nothing is detected, the full 11-layer starter profile is written',
|
|
87
|
+
'instead (all layers optional, anchored at src/), so the strict check passes today and',
|
|
88
|
+
'each layer starts being enforced as soon as its directory gains source files.',
|
|
65
89
|
'',
|
|
66
90
|
'Resolves relative, tsconfig path-alias, and package imports via the TypeScript',
|
|
67
91
|
'module resolver, then checks each resolved cross-layer import against the rules.',
|
|
@@ -80,8 +104,20 @@ function usage() {
|
|
|
80
104
|
'Config warnings are advisory by default and are included in JSON output.',
|
|
81
105
|
'Use --strict-config to make config warnings fail the check.',
|
|
82
106
|
'',
|
|
107
|
+
'--require-gates fails the check when AGENTS.md, .mcp.json, or the generated CI',
|
|
108
|
+
'workflow is missing, so "installed but never configured" is a red CI. Combine it',
|
|
109
|
+
'with --strict-config to enforce gate presence and architecture in one run.',
|
|
110
|
+
'',
|
|
111
|
+
'--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
|
+
'',
|
|
83
116
|
'Generate a starter 11-layer config:',
|
|
84
117
|
' ark-check --print-config eleven-layer > ark.config.json',
|
|
118
|
+
'',
|
|
119
|
+
'Install agent + CI enforcement templates:',
|
|
120
|
+
' ark-check --install-agent-gates',
|
|
85
121
|
].join('\n');
|
|
86
122
|
}
|
|
87
123
|
|
|
@@ -89,6 +125,35 @@ function readJson(file) {
|
|
|
89
125
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
90
126
|
}
|
|
91
127
|
|
|
128
|
+
function readPackageJson(root) {
|
|
129
|
+
const file = path.join(root, 'package.json');
|
|
130
|
+
if (!fs.existsSync(file)) return null;
|
|
131
|
+
return readJson(file);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasCheckArchitectureScript(root) {
|
|
135
|
+
const pkg = readPackageJson(root);
|
|
136
|
+
return Boolean(pkg?.scripts?.['check:architecture']);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const REQUIRED_GATE_FILES = [
|
|
140
|
+
'AGENTS.md',
|
|
141
|
+
'.mcp.json',
|
|
142
|
+
'.github/workflows/ark-check.yml',
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function missingGates(root) {
|
|
146
|
+
return REQUIRED_GATE_FILES.filter(
|
|
147
|
+
(relativePath) => !fs.existsSync(path.join(root, relativePath))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkArchitectureScriptSnippet() {
|
|
152
|
+
// npx resolves the installed package binary; `node bin/ark-check.mjs` only works
|
|
153
|
+
// inside Ark's own repo.
|
|
154
|
+
return '"check:architecture": "npx ark-check --root . --config ark.config.json --strict-config"';
|
|
155
|
+
}
|
|
156
|
+
|
|
92
157
|
function readConfig(root, configPath) {
|
|
93
158
|
const fullPath = path.isAbsolute(configPath)
|
|
94
159
|
? configPath
|
|
@@ -170,39 +235,328 @@ function runInit(args) {
|
|
|
170
235
|
}
|
|
171
236
|
|
|
172
237
|
const { srcDir, config } = detectConfig(args.root);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
].join('\n')
|
|
181
|
-
);
|
|
182
|
-
process.exitCode = 1;
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
238
|
+
const greenfield = config.layers.length === 0;
|
|
239
|
+
// Greenfield: anchor the starter profile at src/ (the convention a fresh project will
|
|
240
|
+
// scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
|
|
241
|
+
// check passes today and governance switches on the moment src/domain/ etc. appear.
|
|
242
|
+
const finalConfig = greenfield
|
|
243
|
+
? createElevenLayerConfig({ rootDir: srcDir === '.' ? 'src' : srcDir })
|
|
244
|
+
: config;
|
|
185
245
|
|
|
186
|
-
fs.writeFileSync(configPath, `${JSON.stringify(
|
|
246
|
+
fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
|
|
187
247
|
|
|
188
248
|
console.log(`Wrote ${configPath}`);
|
|
189
249
|
console.log('');
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
console.log(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
250
|
+
if (greenfield) {
|
|
251
|
+
console.log('No conventional layer directories found — generated the full 11-layer starter');
|
|
252
|
+
console.log('profile instead. Every layer is marked optional, so the strict check passes now');
|
|
253
|
+
console.log('and each layer starts being enforced as soon as its directory gains source files:');
|
|
254
|
+
for (const layer of finalConfig.layers) {
|
|
255
|
+
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
256
|
+
}
|
|
257
|
+
// The starter profile only governs src/. Existing source elsewhere would make the
|
|
258
|
+
// gate silently green, so surface it instead of pretending the project is covered.
|
|
259
|
+
const outside = walk(args.root)
|
|
260
|
+
.map((file) => normalize(path.relative(args.root, file)))
|
|
261
|
+
.filter((rel) => !rel.startsWith('src/') && !rel.split('/').some((s) => s.startsWith('.')));
|
|
262
|
+
if (outside.length > 0) {
|
|
263
|
+
console.log('');
|
|
264
|
+
console.log(`WARNING: ${outside.length} source file(s) live outside src/ and are NOT governed`);
|
|
265
|
+
console.log(`by this config (e.g. ${outside.slice(0, 3).join(', ')}).`);
|
|
266
|
+
console.log('Move them under src/, or edit the "include" and layer patterns to match your layout.');
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
console.log('Detected layers:');
|
|
270
|
+
for (const layer of finalConfig.layers) {
|
|
271
|
+
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
const detected = new Set(finalConfig.layers.map((layer) => layer.name));
|
|
274
|
+
const suggested = DEFAULT_INTENT_PREFIXES.filter((entry) => !detected.has(entry.layer));
|
|
275
|
+
if (suggested.length > 0) {
|
|
276
|
+
console.log('');
|
|
277
|
+
console.log('Suggested layers from the 11-layer profile (not detected — conventional');
|
|
278
|
+
console.log('directories shown; create one and re-run --init, or add the layer by hand):');
|
|
279
|
+
for (const entry of suggested) {
|
|
280
|
+
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
281
|
+
.map((directory) => `${srcDir}/${directory}`)
|
|
282
|
+
.join(', ');
|
|
283
|
+
console.log(` ${entry.layer}: ${dirs}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const uncovered = uncoveredDirectories(args.root, srcDir, finalConfig.layers);
|
|
287
|
+
if (uncovered.length > 0) {
|
|
288
|
+
console.log('');
|
|
289
|
+
console.log(
|
|
290
|
+
`Not covered by any layer (add patterns for these or they stay ungoverned): ${uncovered.join(', ')}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
200
293
|
}
|
|
201
294
|
console.log('');
|
|
202
295
|
console.log('Next steps:');
|
|
203
296
|
console.log(' 1. CI gate: npx ark-check --root . --config ark.config.json --strict-config');
|
|
204
297
|
console.log(' 2. AI write gate: npx ark-mcp --root . --config ark.config.json');
|
|
205
298
|
console.log(' (bind its validate_code tool to your agent\'s pre-write hook — see README)');
|
|
299
|
+
if (!hasCheckArchitectureScript(args.root)) {
|
|
300
|
+
console.log(' 3. Add the npm alias if you want `npm run check:architecture`:');
|
|
301
|
+
console.log(` ${checkArchitectureScriptSnippet()}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ensureDirForFile(file) {
|
|
306
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function writeTemplate(root, relativePath, content, force) {
|
|
310
|
+
const fullPath = path.join(root, relativePath);
|
|
311
|
+
if (fs.existsSync(fullPath) && !force) {
|
|
312
|
+
return { relativePath, status: 'skipped' };
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
ensureDirForFile(fullPath);
|
|
316
|
+
fs.writeFileSync(fullPath, content);
|
|
317
|
+
return { relativePath, status: 'written' };
|
|
318
|
+
} catch {
|
|
319
|
+
return { relativePath, status: 'failed' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function packageManager(root) {
|
|
324
|
+
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) {
|
|
325
|
+
return {
|
|
326
|
+
cache: 'pnpm',
|
|
327
|
+
setup: ['corepack enable'],
|
|
328
|
+
install: 'pnpm install --frozen-lockfile',
|
|
329
|
+
run: 'pnpm exec ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (fs.existsSync(path.join(root, 'yarn.lock'))) {
|
|
333
|
+
return {
|
|
334
|
+
cache: 'yarn',
|
|
335
|
+
setup: ['corepack enable'],
|
|
336
|
+
install: 'yarn install --frozen-lockfile',
|
|
337
|
+
run: 'yarn ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
cache: 'npm',
|
|
342
|
+
setup: [],
|
|
343
|
+
install: fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install',
|
|
344
|
+
run: 'npx ark-check --root . --config ark.config.json --strict-config --require-gates',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const ARK_CHECK_COMMAND = 'npx ark-check --root . --config ark.config.json --strict-config';
|
|
349
|
+
|
|
350
|
+
// Canonical agent contract. AGENTS.md and the Cursor rule both derive from this
|
|
351
|
+
// single source so the steps can never drift out of sync between the two files.
|
|
352
|
+
const AGENT_CONTRACT = {
|
|
353
|
+
manifestResource: 'ark://manifest',
|
|
354
|
+
steps: [
|
|
355
|
+
`Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
|
|
356
|
+
`Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
|
|
357
|
+
`Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
|
|
358
|
+
`After edits, run \`${ARK_CHECK_COMMAND}\`.`,
|
|
359
|
+
`If Ark reports violations, fix the architecture instead of weakening the gate.`,
|
|
360
|
+
],
|
|
361
|
+
// Cursor-only guidance: the write-time validate_code tool is available in
|
|
362
|
+
// Cursor's runtime but has no equivalent in a plain AGENTS.md read.
|
|
363
|
+
cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
function layerPlacementTable() {
|
|
367
|
+
const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
|
|
368
|
+
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
369
|
+
.map((directory) => `\`${directory}/\``)
|
|
370
|
+
.join(', ');
|
|
371
|
+
return `| ${entry.layer} | ${dirs} | ${entry.prefixes.map((p) => `\`${p}\``).join(', ')} |`;
|
|
372
|
+
}).join('\n');
|
|
373
|
+
return `| Layer | Conventional directories (under the source root) | Intent prefixes |
|
|
374
|
+
|-------|---------------------------------------------------|-----------------|
|
|
375
|
+
${rows}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function agentInstructions() {
|
|
379
|
+
const steps = AGENT_CONTRACT.steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
|
|
380
|
+
return `# Ark Enforcement
|
|
381
|
+
|
|
382
|
+
Before editing TypeScript or JavaScript source files:
|
|
383
|
+
|
|
384
|
+
${steps}
|
|
385
|
+
|
|
386
|
+
## Where new code belongs
|
|
387
|
+
|
|
388
|
+
\`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
|
|
389
|
+
that no existing layer covers (a saga, a background job, a read model, ...), use the
|
|
390
|
+
default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
|
|
391
|
+
an ungoverned location:
|
|
392
|
+
|
|
393
|
+
${layerPlacementTable()}
|
|
394
|
+
|
|
395
|
+
The project is only considered Ark-enforced when the write gate, CI gate, and runtime path all pass.
|
|
396
|
+
`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function mcpJson() {
|
|
400
|
+
return `${JSON.stringify({
|
|
401
|
+
mcpServers: {
|
|
402
|
+
ark: {
|
|
403
|
+
type: 'stdio',
|
|
404
|
+
command: 'npx',
|
|
405
|
+
args: ['ark-mcp', '--root', '.', '--config', 'ark.config.json'],
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
}, null, 2)}\n`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function codexTomlSnippet() {
|
|
412
|
+
return `[mcp_servers.ark]
|
|
413
|
+
command = "npx"
|
|
414
|
+
args = ["ark-mcp", "--root", ".", "--config", "ark.config.json"]
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function cursorRule() {
|
|
419
|
+
return `---
|
|
420
|
+
description: Ark architecture contract
|
|
421
|
+
alwaysApply: true
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
Before writing or editing TypeScript or JavaScript source files, read the
|
|
425
|
+
\`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
|
|
426
|
+
|
|
427
|
+
${AGENT_CONTRACT.cursorValidateStep} After edits, run:
|
|
428
|
+
|
|
429
|
+
\`\`\`bash
|
|
430
|
+
${ARK_CHECK_COMMAND}
|
|
431
|
+
\`\`\`
|
|
432
|
+
|
|
433
|
+
If Ark reports violations, fix the architecture instead of bypassing the gate.
|
|
434
|
+
`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function githubWorkflow(pm) {
|
|
438
|
+
const setupSteps = pm.setup.map((command) => ` - run: ${command}`).join('\n');
|
|
439
|
+
return `name: Ark architecture gate
|
|
440
|
+
|
|
441
|
+
on:
|
|
442
|
+
pull_request:
|
|
443
|
+
push:
|
|
444
|
+
branches: [main, master]
|
|
445
|
+
|
|
446
|
+
jobs:
|
|
447
|
+
ark-check:
|
|
448
|
+
runs-on: ubuntu-latest
|
|
449
|
+
steps:
|
|
450
|
+
- uses: actions/checkout@v4
|
|
451
|
+
- uses: actions/setup-node@v4
|
|
452
|
+
with:
|
|
453
|
+
node-version: 20
|
|
454
|
+
cache: ${pm.cache}
|
|
455
|
+
${setupSteps ? `${setupSteps}\n` : ''} - run: ${pm.install}
|
|
456
|
+
- run: ${pm.run}
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function claudeSettings() {
|
|
461
|
+
return `${JSON.stringify({
|
|
462
|
+
hooks: {
|
|
463
|
+
PreToolUse: [
|
|
464
|
+
{
|
|
465
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
466
|
+
hooks: [
|
|
467
|
+
{
|
|
468
|
+
type: 'command',
|
|
469
|
+
command:
|
|
470
|
+
'npx ark-mcp --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json',
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
}, null, 2)}\n`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function resolveTools(args) {
|
|
480
|
+
if (args.tools && args.tools.length > 0) {
|
|
481
|
+
return new Set(args.tools);
|
|
482
|
+
}
|
|
483
|
+
const root = args.root;
|
|
484
|
+
const detected = new Set();
|
|
485
|
+
if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
|
|
486
|
+
if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
|
|
487
|
+
if (fs.existsSync(path.join(root, '.codex'))) {
|
|
488
|
+
detected.add('codex');
|
|
489
|
+
}
|
|
490
|
+
// No signal at all: fall back to writing every tool's templates so a fresh
|
|
491
|
+
// project still gets a complete, reviewable starter set.
|
|
492
|
+
if (detected.size === 0) {
|
|
493
|
+
return new Set(['claude', 'cursor', 'codex']);
|
|
494
|
+
}
|
|
495
|
+
return detected;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const KNOWN_TOOLS = ['claude', 'cursor', 'codex'];
|
|
499
|
+
|
|
500
|
+
function runInstallAgentGates(args) {
|
|
501
|
+
const root = args.root;
|
|
502
|
+
if (args.tools) {
|
|
503
|
+
const unknown = args.tools.filter((tool) => !KNOWN_TOOLS.includes(tool));
|
|
504
|
+
if (args.tools.length === 0 || unknown.length > 0) {
|
|
505
|
+
console.error(
|
|
506
|
+
`--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
|
|
507
|
+
(unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
|
|
508
|
+
);
|
|
509
|
+
process.exitCode = 2;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const pm = packageManager(root);
|
|
514
|
+
const hasCheckScript = hasCheckArchitectureScript(root);
|
|
515
|
+
const tools = resolveTools(args);
|
|
516
|
+
const templates = [
|
|
517
|
+
// Base gates: tool-agnostic contract + CI backstop, always written.
|
|
518
|
+
['AGENTS.md', agentInstructions()],
|
|
519
|
+
['.mcp.json', mcpJson()],
|
|
520
|
+
['.github/workflows/ark-check.yml', githubWorkflow(pm)],
|
|
521
|
+
];
|
|
522
|
+
if (tools.has('cursor')) {
|
|
523
|
+
templates.push(['.cursor/mcp.json', mcpJson()]);
|
|
524
|
+
templates.push(['.cursor/rules/ark.mdc', cursorRule()]);
|
|
525
|
+
}
|
|
526
|
+
if (tools.has('claude')) {
|
|
527
|
+
templates.push(['.claude/settings.json', claudeSettings()]);
|
|
528
|
+
}
|
|
529
|
+
if (tools.has('codex')) {
|
|
530
|
+
templates.push(['docs/ark-codex-config.toml', codexTomlSnippet()]);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const results = templates.map(([relativePath, content]) =>
|
|
534
|
+
writeTemplate(root, relativePath, content, args.force)
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
console.log('Ark agent gate templates:');
|
|
538
|
+
for (const result of results) {
|
|
539
|
+
const marker =
|
|
540
|
+
result.status === 'written' ? 'wrote' : result.status === 'failed' ? 'FAILED' : 'skipped';
|
|
541
|
+
console.log(` ${marker.padEnd(7)} ${result.relativePath}`);
|
|
542
|
+
}
|
|
543
|
+
const failed = results.filter((result) => result.status === 'failed');
|
|
544
|
+
if (failed.length > 0) {
|
|
545
|
+
console.error(`\nFailed to write ${failed.length} template(s).`);
|
|
546
|
+
process.exitCode = 1;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
console.log('');
|
|
550
|
+
console.log('Next steps:');
|
|
551
|
+
console.log(' 1. Review the generated files and commit the ones that match your tools.');
|
|
552
|
+
console.log(' 2. Run: npx ark-check --root . --config ark.config.json --strict-config');
|
|
553
|
+
if (!hasCheckScript) {
|
|
554
|
+
console.log(' 3. Add the npm alias if you want `npm run check:architecture`:');
|
|
555
|
+
console.log(` ${checkArchitectureScriptSnippet()}`);
|
|
556
|
+
console.log(' 4. If you use Codex in this project, wire it now so `ark://manifest` is available from the first edit.');
|
|
557
|
+
} else {
|
|
558
|
+
console.log(' 3. If you use Codex in this project, wire it now so `ark://manifest` is available from the first edit.');
|
|
559
|
+
}
|
|
206
560
|
}
|
|
207
561
|
|
|
208
562
|
function readManifest(root, manifestPath) {
|
|
@@ -670,6 +1024,10 @@ async function main() {
|
|
|
670
1024
|
runInit(args);
|
|
671
1025
|
return;
|
|
672
1026
|
}
|
|
1027
|
+
if (args.installAgentGates) {
|
|
1028
|
+
runInstallAgentGates(args);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
673
1031
|
if (args.printConfig) {
|
|
674
1032
|
if (args.printConfig !== 'eleven-layer') {
|
|
675
1033
|
console.error(`Unknown config profile: ${args.printConfig}`);
|
|
@@ -680,6 +1038,35 @@ async function main() {
|
|
|
680
1038
|
return;
|
|
681
1039
|
}
|
|
682
1040
|
|
|
1041
|
+
if (args.requireGates) {
|
|
1042
|
+
const missing = missingGates(args.root);
|
|
1043
|
+
if (missing.length > 0) {
|
|
1044
|
+
const payload = {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
error: 'missing-gates',
|
|
1047
|
+
missing,
|
|
1048
|
+
};
|
|
1049
|
+
if (args.json) {
|
|
1050
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1051
|
+
} else {
|
|
1052
|
+
console.error('Ark gates are not installed. Missing:');
|
|
1053
|
+
for (const relativePath of missing) {
|
|
1054
|
+
console.error(` - ${relativePath}`);
|
|
1055
|
+
}
|
|
1056
|
+
console.error('\nRun `npx ark init` (or `ark-check --install-agent-gates`) to configure enforcement.');
|
|
1057
|
+
}
|
|
1058
|
+
process.exitCode = 1;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
// Gates present. This is a precondition, not a standalone report: stay quiet
|
|
1062
|
+
// in --json mode so the architecture check below owns the single JSON output.
|
|
1063
|
+
// When --require-gates is the only intent (no config/architecture run needed),
|
|
1064
|
+
// callers still get a clear signal from the exit code and the human-mode line.
|
|
1065
|
+
if (!args.json) {
|
|
1066
|
+
console.log('Ark gates present: ' + REQUIRED_GATE_FILES.join(', '));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
683
1070
|
let ts;
|
|
684
1071
|
try {
|
|
685
1072
|
ts = await import('typescript');
|
package/bin/ark-mcp.mjs
CHANGED
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import readline from 'node:readline';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_INTENT_PREFIXES,
|
|
30
|
+
DEFAULT_LAYER_DIRECTORIES,
|
|
31
|
+
DEFAULT_RULES,
|
|
32
|
+
layerForFile,
|
|
33
|
+
} from './ark-shared.mjs';
|
|
29
34
|
|
|
30
35
|
function parseArgs(argv) {
|
|
31
36
|
const args = {
|
|
@@ -297,6 +302,31 @@ async function main() {
|
|
|
297
302
|
},
|
|
298
303
|
];
|
|
299
304
|
|
|
305
|
+
// Layers from the 11-layer profile that this project has NOT declared, with their
|
|
306
|
+
// conventional directories: tells the agent where a new kind of code (a saga, a job,
|
|
307
|
+
// a read model, ...) belongs BEFORE it improvises a location the gate can't govern.
|
|
308
|
+
// A default layer is dropped when the project already claims any of its intent
|
|
309
|
+
// prefixes under another name (e.g. a `core` layer owning `Domain.`) — suggesting
|
|
310
|
+
// DomainModel there would tell the agent to create a second layer for the same
|
|
311
|
+
// prefix, making longest-prefix resolution ambiguous.
|
|
312
|
+
function suggestedLayers() {
|
|
313
|
+
const activeNames = new Set(profile.layers.map((layer) => layer.name));
|
|
314
|
+
const claimedPrefixes = new Set(
|
|
315
|
+
profile.layers.flatMap((layer) =>
|
|
316
|
+
(layer.prefixes ?? []).map((p) => (p.endsWith('.') ? p : `${p}.`))
|
|
317
|
+
)
|
|
318
|
+
);
|
|
319
|
+
return DEFAULT_INTENT_PREFIXES.filter(
|
|
320
|
+
(entry) =>
|
|
321
|
+
!activeNames.has(entry.layer) &&
|
|
322
|
+
!entry.prefixes.some((p) => claimedPrefixes.has(p.endsWith('.') ? p : `${p}.`))
|
|
323
|
+
).map((entry) => ({
|
|
324
|
+
layer: entry.layer,
|
|
325
|
+
intentPrefixes: entry.prefixes,
|
|
326
|
+
conventionalDirectories: DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [],
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
300
330
|
function manifestText() {
|
|
301
331
|
if (projectManifest) {
|
|
302
332
|
return JSON.stringify(
|
|
@@ -305,12 +335,23 @@ async function main() {
|
|
|
305
335
|
2
|
|
306
336
|
);
|
|
307
337
|
}
|
|
338
|
+
const suggestions = suggestedLayers();
|
|
308
339
|
return JSON.stringify(
|
|
309
340
|
{
|
|
310
341
|
source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
|
|
311
342
|
name: profile.name,
|
|
312
343
|
layers: profile.layers,
|
|
313
344
|
rules: profile.rules,
|
|
345
|
+
...(suggestions.length > 0
|
|
346
|
+
? {
|
|
347
|
+
suggestedLayers: suggestions,
|
|
348
|
+
suggestedLayersNote:
|
|
349
|
+
'Layers from the default 11-layer profile this project has not declared. ' +
|
|
350
|
+
'When creating a NEW kind of code that fits one of these, place it in a ' +
|
|
351
|
+
'conventional directory and add the layer to ark.config.json instead of ' +
|
|
352
|
+
'inventing an ungoverned location.',
|
|
353
|
+
}
|
|
354
|
+
: {}),
|
|
314
355
|
},
|
|
315
356
|
null,
|
|
316
357
|
2
|