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
@@ -0,0 +1,331 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/configContract.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/config-contract.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_CONFIG_SCHEMA_VERSION = '1.0';
12
+ export const ARK_CONFIG_SCHEMA_URL = 'https://unpkg.com/arkgate@2/schemas/ark.config.schema.json';
13
+ const DEFAULT_LAYER_NAMES = [
14
+ 'DomainModel',
15
+ 'ApplicationOrchestration',
16
+ 'PersistenceAdapters',
17
+ 'IntegrationAdapters',
18
+ 'WorkflowSagaEngine',
19
+ 'BackgroundJobsScheduling',
20
+ 'PresentationAdapters',
21
+ 'ReportingReadModels',
22
+ 'ExtensibilityMetadata',
23
+ 'SecurityAuditObservability',
24
+ 'Kernel',
25
+ ];
26
+ const DEFAULT_ALLOWED_FLOWS = new Set([
27
+ 'PresentationAdapters->ApplicationOrchestration',
28
+ 'ApplicationOrchestration->DomainModel',
29
+ 'WorkflowSagaEngine->ApplicationOrchestration',
30
+ 'WorkflowSagaEngine->DomainModel',
31
+ 'BackgroundJobsScheduling->ApplicationOrchestration',
32
+ ]);
33
+ function createDefaultRules() {
34
+ const rules = [];
35
+ for (const from of DEFAULT_LAYER_NAMES) {
36
+ for (const to of DEFAULT_LAYER_NAMES) {
37
+ if (from === to || DEFAULT_ALLOWED_FLOWS.has(`${from}->${to}`))
38
+ continue;
39
+ rules.push({ from, to, allowed: false });
40
+ }
41
+ }
42
+ return rules;
43
+ }
44
+ export const DEFAULT_ARK_CONFIG_RULES = createDefaultRules();
45
+ export const ARK_CONFIG_MIGRATIONS = [
46
+ { from: 'unversioned', to: ARK_CONFIG_SCHEMA_VERSION },
47
+ ];
48
+ const stringArraySchema = {
49
+ type: 'array',
50
+ items: { type: 'string', minLength: 1 },
51
+ uniqueItems: true,
52
+ };
53
+ export const ARK_CONFIG_SCHEMA = {
54
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
55
+ $id: ARK_CONFIG_SCHEMA_URL,
56
+ title: 'ArkGate architecture contract',
57
+ description: 'Versioned contract consumed identically by ArkGate CLI, MCP, and ESLint surfaces.',
58
+ type: 'object',
59
+ additionalProperties: false,
60
+ required: ['$schema', 'schemaVersion', 'include', 'layers', 'rules'],
61
+ properties: {
62
+ $schema: {
63
+ type: 'string',
64
+ minLength: 1,
65
+ default: ARK_CONFIG_SCHEMA_URL,
66
+ description: 'Editor-facing URL or local path for this JSON Schema.',
67
+ },
68
+ schemaVersion: {
69
+ type: 'string',
70
+ const: ARK_CONFIG_SCHEMA_VERSION,
71
+ default: ARK_CONFIG_SCHEMA_VERSION,
72
+ },
73
+ name: { type: 'string', minLength: 1 },
74
+ include: { ...stringArraySchema, minItems: 1, default: ['src'] },
75
+ exclude: { ...stringArraySchema, default: [] },
76
+ excludeGenerated: { type: 'boolean', default: true },
77
+ frameworkOverlay: { type: 'string', minLength: 1 },
78
+ layers: {
79
+ type: 'array',
80
+ default: [],
81
+ items: { $ref: '#/$defs/layer' },
82
+ },
83
+ rules: {
84
+ type: 'array',
85
+ default: DEFAULT_ARK_CONFIG_RULES,
86
+ items: { $ref: '#/$defs/rule' },
87
+ },
88
+ cyclePolicy: {
89
+ type: 'string',
90
+ enum: ['strict', 'soft', 'framework-soft', 'off'],
91
+ default: 'strict',
92
+ },
93
+ dynamicImportAllowlist: { ...stringArraySchema, default: [] },
94
+ safety: {
95
+ $ref: '#/$defs/safety',
96
+ default: {
97
+ maxTsSuppressions: 0,
98
+ maxAnyCasts: 0,
99
+ allowInMemory: false,
100
+ allowDisabledPeerIsolation: false,
101
+ },
102
+ },
103
+ },
104
+ $defs: {
105
+ layer: {
106
+ type: 'object',
107
+ additionalProperties: false,
108
+ required: ['name', 'patterns'],
109
+ properties: {
110
+ name: { type: 'string', minLength: 1 },
111
+ patterns: { ...stringArraySchema, minItems: 1 },
112
+ exclude: stringArraySchema,
113
+ intentPrefixes: stringArraySchema,
114
+ description: { type: 'string', minLength: 1 },
115
+ forbiddenGlobals: stringArraySchema,
116
+ mayImportInfrastructure: { type: 'boolean' },
117
+ optional: { type: 'boolean' },
118
+ },
119
+ },
120
+ rule: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['from', 'to', 'allowed'],
124
+ properties: {
125
+ from: { type: 'string', minLength: 1 },
126
+ to: { type: 'string', minLength: 1 },
127
+ allowed: { type: 'boolean' },
128
+ message: { type: 'string', minLength: 1 },
129
+ peerIsolation: { type: 'boolean' },
130
+ sliceFolders: { ...stringArraySchema, minItems: 1 },
131
+ },
132
+ },
133
+ safety: {
134
+ type: 'object',
135
+ additionalProperties: false,
136
+ properties: {
137
+ maxTsSuppressions: { type: 'integer', minimum: 0, default: 0 },
138
+ maxAnyCasts: { type: 'integer', minimum: 0, default: 0 },
139
+ allowInMemory: { type: 'boolean', default: false },
140
+ allowDisabledPeerIsolation: { type: 'boolean', default: false },
141
+ },
142
+ },
143
+ },
144
+ };
145
+ export class ArkConfigValidationError extends Error {
146
+ issues;
147
+ source;
148
+ constructor(source, issues) {
149
+ super(`Invalid ArkGate config (${source}):\n${issues
150
+ .map((issue) => `- ${issue.path}: ${issue.message}`)
151
+ .join('\n')}`);
152
+ this.name = 'ArkConfigValidationError';
153
+ this.source = source;
154
+ this.issues = issues;
155
+ }
156
+ }
157
+ function isObject(value) {
158
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
159
+ }
160
+ function propertyPath(parent, key) {
161
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
162
+ ? `${parent}.${key}`
163
+ : `${parent}[${JSON.stringify(key)}]`;
164
+ }
165
+ function valueType(value) {
166
+ if (value === null)
167
+ return 'null';
168
+ if (Array.isArray(value))
169
+ return 'array';
170
+ return typeof value;
171
+ }
172
+ function resolveSchemaRef(ref, root) {
173
+ const prefix = '#/$defs/';
174
+ if (!ref.startsWith(prefix))
175
+ return undefined;
176
+ return root.$defs[ref.slice(prefix.length)];
177
+ }
178
+ function validateNode(value, schema, path, root, issues) {
179
+ if (schema.$ref) {
180
+ const referenced = resolveSchemaRef(schema.$ref, root);
181
+ if (!referenced) {
182
+ issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
183
+ return;
184
+ }
185
+ validateNode(value, referenced, path, root, issues);
186
+ return;
187
+ }
188
+ if (schema.const !== undefined && !Object.is(value, schema.const)) {
189
+ issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
190
+ return;
191
+ }
192
+ if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
193
+ issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
194
+ return;
195
+ }
196
+ if (schema.type === 'object') {
197
+ if (!isObject(value)) {
198
+ issues.push({ path, message: `must be an object; received ${valueType(value)}` });
199
+ return;
200
+ }
201
+ const properties = schema.properties ?? {};
202
+ for (const key of schema.required ?? []) {
203
+ if (value[key] === undefined) {
204
+ issues.push({ path: propertyPath(path, key), message: 'is required' });
205
+ }
206
+ }
207
+ if (schema.additionalProperties === false) {
208
+ for (const key of Object.keys(value)) {
209
+ if (!(key in properties)) {
210
+ issues.push({ path: propertyPath(path, key), message: 'unknown field' });
211
+ }
212
+ }
213
+ }
214
+ for (const [key, childSchema] of Object.entries(properties)) {
215
+ if (value[key] !== undefined) {
216
+ validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
217
+ }
218
+ }
219
+ return;
220
+ }
221
+ if (schema.type === 'array') {
222
+ if (!Array.isArray(value)) {
223
+ issues.push({ path, message: `must be an array; received ${valueType(value)}` });
224
+ return;
225
+ }
226
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
227
+ issues.push({ path, message: `must contain at least ${schema.minItems} item(s)` });
228
+ }
229
+ if (schema.uniqueItems) {
230
+ const serialized = value.map((entry) => JSON.stringify(entry));
231
+ if (new Set(serialized).size !== serialized.length) {
232
+ issues.push({ path, message: 'must not contain duplicate items' });
233
+ }
234
+ }
235
+ if (schema.items) {
236
+ value.forEach((entry, index) => validateNode(entry, schema.items, `${path}[${index}]`, root, issues));
237
+ }
238
+ return;
239
+ }
240
+ if (schema.type === 'string') {
241
+ if (typeof value !== 'string') {
242
+ issues.push({ path, message: `must be a string; received ${valueType(value)}` });
243
+ return;
244
+ }
245
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
246
+ issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
247
+ }
248
+ return;
249
+ }
250
+ if (schema.type === 'boolean') {
251
+ if (typeof value !== 'boolean') {
252
+ issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
253
+ }
254
+ return;
255
+ }
256
+ if (schema.type === 'integer') {
257
+ if (!Number.isInteger(value)) {
258
+ issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
259
+ return;
260
+ }
261
+ if (schema.minimum !== undefined && value < schema.minimum) {
262
+ issues.push({ path, message: `must be at least ${schema.minimum}` });
263
+ }
264
+ }
265
+ }
266
+ function defaultedConfig(input) {
267
+ return {
268
+ ...input,
269
+ $schema: input.$schema === undefined ? ARK_CONFIG_SCHEMA_URL : input.$schema,
270
+ schemaVersion: input.schemaVersion === undefined ? ARK_CONFIG_SCHEMA_VERSION : input.schemaVersion,
271
+ include: input.include === undefined ? ['src'] : input.include,
272
+ layers: input.layers === undefined ? [] : input.layers,
273
+ rules: input.rules === undefined
274
+ ? DEFAULT_ARK_CONFIG_RULES.map((rule) => ({ ...rule }))
275
+ : input.rules,
276
+ };
277
+ }
278
+ export function migrateArkConfig(input, source = 'ark.config.json') {
279
+ if (!isObject(input)) {
280
+ throw new ArkConfigValidationError(source, [
281
+ { path: '$', message: `must be an object; received ${valueType(input)}` },
282
+ ]);
283
+ }
284
+ const migratedFrom = input.schemaVersion === undefined ? 'unversioned' : null;
285
+ if (input.schemaVersion !== undefined &&
286
+ input.schemaVersion !== ARK_CONFIG_SCHEMA_VERSION) {
287
+ throw new ArkConfigValidationError(source, [
288
+ {
289
+ path: '$.schemaVersion',
290
+ message: `unsupported version ${JSON.stringify(input.schemaVersion)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
291
+ },
292
+ ]);
293
+ }
294
+ return { candidate: defaultedConfig(input), migratedFrom };
295
+ }
296
+ export function loadArkConfigContract(input, source = 'ark.config.json') {
297
+ const { candidate, migratedFrom } = migrateArkConfig(input, source);
298
+ const issues = [];
299
+ validateNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
300
+ if (issues.length > 0)
301
+ throw new ArkConfigValidationError(source, issues);
302
+ return { config: candidate, migratedFrom };
303
+ }
304
+ export function parseArkConfigJson(json, source = 'ark.config.json') {
305
+ let input;
306
+ try {
307
+ input = JSON.parse(json);
308
+ }
309
+ catch (error) {
310
+ throw new ArkConfigValidationError(source, [
311
+ {
312
+ path: '$',
313
+ message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
314
+ },
315
+ ]);
316
+ }
317
+ return loadArkConfigContract(input, source);
318
+ }
319
+ export function withArkConfigMetadata(config) {
320
+ const result = {
321
+ $schema: typeof config.$schema === 'string' && config.$schema.length > 0
322
+ ? config.$schema
323
+ : ARK_CONFIG_SCHEMA_URL,
324
+ schemaVersion: ARK_CONFIG_SCHEMA_VERSION,
325
+ };
326
+ for (const [key, value] of Object.entries(config)) {
327
+ if (key !== '$schema' && key !== 'schemaVersion')
328
+ result[key] = value;
329
+ }
330
+ return result;
331
+ }
@@ -327,8 +327,14 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
327
327
  gatesMissing,
328
328
  skillGaps,
329
329
  staleRunnerFiles: staleRunners,
330
- // W5 — prepare-write / autoPatch / reject-only awareness (stable additive)
330
+ // Active-host guarantees plus separate repo-wide inventory.
331
331
  writePath: {
332
+ activeHost: writePath.activeHost,
333
+ support: writePath.support,
334
+ supportSummary: writePath.supportSummary,
335
+ capabilities: writePath.capabilities,
336
+ capabilityEvidence: writePath.capabilityEvidence,
337
+ inventory: writePath.inventory,
332
338
  mode: writePath.mode,
333
339
  prepareWrite: writePath.prepareWrite,
334
340
  autoPatch: writePath.autoPatch,
@@ -404,7 +410,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
404
410
  adapt:
405
411
  'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
406
412
  enforce:
407
- 'Guard — contract governs enough real code and edges are clean enough for gates to protect you. You do not pick this mode; you arrived here. Next: keep CI/write gates on; only NEW violations should fail.',
413
+ 'Guard — contract coverage is honest and checked edges are clean. You do not pick this mode; you arrived here. Next: keep the host-appropriate write path and CI check on; only NEW violations should fail.',
408
414
  };
409
415
  line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
410
416
  if (emptyScope) {
@@ -496,26 +502,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
496
502
 
497
503
  console.log('');
498
504
  console.log(color.bold('Write path (agent)'));
505
+ const capabilities = writePath.capabilities;
499
506
  const writePathLabels = {
500
507
  repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
501
508
  'reject-only': 'reject-only — hard block with prose; no repair payload',
502
509
  'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
503
- none: 'none — no write gate hook and no Ark MCP',
510
+ none: 'no write gate hook and no Ark MCP',
504
511
  };
505
512
  const wpMark =
506
- writePath.mode === 'repair'
513
+ capabilities['hard-write']
507
514
  ? ok
508
- : writePath.mode === 'none'
509
- ? bad
510
- : warn;
515
+ : capabilities['advisory-write'] || capabilities['merge-gate']
516
+ ? warn
517
+ : bad;
518
+ line(' ', `Active host: ${writePath.activeHost}`);
519
+ line(' ', `Supported profile: ${writePath.supportSummary}`);
511
520
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
512
521
  line(
513
- writePath.prepareWrite ? ok : warn,
514
- `prepare-write (MCP): ${writePath.prepareWrite ? 'yes' : 'no'}`
522
+ capabilities['hard-write'] ? ok : warn,
523
+ `Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
524
+ );
525
+ line(
526
+ warn,
527
+ `Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
515
528
  );
516
529
  line(
517
- writePath.autoPatch ? ok : warn,
518
- `autoPatch surface: ${writePath.autoPatch ? 'yes' : 'no'}`
530
+ capabilities['merge-gate'] ? ok : bad,
531
+ `CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
532
+ );
533
+ line(
534
+ capabilities['repair-payload'] ? ok : warn,
535
+ `Repair payload at hard boundary: ${capabilities['repair-payload'] ? 'yes' : 'no'}`
519
536
  );
520
537
  if (writePath.gap) {
521
538
  line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
@@ -527,7 +544,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
527
544
 
528
545
  console.log('');
529
546
  console.log(color.bold('Gates & skills'));
530
- if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
547
+ if (gatesMissing.length === 0) line(ok, 'Shared gate files present (AGENTS.md, .mcp.json, CI)');
531
548
  else {
532
549
  line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
533
550
  actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
@@ -570,10 +587,20 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
570
587
  );
571
588
  } else {
572
589
  for (const gap of adoption.gaps) {
573
- const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
590
+ // Deferred Codex-home debt (non-temp) is annotated, not a top action, when the
591
+ // session host is not Codex — fix when that host is used.
592
+ const mark = gap.deferred
593
+ ? color.dim('·')
594
+ : gap.severity === 'warn'
595
+ ? warn
596
+ : gap.severity === 'info'
597
+ ? warn
598
+ : bad;
574
599
  line(mark, gap.message);
575
- if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
576
- actions.push(gap.fix || gap.message);
600
+ if (gap.fix) {
601
+ line(' ', color.dim(gap.deferred ? `When using Codex: ${gap.fix}` : `Fix: ${gap.fix}`));
602
+ }
603
+ if (!gap.deferred) actions.push(gap.fix || gap.message);
577
604
  }
578
605
  if (adoption.layerBalance) {
579
606
  line(warn, color.dim(adoption.layerBalance.educational));
@@ -601,7 +628,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
601
628
  line(warn, 'Safety diagnostics unavailable');
602
629
  } else {
603
630
  const rows = [
604
- ['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
631
+ ['Non-literal dynamic dependencies', safety.nonLiteralDynamicImports],
605
632
  ['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
606
633
  ['Explicit any casts', safety.anyCasts],
607
634
  ['InMemory stores in production source', safety.inMemoryProductionStores],
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Static host support plus preflight validation for requested write guarantees.
3
+ * Installed evidence remains authoritative in write-path-capabilities.mjs.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { detectWritePathCapabilities } from './write-path-detect.mjs';
8
+ import {
9
+ HOST_SUPPORT_HOSTS,
10
+ HOST_SUPPORT_MATRIX,
11
+ } from './host-support-matrix.mjs';
12
+ import { KNOWN_TOOLS, normalizeToolsList } from './skill-install.mjs';
13
+
14
+ export const HOST_ENFORCEMENT_SUPPORT = Object.freeze(
15
+ Object.fromEntries(
16
+ HOST_SUPPORT_HOSTS.map((host) => {
17
+ const profile = HOST_SUPPORT_MATRIX[host];
18
+ return [
19
+ host,
20
+ Object.freeze({
21
+ hardWrite: profile.capabilities['hard-write'],
22
+ advisoryWrite: profile.capabilities['advisory-write'],
23
+ hookPath: profile.hookPath,
24
+ }),
25
+ ];
26
+ })
27
+ )
28
+ );
29
+
30
+ export const WRITE_PROFILE_HOSTS = HOST_SUPPORT_HOSTS;
31
+
32
+ export function validateSelectedTools(tools) {
33
+ if (tools == null) return { ok: true, tools: null };
34
+ const selected = normalizeToolsList(tools);
35
+ const unknown = selected.filter((tool) => !KNOWN_TOOLS.includes(tool));
36
+ if (selected.length === 0 || unknown.length > 0) {
37
+ return {
38
+ ok: false,
39
+ error:
40
+ `--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
41
+ (unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : ''),
42
+ };
43
+ }
44
+ return { ok: true, tools: selected };
45
+ }
46
+
47
+ export function hasHardWriteHook(root, host) {
48
+ return detectWritePathCapabilities(root, host).capabilities['hard-write'];
49
+ }
50
+
51
+ export function validateHardWriteRequest({ root, host, tools, force = false }) {
52
+ const toolSelection = validateSelectedTools(tools);
53
+ if (!toolSelection.ok) return toolSelection;
54
+ if (host == null) return { ok: true, host: null, tools: toolSelection.tools };
55
+
56
+ const normalizedHost = String(host).trim().toLowerCase();
57
+ const support = HOST_ENFORCEMENT_SUPPORT[normalizedHost];
58
+ if (!support) {
59
+ return {
60
+ ok: false,
61
+ error: `Unknown write host "${normalizedHost}". Expected: ${WRITE_PROFILE_HOSTS.join(', ')}.`,
62
+ };
63
+ }
64
+ if (!support.hardWrite) {
65
+ return {
66
+ ok: false,
67
+ error:
68
+ `${normalizedHost} supports advisory-write plus the shared CI check, not a hard local write hook. ` +
69
+ 'Omit --require-write-hook, keep --strict-merge in CI, and require that status to block merges.',
70
+ };
71
+ }
72
+
73
+ const selectedTools = toolSelection.tools ?? [normalizedHost];
74
+ if (!selectedTools.includes(normalizedHost)) {
75
+ return {
76
+ ok: false,
77
+ error: `--require-write-hook ${normalizedHost} requires --tools to include ${normalizedHost}.`,
78
+ };
79
+ }
80
+
81
+ const hookFile = path.join(root, support.hookPath);
82
+ if (fs.existsSync(hookFile) && !force && !hasHardWriteHook(root, normalizedHost)) {
83
+ return {
84
+ ok: false,
85
+ error:
86
+ `${support.hookPath} already exists without an Ark hard-write hook and would be preserved. ` +
87
+ 'Use --force to replace that host file, or omit --require-write-hook for merge-only enforcement.',
88
+ };
89
+ }
90
+
91
+ return {
92
+ ok: true,
93
+ host: normalizedHost,
94
+ tools: selectedTools,
95
+ support,
96
+ };
97
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Canonical host support promises.
3
+ *
4
+ * These records describe what ArkGate can install for each supported host.
5
+ * Installed evidence remains authoritative for a specific repository and is
6
+ * reported separately by write-path-capabilities.mjs.
7
+ */
8
+
9
+ function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
10
+ return Object.freeze({
11
+ label,
12
+ hookPath,
13
+ hookSurface,
14
+ capabilities: Object.freeze({
15
+ 'hard-write': hardWrite,
16
+ 'advisory-write': true,
17
+ 'merge-gate': true,
18
+ 'repair-payload': repairPayload,
19
+ }),
20
+ });
21
+ }
22
+
23
+ export const HOST_SUPPORT_MATRIX = Object.freeze({
24
+ claude: hostProfile(
25
+ 'Claude Code',
26
+ '.claude/settings.json',
27
+ 'PreToolUse `Write` / `Edit` / `MultiEdit`',
28
+ true,
29
+ true
30
+ ),
31
+ grok: hostProfile(
32
+ 'Grok Build',
33
+ '.grok/hooks/ark-write-gate.json',
34
+ 'PreToolUse `write` / `search_replace` (plus aliases)',
35
+ true,
36
+ true
37
+ ),
38
+ cursor: hostProfile('Cursor', null, null, false, false),
39
+ codex: hostProfile('OpenAI Codex', null, null, false, false),
40
+ });
41
+
42
+ export const HOST_SUPPORT_HOSTS = Object.freeze(Object.keys(HOST_SUPPORT_MATRIX));
43
+
44
+ export function getHostSupportProfile(host) {
45
+ const normalized = typeof host === 'string' ? host.trim().toLowerCase() : '';
46
+ return HOST_SUPPORT_MATRIX[normalized] ?? null;
47
+ }
48
+
49
+ export function formatHostSupportSummary(profile) {
50
+ if (!profile) return 'unknown host; no local write guarantee';
51
+ const capabilities = profile.capabilities;
52
+ const write = capabilities['hard-write']
53
+ ? 'hard local write boundary'
54
+ : 'no hard local write boundary';
55
+ const repair = capabilities['repair-payload'] ? 'repair payload' : 'no hard-boundary repair';
56
+ return `${write} + advisory MCP + CI check + ${repair}`;
57
+ }
58
+
59
+ export function renderHostSupportMatrixMarkdown() {
60
+ const rows = HOST_SUPPORT_HOSTS.map((host) => {
61
+ const profile = HOST_SUPPORT_MATRIX[host];
62
+ const capabilities = profile.capabilities;
63
+ const local = capabilities['hard-write']
64
+ ? `Hard block for ${profile.hookSurface}`
65
+ : 'No hard hook; MCP/rules are advisory';
66
+ const repair = capabilities['repair-payload']
67
+ ? 'Emitted on hook deny; host must re-inject'
68
+ : 'No hard-boundary payload';
69
+ return `| ${profile.label} | ${local} | Advisory; the agent must call it | Available \`arkgate-check --strict-merge\` check | ${repair} |`;
70
+ }).join('\n');
71
+
72
+ return `| Host | Local write boundary | MCP validation | CI / merge path | Repair payload |
73
+ |------|----------------------|----------------|-----------------|----------------|
74
+ ${rows}
75
+
76
+ This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run \`arkgate-check --doctor\` for the evidence actually detected in the current repository.`;
77
+ }