arkgate 4.6.7 → 4.7.1

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 (68) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +20 -9
  3. package/SECURITY.md +1 -1
  4. package/bin/ark-check-runtime.mjs +13 -1
  5. package/bin/ark-mcp-runtime.mjs +65 -3
  6. package/bin/lib/adapter-contract.mjs +17 -36
  7. package/bin/lib/agent-skills-package.mjs +29 -0
  8. package/bin/lib/analysis-engine.mjs +6 -6
  9. package/bin/lib/ark-run-doctor.mjs +144 -0
  10. package/bin/lib/ark-run-facts.mjs +472 -0
  11. package/bin/lib/ark-run-report.mjs +57 -0
  12. package/bin/lib/ark-run-sensors.mjs +309 -0
  13. package/bin/lib/check-args.mjs +1 -0
  14. package/bin/lib/config-contract.mjs +86 -11
  15. package/bin/lib/diagnostic-catalog.mjs +8 -0
  16. package/bin/lib/doctor-advisories.mjs +45 -8
  17. package/bin/lib/doctor-human.mjs +10 -0
  18. package/bin/lib/doctor-next-actions.mjs +5 -1
  19. package/bin/lib/doctor-plan.mjs +20 -16
  20. package/bin/lib/extra-merge-teeth.mjs +187 -0
  21. package/bin/lib/first-run-help.mjs +2 -2
  22. package/bin/lib/html-report-advisories.mjs +2 -0
  23. package/bin/lib/html-report-depth.mjs +22 -2
  24. package/bin/lib/html-report.mjs +16 -0
  25. package/bin/lib/install-migrate.mjs +29 -57
  26. package/bin/lib/managed-upgrade.mjs +2 -7
  27. package/bin/lib/remediation.mjs +132 -0
  28. package/bin/lib/resolved-candidate-facts.mjs +67 -2
  29. package/bin/lib/rules-under-contract.mjs +37 -89
  30. package/bin/lib/skill-catalog-apply.mjs +126 -0
  31. package/bin/lib/skill-install.mjs +264 -33
  32. package/bin/lib/skill-write.mjs +3 -0
  33. package/bin/lib/snippet-analysis.mjs +43 -2
  34. package/bin/lib/status-command.mjs +28 -0
  35. package/bin/lib/status-manifest.mjs +23 -0
  36. package/dist/{configTypes-l6XiwiC1.d.ts → configTypes-CgJimx9o.d.ts} +17 -3
  37. package/dist/eslint/index.cjs +6 -2
  38. package/dist/eslint/index.d.ts +70 -2
  39. package/dist/eslint/index.js +6 -2
  40. package/dist/index.cjs +35 -35
  41. package/dist/index.d.ts +804 -272
  42. package/dist/index.js +35 -35
  43. package/docs/README.md +3 -3
  44. package/docs/agent-guide.md +22 -16
  45. package/docs/ai-gates.md +15 -2
  46. package/docs/configuration.md +24 -11
  47. package/docs/develop.md +12 -3
  48. package/docs/diagnostics.md +75 -0
  49. package/docs/enthusiast/README.md +4 -3
  50. package/docs/enthusiast/how-to-agent-gates.md +1 -1
  51. package/docs/package-surface.md +16 -13
  52. package/docs/product-voice.md +6 -3
  53. package/docs/threat-model.md +1 -1
  54. package/docs/use.md +5 -4
  55. package/package.json +1 -1
  56. package/schemas/ark.config.schema.json +41 -2
  57. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  58. package/schemas/ark.status-manifest.schema.json +47 -0
  59. package/server.json +2 -2
  60. package/templates/agent-skills/README.md +1 -1
  61. package/templates/agent-skills/ark-adopt/SKILL.md +23 -2
  62. package/templates/agent-skills/ark-contract/SKILL.md +8 -7
  63. package/templates/agent-skills/ark-place/SKILL.md +26 -2
  64. package/templates/agent-skills/ark-runtime/SKILL.md +66 -24
  65. package/templates/skills/ark-adopt.md +23 -2
  66. package/templates/skills/ark-contract.md +8 -7
  67. package/templates/skills/ark-place.md +26 -2
  68. package/templates/skills/ark-runtime.md +66 -24
@@ -0,0 +1,309 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/arkRunSensors.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/ark-run-sensors.mjs). Zero Node I/O.
9
+ */
10
+
11
+ import { extractArkRunImportedConstructorNamesFromSource, extractArkRunKernelCallsFromSource, extractArkRunManagedNewsFromSource, extractArkRunValueImportDependenciesFromSource, isArkRunKernelModuleSpecifier, isArkRunTransportBypassSpecifier, } from './ark-run-facts.mjs';
12
+ import { extraMergeTeethAllowed, } from './extra-merge-teeth.mjs';
13
+ import { deterministicNextAction } from './remediation.mjs';
14
+ export const ARKRUN_TIER1_SENSOR_IDS = [
15
+ 'arkrun-missing-root',
16
+ 'arkrun-kernel-in-domain',
17
+ 'arkrun-direct-new',
18
+ 'arkrun-undeclared-emit',
19
+ 'arkrun-undeclared-handle',
20
+ 'arkrun-undeclared-depend',
21
+ 'arkrun-transport-bypass',
22
+ ];
23
+ /**
24
+ * Import / `new` envelope for `arkgate/eslint`.
25
+ * Missing-root and undeclared-* stay CLI/MCP/preflight (project-wide or declaration facts).
26
+ */
27
+ export const ARKRUN_EDITOR_SENSOR_IDS = [
28
+ 'arkrun-kernel-in-domain',
29
+ 'arkrun-direct-new',
30
+ 'arkrun-transport-bypass',
31
+ ];
32
+ const EDITOR_SENSOR_SET = new Set(ARKRUN_EDITOR_SENSOR_IDS);
33
+ export function isArkRunEditorSensor(sensor) {
34
+ return EDITOR_SENSOR_SET.has(sensor);
35
+ }
36
+ export const ARKRUN_RULE_IDS = {
37
+ 'arkrun-missing-root': 'ARKRUN_MISSING_ROOT',
38
+ 'arkrun-kernel-in-domain': 'ARKRUN_KERNEL_IN_DOMAIN',
39
+ 'arkrun-direct-new': 'ARKRUN_DIRECT_NEW',
40
+ 'arkrun-undeclared-emit': 'ARKRUN_UNDECLARED_EMIT',
41
+ 'arkrun-undeclared-handle': 'ARKRUN_UNDECLARED_HANDLE',
42
+ 'arkrun-undeclared-depend': 'ARKRUN_UNDECLARED_DEPEND',
43
+ 'arkrun-transport-bypass': 'ARKRUN_TRANSPORT_BYPASS',
44
+ };
45
+ export const ARKRUN_INTERACTION_NAME_INCOMPLETE = 'ARKRUN_INTERACTION_NAME_INCOMPLETE';
46
+ function isDomainRoleLayer(layer, intentPrefixes = []) {
47
+ const name = layer.trim();
48
+ // Start-anchored Domain/entity/aggregate — unanchored "model" matches ReportingReadModels.
49
+ if (/^domain(?:model)?$/i.test(name) || /^domain(?=[A-Z_\-\s])/i.test(name))
50
+ return true;
51
+ if (/^(?:entit(?:y|ies)|aggregates?)(?:$|(?=[A-Z_\-\s]))/i.test(name))
52
+ return true;
53
+ return intentPrefixes.some((prefix) => {
54
+ const normalized = prefix.trim().replace(/\.+$/, '');
55
+ return normalized === 'Domain' || normalized.startsWith('Domain.');
56
+ });
57
+ }
58
+ function compareFindings(left, right) {
59
+ return (left.file.localeCompare(right.file) ||
60
+ left.ruleId.localeCompare(right.ruleId) ||
61
+ left.line - right.line ||
62
+ left.message.localeCompare(right.message));
63
+ }
64
+ function finding(extra, sensor, file, line, message, extras, teethAllowed) {
65
+ const failsStrict = extra.mode === 'enforced' && teethAllowed;
66
+ return {
67
+ ruleId: ARKRUN_RULE_IDS[sensor],
68
+ sensor,
69
+ message,
70
+ file,
71
+ line,
72
+ ...(extras?.fromLayer ? { fromLayer: extras.fromLayer } : {}),
73
+ ...(extras?.target ? { target: extras.target } : {}),
74
+ severity: failsStrict ? 'error' : 'warning',
75
+ failsStrict,
76
+ nextAction: deterministicNextAction({
77
+ ruleId: ARKRUN_RULE_IDS[sensor],
78
+ fromLayer: extras?.fromLayer,
79
+ target: extras?.target,
80
+ }),
81
+ };
82
+ }
83
+ function bagForFile(declarations, file) {
84
+ const uses = [];
85
+ const reactsTo = [];
86
+ const raises = [];
87
+ const sends = [];
88
+ for (const entry of declarations) {
89
+ if (entry.file !== file)
90
+ continue;
91
+ uses.push(...entry.uses);
92
+ reactsTo.push(...entry.reactsTo);
93
+ raises.push(...entry.raises);
94
+ sends.push(...entry.sends);
95
+ }
96
+ return {
97
+ uses: new Set(uses),
98
+ reactsTo: new Set(reactsTo),
99
+ raises: new Set(raises),
100
+ sends: new Set(sends),
101
+ };
102
+ }
103
+ function emitKinds(kind) {
104
+ return kind === 'publisher' || kind === 'publish' || kind === 'raise' || kind === 'send';
105
+ }
106
+ function handleKinds(kind) {
107
+ return kind === 'subscribe' || kind === 'register-handler';
108
+ }
109
+ function dependKinds(kind) {
110
+ return kind === 'resolve' || kind === 'resolve-singleton';
111
+ }
112
+ function evaluateMissingRoot(extra, hits, teethAllowed) {
113
+ const out = [];
114
+ const roots = extra.compositionRoots;
115
+ if (roots.length === 0) {
116
+ out.push(finding(extra, 'arkrun-missing-root', 'ark.config.json', 1, 'ArkRun compositionRoots is empty; no createArkKernel factory site is declared.', undefined, teethAllowed));
117
+ return out;
118
+ }
119
+ const hitsByRoot = new Map();
120
+ for (const hit of hits) {
121
+ const list = hitsByRoot.get(hit.matchedRoot) ?? [];
122
+ list.push(hit);
123
+ hitsByRoot.set(hit.matchedRoot, list);
124
+ }
125
+ for (const pattern of roots) {
126
+ const matched = [...(hitsByRoot.get(pattern) ?? [])].sort((left, right) => left.file.localeCompare(right.file));
127
+ if (matched.length === 0) {
128
+ out.push(finding(extra, 'arkrun-missing-root', 'ark.config.json', 1, `ArkRun composition root ${JSON.stringify(pattern)} matched no governed files and has no createArkKernel factory.`, { target: pattern }, teethAllowed));
129
+ continue;
130
+ }
131
+ // Factory required in the root set, not in every glob hit.
132
+ if (matched.some((hit) => hit.hasKernelFactory))
133
+ continue;
134
+ const first = matched[0];
135
+ out.push(finding(extra, 'arkrun-missing-root', first.file, 1, `ArkRun composition root ${JSON.stringify(pattern)} has no createArkKernel / createStrictArkKernel factory.`, { target: pattern }, teethAllowed));
136
+ }
137
+ return out;
138
+ }
139
+ function evaluateKernelInDomain(extra, layers, dependencies, layerForFile, teethAllowed) {
140
+ const prefixes = new Map(layers.map((layer) => [layer.name, layer.intentPrefixes ?? []]));
141
+ const out = [];
142
+ for (const dependency of dependencies) {
143
+ const specifier = dependency.specifier;
144
+ if (!specifier || !isArkRunKernelModuleSpecifier(specifier))
145
+ continue;
146
+ const fromLayer = layerForFile(dependency.from);
147
+ if (!fromLayer)
148
+ continue;
149
+ if (!isDomainRoleLayer(fromLayer, prefixes.get(fromLayer) ?? []))
150
+ continue;
151
+ out.push(finding(extra, 'arkrun-kernel-in-domain', dependency.from, dependency.line, `${fromLayer} must not import kernel module ${JSON.stringify(specifier)}.`, { fromLayer, target: specifier }, teethAllowed));
152
+ }
153
+ return out;
154
+ }
155
+ function evaluateDirectNew(extra, layers, managedNews, hits, layerForFile, teethAllowed) {
156
+ const managed = new Set(extra.managedLayers);
157
+ if (managed.size === 0)
158
+ return [];
159
+ const prefixes = new Map(layers.map((layer) => [layer.name, layer.intentPrefixes ?? []]));
160
+ const admittedFactories = new Set(hits.filter((hit) => hit.hasKernelFactory).map((hit) => hit.file));
161
+ const out = [];
162
+ for (const constructed of managedNews) {
163
+ if (admittedFactories.has(constructed.file))
164
+ continue;
165
+ const fromLayer = layerForFile(constructed.file);
166
+ if (!fromLayer || !managed.has(fromLayer))
167
+ continue;
168
+ if (isDomainRoleLayer(fromLayer, prefixes.get(fromLayer) ?? []))
169
+ continue;
170
+ out.push(finding(extra, 'arkrun-direct-new', constructed.file, constructed.line, `${fromLayer} must not construct ${constructed.typeName} with new outside an ArkRun composition-root factory.`, { fromLayer, target: constructed.typeName }, teethAllowed));
171
+ }
172
+ return out;
173
+ }
174
+ function evaluateUndeclared(extra, kernelCalls, declarations, layerForFile, teethAllowed) {
175
+ const findings = [];
176
+ const completenessReasons = [];
177
+ if (extra.requireDeclarations !== true) {
178
+ return { findings, completenessReasons };
179
+ }
180
+ const managed = new Set(extra.managedLayers);
181
+ if (managed.size === 0)
182
+ return { findings, completenessReasons };
183
+ for (const call of kernelCalls) {
184
+ if (!emitKinds(call.kind) && !handleKinds(call.kind) && !dependKinds(call.kind))
185
+ continue;
186
+ const fromLayer = layerForFile(call.file);
187
+ if (!fromLayer || !managed.has(fromLayer))
188
+ continue;
189
+ if (!call.nameLiteral) {
190
+ if (extra.mode === 'enforced') {
191
+ completenessReasons.push({
192
+ code: ARKRUN_INTERACTION_NAME_INCOMPLETE,
193
+ file: call.file,
194
+ message: `ArkRun ${call.kind} call in ${call.file} has no string-literal name; enforced extra cannot prove the declaration.`,
195
+ });
196
+ }
197
+ continue;
198
+ }
199
+ const bag = bagForFile(declarations, call.file);
200
+ if (emitKinds(call.kind)) {
201
+ if (bag.raises.has(call.nameLiteral) || bag.sends.has(call.nameLiteral))
202
+ continue;
203
+ findings.push(finding(extra, 'arkrun-undeclared-emit', call.file, call.line, `Emit ${JSON.stringify(call.nameLiteral)} is not declared in raises or sends.`, { fromLayer, target: call.nameLiteral }, teethAllowed));
204
+ continue;
205
+ }
206
+ if (handleKinds(call.kind)) {
207
+ if (bag.reactsTo.has(call.nameLiteral))
208
+ continue;
209
+ findings.push(finding(extra, 'arkrun-undeclared-handle', call.file, call.line, `Handle ${JSON.stringify(call.nameLiteral)} is not declared in reactsTo.`, { fromLayer, target: call.nameLiteral }, teethAllowed));
210
+ continue;
211
+ }
212
+ if (bag.uses.has(call.nameLiteral))
213
+ continue;
214
+ findings.push(finding(extra, 'arkrun-undeclared-depend', call.file, call.line, `Depend ${JSON.stringify(call.nameLiteral)} is not declared in uses.`, { fromLayer, target: call.nameLiteral }, teethAllowed));
215
+ }
216
+ return { findings, completenessReasons };
217
+ }
218
+ function evaluateTransportBypass(extra, dependencies, layerForFile, teethAllowed) {
219
+ const managed = new Set(extra.managedLayers);
220
+ if (managed.size === 0)
221
+ return [];
222
+ const out = [];
223
+ for (const dependency of dependencies) {
224
+ if (dependency.typeOnly)
225
+ continue;
226
+ const specifier = dependency.specifier;
227
+ if (!specifier || !isArkRunTransportBypassSpecifier(specifier))
228
+ continue;
229
+ const fromLayer = layerForFile(dependency.from);
230
+ if (!fromLayer || !managed.has(fromLayer))
231
+ continue;
232
+ out.push(finding(extra, 'arkrun-transport-bypass', dependency.from, dependency.line, `${fromLayer} must not import broker/queue/emitter ${JSON.stringify(specifier)}; use the ArkRun kernel transport.`, { fromLayer, target: specifier }, teethAllowed));
233
+ }
234
+ return out;
235
+ }
236
+ /**
237
+ * Evaluate closed tier-1 ArkRun sensors. Empty extra → no findings (silent).
238
+ */
239
+ export function evaluateArkRunSensors(input) {
240
+ const extra = input.arkRun;
241
+ if (!extra)
242
+ return { findings: [], completenessReasons: [] };
243
+ const teethAllowed = extraMergeTeethAllowed(input.classification);
244
+ const undeclared = evaluateUndeclared(extra, input.kernelCalls, input.declarations, input.layerForFile, teethAllowed);
245
+ const findings = [
246
+ ...evaluateMissingRoot(extra, input.compositionRootHits, teethAllowed),
247
+ ...evaluateKernelInDomain(extra, input.layers, input.dependencies, input.layerForFile, teethAllowed),
248
+ ...evaluateDirectNew(extra, input.layers, input.managedNews, input.compositionRootHits, input.layerForFile, teethAllowed),
249
+ ...undeclared.findings,
250
+ ...evaluateTransportBypass(extra, input.dependencies, input.layerForFile, teethAllowed),
251
+ ].sort(compareFindings);
252
+ const completenessReasons = [...undeclared.completenessReasons].sort((left, right) => {
253
+ const leftKey = `${left.code}\0${left.file ?? ''}\0${left.message}`;
254
+ const rightKey = `${right.code}\0${right.file ?? ''}\0${right.message}`;
255
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
256
+ });
257
+ return { findings, completenessReasons };
258
+ }
259
+ /**
260
+ * Same sensors as `evaluateArkRunSensors`, filtered to the ESLint import/`new` envelope.
261
+ * Does not emit missing-root or undeclared-* (those need project-wide / declaration facts).
262
+ */
263
+ export function evaluateArkRunEditorSensors(input) {
264
+ const result = evaluateArkRunSensors(input);
265
+ return {
266
+ findings: result.findings.filter((item) => isArkRunEditorSensor(item.sensor)),
267
+ completenessReasons: [],
268
+ };
269
+ }
270
+ function fileMatchesCompositionRoot(pattern, file) {
271
+ if (pattern === file)
272
+ return true;
273
+ const star = pattern.indexOf('*');
274
+ if (star < 0)
275
+ return false;
276
+ const prefix = pattern.slice(0, star).replace(/\/$/, '');
277
+ return prefix.length > 0 && (file === prefix || file.startsWith(`${prefix}/`));
278
+ }
279
+ function compositionRootHitsForSource(extra, file, source) {
280
+ const hasFactory = extractArkRunKernelCallsFromSource(file, source).some((call) => call.kind === 'factory');
281
+ const hits = [];
282
+ for (const pattern of extra.compositionRoots) {
283
+ if (!fileMatchesCompositionRoot(pattern, file))
284
+ continue;
285
+ hits.push({ file, matchedRoot: pattern, hasKernelFactory: hasFactory });
286
+ }
287
+ return hits;
288
+ }
289
+ /**
290
+ * Import / `new` envelope from one proposed source (hook Write/Edit, snippet MCP).
291
+ * Missing-root and undeclared-* stay project-wide CLI/MCP/preflight.
292
+ */
293
+ export function evaluateArkRunEditorSensorsFromSource(input) {
294
+ const extra = input.arkRun;
295
+ if (!extra)
296
+ return { findings: [], completenessReasons: [] };
297
+ const admitted = new Set(extractArkRunImportedConstructorNamesFromSource(input.source));
298
+ return evaluateArkRunEditorSensors({
299
+ arkRun: extra,
300
+ layers: input.layers,
301
+ kernelCalls: [],
302
+ managedNews: extractArkRunManagedNewsFromSource(input.file, input.source, admitted),
303
+ compositionRootHits: compositionRootHitsForSource(extra, input.file, input.source),
304
+ declarations: [],
305
+ dependencies: extractArkRunValueImportDependenciesFromSource(input.file, input.source),
306
+ layerForFile: input.layerForFile,
307
+ classification: input.classification,
308
+ });
309
+ }
@@ -124,6 +124,7 @@ export function parseArgs(argv) {
124
124
  else if (arg === '--watch') args.watch = true;
125
125
  else if (arg === '--beginner') args.beginner = true;
126
126
  else if (arg === '--codex-home') args.codexHome = true;
127
+ else if (arg === '--prune-home-duplicates') args.pruneHomeDuplicates = true;
127
128
  else if (arg === '--claude-home') args.claudeHome = true;
128
129
  else if (arg === '--grok-home') args.grokHome = true;
129
130
  else if (arg === '--agent-homes') {
@@ -8,8 +8,8 @@
8
8
  * Pure CLI helper (bin/lib/config-contract.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
- /** Current published ark.config.json schema version (ADR 0012: 1.1 adds optional arkRules). */
12
- export const ARK_CONFIG_SCHEMA_VERSION = '1.1';
11
+ /** Current published ark.config.json schema version (ADR 0020: 1.2 adds optional arkRun). */
12
+ export const ARK_CONFIG_SCHEMA_VERSION = '1.2';
13
13
  export const ARK_CONFIG_SCHEMA_URL = 'https://unpkg.com/arkgate@2/schemas/ark.config.schema.json';
14
14
  const DEFAULT_LAYER_NAMES = [
15
15
  'DomainModel',
@@ -50,6 +50,7 @@ export const DEFAULT_ARK_CONFIG_RULES = createDefaultRules();
50
50
  export const ARK_CONFIG_MIGRATIONS = [
51
51
  { from: 'unversioned', to: '1.0' },
52
52
  { from: '1.0', to: '1.1' },
53
+ { from: '1.1', to: '1.2' },
53
54
  ];
54
55
  const stringArraySchema = {
55
56
  type: 'array',
@@ -112,6 +113,8 @@ export const ARK_CONFIG_SCHEMA = {
112
113
  additionalProperties: { type: 'string', minLength: 1 },
113
114
  default: {},
114
115
  },
116
+ /** ADR 0020 — optional ArkRun extra. Absence is silent; unknown keys fail closed. */
117
+ arkRun: { $ref: '#/$defs/arkRun' },
115
118
  /** Team parliament — GitHub handles or emails who may loosen the law (not part of policy hash). */
116
119
  stewards: { ...stringArraySchema, default: [] },
117
120
  },
@@ -181,6 +184,20 @@ export const ARK_CONFIG_SCHEMA = {
181
184
  allowDisabledPeerIsolation: { type: 'boolean', default: false },
182
185
  },
183
186
  },
187
+ arkRun: {
188
+ type: 'object',
189
+ additionalProperties: false,
190
+ properties: {
191
+ mode: {
192
+ type: 'string',
193
+ enum: ['advisory', 'enforced'],
194
+ default: 'advisory',
195
+ },
196
+ compositionRoots: { ...stringArraySchema, default: [] },
197
+ managedLayers: { ...stringArraySchema, default: [] },
198
+ requireDeclarations: { type: 'boolean', default: true },
199
+ },
200
+ },
184
201
  },
185
202
  };
186
203
  export class ArkConfigValidationError extends Error {
@@ -314,8 +331,19 @@ function validateNode(value, schema, path, root, issues) {
314
331
  }
315
332
  }
316
333
  }
317
- function defaultedConfig(input) {
334
+ function defaultedArkRun(value) {
335
+ if (!isObject(value))
336
+ return value;
318
337
  return {
338
+ ...value,
339
+ mode: value.mode === undefined ? 'advisory' : value.mode,
340
+ compositionRoots: value.compositionRoots === undefined ? [] : value.compositionRoots,
341
+ managedLayers: value.managedLayers === undefined ? [] : value.managedLayers,
342
+ requireDeclarations: value.requireDeclarations === undefined ? true : value.requireDeclarations,
343
+ };
344
+ }
345
+ function defaultedConfig(input) {
346
+ const result = {
319
347
  ...input,
320
348
  $schema: input.$schema === undefined ? ARK_CONFIG_SCHEMA_URL : input.$schema,
321
349
  schemaVersion: input.schemaVersion === undefined ? ARK_CONFIG_SCHEMA_VERSION : input.schemaVersion,
@@ -325,6 +353,57 @@ function defaultedConfig(input) {
325
353
  ? DEFAULT_ARK_CONFIG_RULES.map((rule) => ({ ...rule }))
326
354
  : input.rules,
327
355
  };
356
+ if (input.arkRun !== undefined)
357
+ result.arkRun = defaultedArkRun(input.arkRun);
358
+ return result;
359
+ }
360
+ function validateArkRunExtra(config, issues) {
361
+ const extra = config.arkRun;
362
+ if (extra === undefined || !isObject(extra))
363
+ return;
364
+ const layerNames = new Set();
365
+ if (Array.isArray(config.layers)) {
366
+ for (const layer of config.layers) {
367
+ if (isObject(layer) && typeof layer.name === 'string' && layer.name.length > 0) {
368
+ layerNames.add(layer.name);
369
+ }
370
+ }
371
+ }
372
+ const managed = extra.managedLayers;
373
+ if (Array.isArray(managed)) {
374
+ managed.forEach((name, index) => {
375
+ if (typeof name === 'string' && name.length > 0 && !layerNames.has(name)) {
376
+ issues.push({
377
+ path: `$.arkRun.managedLayers[${index}]`,
378
+ message: `layer ${JSON.stringify(name)} is not declared in layers[]`,
379
+ });
380
+ }
381
+ });
382
+ }
383
+ if (extra.mode === 'enforced') {
384
+ const roots = extra.compositionRoots;
385
+ if (!Array.isArray(roots) || roots.length === 0) {
386
+ issues.push({
387
+ path: '$.arkRun.compositionRoots',
388
+ message: 'ARKRUN_MISSING_ROOT: enforced mode requires at least one composition root',
389
+ });
390
+ }
391
+ if (!Array.isArray(managed) || managed.length === 0) {
392
+ issues.push({
393
+ path: '$.arkRun.managedLayers',
394
+ message: 'enforced mode requires at least one managed layer',
395
+ });
396
+ }
397
+ }
398
+ }
399
+ function migratedFromOf(originalVersion) {
400
+ if (originalVersion === ARK_CONFIG_SCHEMA_VERSION)
401
+ return null;
402
+ if (originalVersion === 'unversioned')
403
+ return 'unversioned';
404
+ if (originalVersion === '1.0' || originalVersion === '1.1')
405
+ return originalVersion;
406
+ return null;
328
407
  }
329
408
  function knownInputVersions() {
330
409
  const versions = new Set([ARK_CONFIG_SCHEMA_VERSION]);
@@ -369,8 +448,8 @@ export function migrateArkConfig(input, source = 'ark.config.json') {
369
448
  }
370
449
  let version = originalVersion;
371
450
  const working = { ...input };
372
- // Walk the migration table. Each step is a pure version stamp for 1.0→1.1
373
- // (arkRules is optional; absence needs no field rewrite).
451
+ // Walk the migration table. Each step is a pure version stamp (optional extras
452
+ // like arkRules / arkRun need no field rewrite when absent).
374
453
  let guard = 0;
375
454
  while (version !== ARK_CONFIG_SCHEMA_VERSION && guard < ARK_CONFIG_MIGRATIONS.length + 1) {
376
455
  guard += 1;
@@ -394,17 +473,13 @@ export function migrateArkConfig(input, source = 'ark.config.json') {
394
473
  },
395
474
  ]);
396
475
  }
397
- const migratedFrom = originalVersion === 'unversioned'
398
- ? 'unversioned'
399
- : originalVersion === '1.0'
400
- ? '1.0'
401
- : null;
402
- return { candidate: defaultedConfig(working), migratedFrom };
476
+ return { candidate: defaultedConfig(working), migratedFrom: migratedFromOf(originalVersion) };
403
477
  }
404
478
  export function loadArkConfigContract(input, source = 'ark.config.json') {
405
479
  const { candidate, migratedFrom } = migrateArkConfig(input, source);
406
480
  const issues = [];
407
481
  validateNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
482
+ validateArkRunExtra(candidate, issues);
408
483
  if (issues.length > 0)
409
484
  throw new ArkConfigValidationError(source, issues);
410
485
  return { config: candidate, migratedFrom };
@@ -56,6 +56,14 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
56
56
  entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
57
57
  entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
58
58
  entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green.'),
59
+ // ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
60
+ entry('ARKRUN_MISSING_ROOT', 'arkrun', 'No kernel factory in composition roots', 'The ArkRun extra is on but no createArkKernel / createStrictArkKernel / createArkKernelFromConfig / createStrictArkKernelFromConfig factory was found in arkRun.compositionRoots, so agents can skip the kernel while the write gate stays green.', 'Import createStrictArkKernel from @arkgate/runtime (never a removed arkgate/runtime shim) and call it in a composition root listed in arkRun.compositionRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.'),
61
+ entry('ARKRUN_KERNEL_IN_DOMAIN', 'arkrun', 'Domain-role layer imports the kernel', 'A Domain-role layer imports @arkgate/runtime or kernel types. Domain stays kernel-free; composition roots and adapters own the factory.', 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from @arkgate/runtime, never a removed arkgate/runtime shim, then preflight again. Never mechanical-safe.'),
62
+ entry('ARKRUN_DIRECT_NEW', 'arkrun', 'Managed type constructed with new', 'A managed non-Domain file constructs an admitted type with new outside an ArkRun composition-root factory, skipping kernel resolve/registration.', 'Resolve the type from the kernel instead of constructing it with new, then preflight again. Never mechanical-safe — rewiring construction is a design decision.'),
63
+ entry('ARKRUN_UNDECLARED_EMIT', 'arkrun', 'Emit name not in raises/sends', 'A publisher / publish / raise / send call-site literal is not listed in the file’s raises or sends declaration.', 'Add the existing call-site name to raises or sends on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new emit stays judgment.'),
64
+ entry('ARKRUN_UNDECLARED_HANDLE', 'arkrun', 'Handle name not in reactsTo', 'A subscribe / registerHandler call-site literal is not listed in the file’s reactsTo declaration.', 'Add the existing call-site name to reactsTo on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new handle stays judgment.'),
65
+ entry('ARKRUN_UNDECLARED_DEPEND', 'arkrun', 'Depend name not in uses', 'A resolve / resolveSingleton call-site literal is not listed in the file’s uses declaration.', 'Add the existing call-site name to uses on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new depend stays judgment.'),
66
+ entry('ARKRUN_TRANSPORT_BYPASS', 'arkrun', 'Homemade broker or emitter import', 'A managed layer imports a closed broker/queue/emitter specifier (EventEmitter, queue clients, …) instead of the ArkRun kernel transport.', 'Send through the ArkRun kernel transport instead of importing that broker or emitter, then preflight again. Never mechanical-safe — homemade buses stay judgment.'),
59
67
  // ── atomic preflight / change set ────────────────────────────────────────
60
68
  entry('INVALID_CHANGE_PATH', 'preflight', 'Unsafe change path', 'A change set entry is not a safe, non-empty project-relative path (absolute, escape, empty, or NUL).', 'Use canonical project-relative paths only in the atomic change set, then preflight again.'),
61
69
  entry('DUPLICATE_CHANGE_PATH', 'preflight', 'Duplicate path in change set', 'The atomic change set lists more than one operation for the same path.', 'Collapse to one create/update/delete per path, then preflight again.'),
@@ -20,8 +20,20 @@ import { printParseHealthSection, summarizeParseHealth } from './parse-health.mj
20
20
  import { detectGraphBlindSpots, printGraphBlindSection } from './graph-blind.mjs';
21
21
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
22
  import { collectStewardNudge } from './team-parliament-io.mjs';
23
+ import { formatArkRunDoctorLines, summarizeArkRunSection } from './ark-run-doctor.mjs';
23
24
 
24
- export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth, facts) {
25
+ function classificationFromCoverage(cov) {
26
+ return {
27
+ governedPercent: cov?.governed?.percent ?? null,
28
+ populatedLayerCount: Array.isArray(cov?.layers)
29
+ ? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
30
+ : null,
31
+ classifiedFiles: cov?.governed?.classifiedFiles ?? null,
32
+ };
33
+ }
34
+
35
+ /** `activeViolations` must already exclude frozen baseline keys (report residual parity). */
36
+ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth, facts, activeViolations) {
25
37
  const physicalCohesion = computePhysicalCohesion(root, files);
26
38
  const decisionMemory = computeReshapeDecisionMemory(root, files);
27
39
  physicalCohesion.reshapeDecisions = decisionMemory.summary;
@@ -41,6 +53,27 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
41
53
  })).filter((f) => f.path),
42
54
  }
43
55
  : undefined);
56
+ const classification = classificationFromCoverage(cov);
57
+ const rulesUnderContract = summarizeRulesUnderContract(root, config, factPaths, classification);
58
+ const arkRun = summarizeArkRunSection({
59
+ arkRun: config?.arkRun,
60
+ findings: activeViolations,
61
+ classification,
62
+ arkRules: {
63
+ active: rulesUnderContract?.active === true,
64
+ structureEnforced: rulesUnderContract?.mergePlanes?.structureSensors?.enforced,
65
+ structureTotal: rulesUnderContract?.mergePlanes?.structureSensors?.total,
66
+ structureAdvisory: rulesUnderContract?.mergePlanes?.structureSensors?.advisory,
67
+ invariantEnforced: rulesUnderContract?.mergePlanes?.invariants?.enforced,
68
+ invariantTotal: rulesUnderContract?.mergePlanes?.invariants?.total,
69
+ invariantAdvisory: rulesUnderContract?.mergePlanes?.invariants?.advisory,
70
+ covered: rulesUnderContract?.mergePlanes?.invariants?.covered,
71
+ uncovered: rulesUnderContract?.mergePlanes?.invariants?.uncovered,
72
+ },
73
+ });
74
+ if (rulesUnderContract?.mergePlanes) {
75
+ rulesUnderContract.mergePlanes = arkRun.mergePlanes;
76
+ }
44
77
  return {
45
78
  contractHealth: computeContractHealth(root, config, cov, rules),
46
79
  ambientState: computeAmbientState(ts, root, config, files),
@@ -51,13 +84,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
51
84
  // AR12 — Rules under contract (honest counts; real test I/O, never empty-fileContents stub).
52
85
  // P1M: pass classification so extraMergeTeeth cannot arm at 0% governed.
53
86
  stewardNudge: collectStewardNudge(root, config),
54
- rulesUnderContract: summarizeRulesUnderContract(root, config, factPaths, {
55
- governedPercent: cov?.governed?.percent ?? null,
56
- populatedLayerCount: Array.isArray(cov?.layers)
57
- ? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
58
- : null,
59
- classifiedFiles: cov?.governed?.classifiedFiles ?? null,
60
- }),
87
+ rulesUnderContract,
88
+ arkRun,
61
89
  };
62
90
  }
63
91
 
@@ -79,4 +107,13 @@ export function printDoctorAdvisories(advisories, io) {
79
107
  io.line(io.warn, nudge.ask);
80
108
  if (nudge.nextAction) io.line(' ', io.color.dim(`Next: ${nudge.nextAction}`));
81
109
  }
110
+ const arkRun = advisories.arkRun;
111
+ if (arkRun && arkRun.notAScore === true) {
112
+ console.log('');
113
+ console.log(io.color.bold('ArkRun (not a score)'));
114
+ const mark = arkRun.active && arkRun.residual?.count > 0 ? io.warn : ' ';
115
+ for (const text of formatArkRunDoctorLines(arkRun)) {
116
+ io.line(mark, text);
117
+ }
118
+ }
82
119
  }
@@ -140,6 +140,16 @@ export function printDoctorCompactHuman(view) {
140
140
  line(warn, nudge.ask);
141
141
  }
142
142
 
143
+ const arkRun = doctorAdvisories.arkRun;
144
+ if (arkRun?.active === true && arkRun.notAScore === true) {
145
+ console.log('');
146
+ const residual = Number(arkRun.residual?.count) || 0;
147
+ line(
148
+ residual > 0 ? warn : ' ',
149
+ `ArkRun: ${arkRun.mode || 'on'} · residual=${residual} · not a score`
150
+ );
151
+ }
152
+
143
153
  if (violations.length === 0) {
144
154
  if (!analysisComplete) {
145
155
  console.log('');
@@ -73,7 +73,11 @@ export function collectDoctorNextActions(ctx) {
73
73
  } else if (remStale > 0) {
74
74
  actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
75
75
  }
76
- if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.preferProject !== true) {
76
+ if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.duplicateHome) {
77
+ actions.push(
78
+ 'remove duplicate Codex home /ark-* skills (project .agents/skills is enough): --install-agent-gates --skills-only --prune-home-duplicates'
79
+ );
80
+ } else if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.preferProject !== true) {
77
81
  actions.push(
78
82
  ctx.codexHomeGap.catalogMetadataInvalid
79
83
  ? 'repair invalid Codex home catalog metadata after verifying the newest installed version'
@@ -599,21 +599,22 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
599
599
  patternBets: patternBetsForLoop,
600
600
  designSmells,
601
601
  });
602
- const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
603
- // AR12 compute once for JSON + product honesty + human lines.
604
- // P1M: classification gate on extraMergeTeeth (no teeth at empty graph).
605
- const rulesUnderContract = summarizeRulesUnderContract(
602
+ const activeViolations = baseline.exists
603
+ ? violations.filter((_, index) => !baseline.keys.has(occurrenceKeys[index]))
604
+ : violations;
605
+ const doctorAdvisories = computeDoctorAdvisories(
606
606
  root,
607
607
  config,
608
+ cov,
609
+ rules,
610
+ files,
611
+ options.ts,
612
+ options.parseHealth,
608
613
  options.facts ?? options.architectureFacts,
609
- {
610
- governedPercent: cov.governed?.percent ?? null,
611
- populatedLayerCount: Array.isArray(cov.layers)
612
- ? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
613
- : null,
614
- classifiedFiles: cov.governed?.classifiedFiles ?? null,
615
- }
614
+ activeViolations
616
615
  );
616
+ const rulesUnderContract = doctorAdvisories.rulesUnderContract;
617
+ const arkRun = doctorAdvisories.arkRun;
617
618
  // Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
618
619
  const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
619
620
  // Evidence-backed hard only (never capabilities-from-hook-files alone).
@@ -653,9 +654,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
653
654
  residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
654
655
  pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
655
656
  arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
656
- ? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
657
- : rulesUnderContract?.active === true
658
- ? { active: true, extraMergeTeeth: false }
657
+ ? {
658
+ active: rulesUnderContract.active === true || arkRun?.active === true,
659
+ ...rulesUnderContract.mergePlanes,
660
+ }
661
+ : rulesUnderContract?.active === true || arkRun?.active === true
662
+ ? { active: true, extraMergeTeeth: arkRun?.extraMergeTeeth === true }
659
663
  : null,
660
664
  primaryNextAction:
661
665
  adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext,
@@ -746,10 +750,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
746
750
  // Dual-truth: managed CLI vs package.json pin (not a gate fail).
747
751
  packageVersionTruth,
748
752
  // Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
749
- // (rulesUnderContract is also in advisories; re-assert after spread so mergePlanes wins.)
753
+ // Re-assert after spread so mergePlanes from this scan wins.
750
754
  ...doctorAdvisories,
751
- // AR12 + P1-M mergePlanes (authoritative; after advisories spread).
752
755
  rulesUnderContract,
756
+ arkRun,
753
757
  // P0-B — single anti-false-green honesty surface (never a score).
754
758
  productHonesty,
755
759
  governed: cov.governed,