arkgate 3.0.4 → 3.1.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 (42) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +29 -9
  3. package/bin/ark-check.mjs +69 -54
  4. package/bin/ark-mcp.mjs +267 -26
  5. package/bin/ark.mjs +50 -3
  6. package/bin/lib/adapter-contract.mjs +27 -1
  7. package/bin/lib/agent-gates.mjs +9 -0
  8. package/bin/lib/analysis-engine.mjs +7 -1169
  9. package/bin/lib/ci-and-commands.mjs +4 -0
  10. package/bin/lib/codex-home.mjs +10 -1
  11. package/bin/lib/doctor-plan.mjs +37 -9
  12. package/bin/lib/host-support-matrix.mjs +6 -2
  13. package/bin/lib/install-migrate.mjs +81 -25
  14. package/bin/lib/mcp-adoption.mjs +8 -0
  15. package/bin/lib/policy-delta-io.mjs +161 -0
  16. package/bin/lib/prepare-change.mjs +186 -0
  17. package/bin/lib/remediation.mjs +24 -0
  18. package/bin/lib/skill-install.mjs +302 -22
  19. package/bin/lib/violations.mjs +2 -2
  20. package/bin/lib/weakest-link.mjs +61 -12
  21. package/bin/lib/write-path-capabilities.mjs +70 -2
  22. package/bin/lib/write-path-detect.mjs +18 -11
  23. package/dist/eslint/index.cjs +3 -977
  24. package/dist/eslint/index.js +3 -931
  25. package/dist/index.cjs +6 -1960
  26. package/dist/index.d.cts +152 -5
  27. package/dist/index.d.ts +152 -5
  28. package/dist/index.js +6 -1908
  29. package/docs/agent-guide.md +16 -2
  30. package/docs/ai-gates.md +35 -3
  31. package/docs/configuration.md +44 -0
  32. package/docs/package-surface.md +8 -1
  33. package/docs/threat-model.md +7 -4
  34. package/package.json +6 -5
  35. package/schemas/ark.analysis-result.schema.json +5 -1
  36. package/schemas/ark.change-map.schema.json +77 -0
  37. package/server.json +2 -2
  38. package/templates/skills/ark-upgrade.md +9 -5
  39. package/docs/ark-check-example.json +0 -87
  40. package/docs/demos/03-copilot-autopilot.md +0 -93
  41. package/docs/migrate-from-ark-runtime-kernel.md +0 -174
  42. package/docs/production-hardening.md +0 -100
package/bin/ark-mcp.mjs CHANGED
@@ -18,6 +18,9 @@
18
18
  * discarded if post-patch still invalid.
19
19
  * - tool ark_prepare_write — W2: place + constrain + validate + autoPatch + judgmentBrief
20
20
  * + contentHash (composes ark_place + write gate; not a second contract).
21
+ * - tool ark_prepare_change — atomically preflights a create/update/delete batch without writes.
22
+ * - tool ark_policy_delta — classifies a base/candidate ark.config.json transition and
23
+ * rejects weakening without an exact hash-bound acknowledgement.
21
24
  * - tool ark_recommend — deterministic application-shape plan (same as
22
25
  * ark-check --recommend --json)
23
26
  *
@@ -61,6 +64,8 @@ import { composePrepareWrite } from './lib/prepare-write.mjs';
61
64
  import { loadArkConfigContract } from './lib/config-contract.mjs';
62
65
  import { ARK_ANALYSIS_RESULT_SCHEMA, createAdapterResult } from './lib/adapter-contract.mjs';
63
66
  import { loadGoldenPattern, attachGoldenToPlacement } from './lib/golden-pattern.mjs';
67
+ import { prepareChangeFromRoot } from './lib/prepare-change.mjs';
68
+ import { detectWritePathCapabilities } from './lib/write-path-detect.mjs';
64
69
 
65
70
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
66
71
 
@@ -201,17 +206,23 @@ function applyCodexUpdatePatch(current, lines) {
201
206
  let source = current.split('\n');
202
207
  let cursor = 0;
203
208
  const hunks = [];
204
- let hunk = [];
209
+ let hunk = null;
205
210
  for (const line of lines) {
206
211
  if (line.startsWith('@@')) {
207
- if (hunk.length > 0) hunks.push(hunk);
208
- hunk = [];
212
+ if (hunk) hunks.push(hunk);
213
+ hunk = { anchor: line.slice(2).trim(), entries: [] };
209
214
  } else if (/^[ +\-]/.test(line)) {
210
- hunk.push(line);
215
+ if (!hunk) return null;
216
+ hunk.entries.push(line);
211
217
  }
212
218
  }
213
- if (hunk.length > 0) hunks.push(hunk);
214
- for (const entries of hunks) {
219
+ if (hunk) hunks.push(hunk);
220
+ for (const { anchor, entries } of hunks) {
221
+ if (anchor) {
222
+ const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
223
+ if (anchorAt < 0) return null;
224
+ cursor = anchorAt + 1;
225
+ }
215
226
  const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
216
227
  const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
217
228
  let found = -1;
@@ -229,36 +240,94 @@ function applyCodexUpdatePatch(current, lines) {
229
240
  }
230
241
 
231
242
  function codexPatchWrites(patch, root) {
232
- if (typeof patch !== 'string' || !patch.includes('*** Begin Patch')) return [];
243
+ if (typeof patch !== 'string') {
244
+ return { writes: [], complete: false };
245
+ }
233
246
  const lines = patch.split('\n');
247
+ const begin = lines.indexOf('*** Begin Patch');
248
+ const end = lines.indexOf('*** End Patch', begin + 1);
249
+ if (begin < 0 || end <= begin) return { writes: [], complete: false };
234
250
  const writes = [];
235
- for (let index = 0; index < lines.length; index += 1) {
251
+ const seenPaths = new Set();
252
+ let complete = [
253
+ ...lines.slice(0, begin),
254
+ ...lines.slice(end + 1),
255
+ ].every((line) => line.trim() === '');
256
+ let sawFileDirective = false;
257
+ for (let index = begin + 1; index < end; index += 1) {
236
258
  const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
237
- if (!match) continue;
259
+ if (!match) {
260
+ if (lines[index].trim() !== '') complete = false;
261
+ continue;
262
+ }
263
+ sawFileDirective = true;
238
264
  const [, action, relativePath] = match;
239
265
  const body = [];
240
- for (index += 1; index < lines.length && !lines[index].startsWith('*** '); index += 1) {
266
+ for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
241
267
  body.push(lines[index]);
242
268
  }
243
269
  index -= 1;
244
- if (action === 'Delete') continue;
245
270
  const filePath = path.resolve(root, relativePath);
271
+ const rel = path.relative(root, filePath);
272
+ if (
273
+ seenPaths.has(filePath) ||
274
+ rel.startsWith(`..${path.sep}`) ||
275
+ rel === '..' ||
276
+ path.isAbsolute(rel)
277
+ ) {
278
+ complete = false;
279
+ continue;
280
+ }
281
+ seenPaths.add(filePath);
282
+ if (action === 'Delete') {
283
+ if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
284
+ complete = false;
285
+ continue;
286
+ }
287
+ writes.push({ path: relativePath, delete: true });
288
+ continue;
289
+ }
246
290
  let content;
247
291
  if (action === 'Add') {
292
+ if (
293
+ body.length === 0 ||
294
+ fs.existsSync(filePath) ||
295
+ body.some((line) => !line.startsWith('+'))
296
+ ) {
297
+ complete = false;
298
+ continue;
299
+ }
248
300
  content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
249
301
  if (body.some((line) => line.startsWith('+'))) content += '\n';
250
302
  } else {
303
+ if (
304
+ !body.some((line) => line.startsWith('@@')) ||
305
+ body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
306
+ ) {
307
+ complete = false;
308
+ continue;
309
+ }
251
310
  let current;
252
311
  try {
253
312
  current = fs.readFileSync(filePath, 'utf8');
254
313
  } catch {
314
+ complete = false;
255
315
  continue;
256
316
  }
257
317
  content = applyCodexUpdatePatch(current, body);
318
+ if (content === null) complete = false;
258
319
  }
259
- if (typeof content === 'string') writes.push({ filePath, content });
320
+ if (typeof content === 'string') writes.push({ path: relativePath, filePath, content });
260
321
  }
261
- return writes;
322
+ return { writes, complete: complete && sawFileDirective };
323
+ }
324
+
325
+ function hookEnforcement(root, host, operation, completePatch = false) {
326
+ return detectWritePathCapabilities(root, host, {
327
+ boundary: 'pre-tool',
328
+ operation,
329
+ completePatch,
330
+ }).enforcementLadder;
262
331
  }
263
332
 
264
333
  /**
@@ -308,22 +377,75 @@ function runHook(gate, config, args, ts) {
308
377
  runHookPayload(payload, gate, config, args, ts);
309
378
  }
310
379
 
311
- function runHookPayload(payload, gate, config, args, ts) {
380
+ function runHookPayload(payload, gate, config, args, ts, attemptContext) {
312
381
  const { toolName, toolInput, grokStyle } = normalizeHookPayload(payload);
313
382
  if (toolName === 'ApplyPatch') {
314
383
  const patch = toolInput.patch ?? toolInput.input ?? toolInput.content;
315
- for (const write of codexPatchWrites(patch, args.root)) {
316
- runHookPayload(
317
- {
318
- tool_name: 'Write',
319
- tool_input: { file_path: write.filePath, content: write.content },
320
- },
321
- gate,
384
+ const parsedPatch = codexPatchWrites(patch, args.root);
385
+ // Codex ApplyPatch is only preflighted when Ark can reconstruct every file operation.
386
+ // An incomplete reconstruction must not be mislabeled as atomic or hard enforcement.
387
+ if (!parsedPatch.complete) return;
388
+ const patchWrites = parsedPatch.writes;
389
+ const governedWrites = patchWrites.filter((change) => {
390
+ const relative = String(change.path).replace(/\\/g, '/');
391
+ if (!SOURCE_FILE.test(relative) || relative.endsWith('.d.ts')) return false;
392
+ return Boolean(inferLayer(path.resolve(args.root, relative), config, args.root));
393
+ });
394
+ const changes = governedWrites.map(({ path: relativePath, content, delete: deleted }) =>
395
+ deleted ? { path: relativePath, delete: true } : { path: relativePath, content }
396
+ );
397
+ if (changes.length === 0) return;
398
+ let result;
399
+ try {
400
+ result = prepareChangeFromRoot({
401
+ root: args.root,
322
402
  config,
323
- args,
324
- ts
403
+ configSource: path.isAbsolute(args.config)
404
+ ? args.config
405
+ : path.join(args.root, args.config),
406
+ changes,
407
+ });
408
+ } catch {
409
+ return;
410
+ }
411
+ if (result.valid) {
412
+ for (const write of governedWrites) {
413
+ if (typeof write.content !== 'string') continue;
414
+ runHookPayload(
415
+ {
416
+ tool_name: 'Write',
417
+ tool_input: { file_path: write.filePath, content: write.content },
418
+ },
419
+ gate,
420
+ config,
421
+ args,
422
+ ts,
423
+ { host: 'codex', operation: 'apply_patch', completePatch: true }
424
+ );
425
+ }
426
+ return;
427
+ }
428
+ const message = [
429
+ `Ark architecture gate blocked this complete ${toolName} (${changes.length} governed file(s)):`,
430
+ ...result.diagnostics.map(
431
+ (diagnostic) =>
432
+ `- [${diagnostic.ruleId}] ${diagnostic.message}\n Next action: ${diagnostic.nextAction}`
433
+ ),
434
+ 'No project file was written. Fix the complete patch and retry.',
435
+ ].join('\n');
436
+ process.stderr.write(`${message}\n`);
437
+ if (args.hookRepair) {
438
+ process.stderr.write(
439
+ `ARK_REPAIR_JSON:${JSON.stringify({
440
+ ...result,
441
+ mode: 'repair',
442
+ decision: 'deny',
443
+ enforcement: hookEnforcement(args.root, 'codex', 'apply_patch', true),
444
+ autoPatch: null,
445
+ })}\n`
325
446
  );
326
447
  }
448
+ process.exitCode = 2;
327
449
  return;
328
450
  }
329
451
  const filePath = toolInput.file_path;
@@ -392,9 +514,9 @@ function runHookPayload(payload, gate, config, args, ts) {
392
514
  violations: newViolations.map((violation) => ({ ...violation, file: normalizedRel })),
393
515
  });
394
516
 
395
- const lines = newViolations.map(
396
- (violation) =>
397
- `- [${violation.ruleId}] ${violation.message}${violation.line ? ` (line ${violation.line})` : ''}`
517
+ const lines = adapterResult.diagnostics.map(
518
+ (diagnostic) =>
519
+ `- [${diagnostic.ruleId}] ${diagnostic.message}${diagnostic.location.line ? ` (line ${diagnostic.location.line})` : ''}\n Next action: ${diagnostic.nextAction}`
398
520
  );
399
521
  // Surface the per-violation fix hints (the gate carries them in `suggestion`,
400
522
  // but the hook was dropping them). Dedupe so two infra violations sharing one
@@ -436,6 +558,13 @@ function runHookPayload(payload, gate, config, args, ts) {
436
558
  mode: 'repair',
437
559
  decision: 'deny',
438
560
  filePath: normalizedRel,
561
+ enforcement: hookEnforcement(
562
+ args.root,
563
+ attemptContext?.host ?? (grokStyle ? 'grok' : 'claude'),
564
+ attemptContext?.operation ??
565
+ (grokStyle ? (toolName === 'Edit' ? 'search_replace' : 'write') : toolName),
566
+ Boolean(attemptContext?.completePatch)
567
+ ),
439
568
  ...(layer ? { layer } : {}),
440
569
  ...(autoPatch
441
570
  ? {
@@ -736,6 +865,34 @@ async function main() {
736
865
  },
737
866
  outputSchema: ARK_ANALYSIS_RESULT_SCHEMA,
738
867
  },
868
+ {
869
+ name: 'ark_policy_delta',
870
+ description:
871
+ 'Classify a complete ark.config.json transition as strengthening, neutral, ' +
872
+ 'judgment-required, or weakening. Pass the previous baseConfig and optional ' +
873
+ 'candidateConfig (defaults to this project contract). Weakening and judgment-required ' +
874
+ 'results set isError unless acknowledgement exactly matches both policy hashes and all ' +
875
+ 'blocking finding ids. Read-only; never edits the contract.',
876
+ inputSchema: {
877
+ type: 'object',
878
+ properties: {
879
+ baseConfig: {
880
+ type: 'object',
881
+ description: 'Previous complete ark.config.json object.',
882
+ },
883
+ candidateConfig: {
884
+ type: 'object',
885
+ description: 'Candidate complete config; defaults to the current project contract.',
886
+ },
887
+ acknowledgement: {
888
+ type: 'object',
889
+ description:
890
+ 'Optional schemaVersion/basePolicyHash/candidatePolicyHash/findingIds/reason object.',
891
+ },
892
+ },
893
+ required: ['baseConfig'],
894
+ },
895
+ },
739
896
  {
740
897
  name: 'ark_coverage',
741
898
  description:
@@ -797,6 +954,38 @@ async function main() {
797
954
  required: ['source'],
798
955
  },
799
956
  },
957
+ {
958
+ name: 'ark_prepare_change',
959
+ description:
960
+ 'Validate one complete governed-source create/update/delete batch as an atomic in-memory candidate. ' +
961
+ 'Catches cross-file forbidden edges and cycles before any host write, and returns ' +
962
+ 'per-file content hashes plus base/candidate tree and policy hashes. Never writes files.',
963
+ inputSchema: {
964
+ type: 'object',
965
+ properties: {
966
+ changes: {
967
+ type: 'array',
968
+ description:
969
+ 'Full candidate batch. Each item is {path, content} for create/update or {path, delete:true}.',
970
+ items: {
971
+ type: 'object',
972
+ properties: {
973
+ path: { type: 'string' },
974
+ content: { type: 'string' },
975
+ delete: { type: 'boolean' },
976
+ },
977
+ required: ['path'],
978
+ },
979
+ },
980
+ changeMap: {
981
+ type: 'object',
982
+ description:
983
+ 'Optional strict schema 1.0 architecture change map. Omit it to use ordinary atomic preflight.',
984
+ },
985
+ },
986
+ required: ['changes'],
987
+ },
988
+ },
800
989
  {
801
990
  name: 'ark_recommend',
802
991
  description:
@@ -993,6 +1182,33 @@ async function main() {
993
1182
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], isError: false };
994
1183
  }
995
1184
 
1185
+ function runPolicyDeltaTool(params) {
1186
+ const baseConfig = params?.arguments?.baseConfig;
1187
+ if (!baseConfig || typeof baseConfig !== 'object' || Array.isArray(baseConfig)) {
1188
+ return {
1189
+ content: [{ type: 'text', text: 'ark_policy_delta requires baseConfig (object).' }],
1190
+ isError: true,
1191
+ };
1192
+ }
1193
+ try {
1194
+ const result = ark.analyzePolicyDelta({
1195
+ baseConfig,
1196
+ candidateConfig: params?.arguments?.candidateConfig ?? config,
1197
+ acknowledgement: params?.arguments?.acknowledgement,
1198
+ });
1199
+ return {
1200
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1201
+ structuredContent: result,
1202
+ isError: !result.valid,
1203
+ };
1204
+ } catch (error) {
1205
+ return {
1206
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1207
+ isError: true,
1208
+ };
1209
+ }
1210
+ }
1211
+
996
1212
  function runRecommendTool() {
997
1213
  const { data, raw } = runArkCheckJson(['--recommend']);
998
1214
  if (!data) {
@@ -1153,6 +1369,29 @@ async function main() {
1153
1369
  };
1154
1370
  }
1155
1371
 
1372
+ function runPrepareChange(params) {
1373
+ try {
1374
+ const result = prepareChangeFromRoot({
1375
+ root: args.root,
1376
+ config,
1377
+ configSource: configPath,
1378
+ changes: params?.arguments?.changes,
1379
+ changeMap: params?.arguments?.changeMap,
1380
+ changeMapSource: 'ark_prepare_change.changeMap',
1381
+ });
1382
+ return {
1383
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1384
+ structuredContent: result,
1385
+ isError: !result.valid,
1386
+ };
1387
+ } catch (error) {
1388
+ return {
1389
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1390
+ isError: true,
1391
+ };
1392
+ }
1393
+ }
1394
+
1156
1395
  function runSuggestIncludeTool() {
1157
1396
  try {
1158
1397
  const workspaces = detectWorkspaces(args.root);
@@ -1193,9 +1432,11 @@ async function main() {
1193
1432
  const TOOL_HANDLERS = {
1194
1433
  validate_code: runValidate,
1195
1434
  ark_check: runCheckTool,
1435
+ ark_policy_delta: runPolicyDeltaTool,
1196
1436
  ark_coverage: runCoverageTool,
1197
1437
  ark_place: runPlace,
1198
1438
  ark_prepare_write: runPrepareWrite,
1439
+ ark_prepare_change: runPrepareChange,
1199
1440
  ark_recommend: runRecommendTool,
1200
1441
  ark_suggest_include: runSuggestIncludeTool,
1201
1442
  };
package/bin/ark.mjs CHANGED
@@ -22,6 +22,13 @@ import { pinArkgateDevDependency, FALSE_GREEN_GAP_ID } from './lib/field-install
22
22
  import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
23
23
  import { applyStartPreview, planStart, renderStartPreview } from './lib/start-preview.mjs';
24
24
  import { detectActiveAgentHost } from './lib/skill-install.mjs';
25
+ import { loadArkConfigContract } from './lib/config-contract.mjs';
26
+ import {
27
+ prepareChangeFromRoot,
28
+ readChangeMapFile,
29
+ readChangeSetFile,
30
+ renderChangePreflight,
31
+ } from './lib/prepare-change.mjs';
25
32
 
26
33
  const here = path.dirname(fileURLToPath(import.meta.url));
27
34
  const arkCheck = path.join(here, 'ark-check.mjs');
@@ -56,6 +63,7 @@ function parseArgs(argv) {
56
63
  const args = {
57
64
  command: undefined,
58
65
  root: process.cwd(),
66
+ config: 'ark.config.json',
59
67
  yes: false,
60
68
  force: false,
61
69
  strict: true,
@@ -101,6 +109,9 @@ function parseArgs(argv) {
101
109
  else if (arg === '--skip-package-manager') args.skipPackageManager = true;
102
110
  else if (arg === '--remove-host') args.removeHost = requireValue(arg, i++).trim().toLowerCase();
103
111
  else if (arg === '--preset') args.preset = requireValue(arg, i++);
112
+ else if (arg === '--config') args.config = requireValue(arg, i++);
113
+ else if (arg === '--changes') args.changes = requireValue(arg, i++);
114
+ else if (arg === '--change-map') args.changeMap = requireValue(arg, i++);
104
115
  else if (arg === '--archetype') args.archetype = requireValue(arg, i++);
105
116
  else if (arg === '--tools') args.tools = requireValue(arg, i++);
106
117
  else if (arg === '--require-write-hook') {
@@ -121,6 +132,7 @@ function usage() {
121
132
  ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture]
122
133
  [--archetype <playbook-id>] [--tools <list>] [--require-write-hook <host>] [--yes] [--force] [--no-strict]
123
134
  ark upgrade [--root <project>] [--no-install] [--no-strict]
135
+ ark preflight --changes <change-set.json> [--change-map <map.json>] [--root <project>] [--config ark.config.json] [--json]
124
136
 
125
137
  Commands:
126
138
  start New here? Analyze and preview the complete setup. Read-only unless --apply.
@@ -128,6 +140,7 @@ Commands:
128
140
  upgrade One command to update Ark: bump the package to @latest, refresh gate
129
141
  templates + /ark-* skills (and Codex home prompts), migrate command
130
142
  runners to this project's package manager, then run the strict check.
143
+ preflight Validate one atomic create/update/delete set without writing project files.
131
144
  (alias: ark update)
132
145
 
133
146
  Options:
@@ -213,9 +226,9 @@ async function upgrade(args) {
213
226
  let status = runArkCheck(['--root', root, '--install-agent-gates'], { cwd: root });
214
227
  if (status !== 0) return status;
215
228
 
216
- // Codex loads slash-command prompts from $CODEX_HOME/prompts, not the repo refresh those
217
- // when a Codex home exists. --force rewrites temp/upgrade MCP roots to this project + arkgate-mcp.
218
- // Non-fatal: a permission error (e.g. sandbox) shouldn't fail the whole upgrade.
229
+ // Codex home skill catalog is $CODEX_HOME/skills/<name>/SKILL.md (repo uses .agents/skills/).
230
+ // Refresh home when a Codex home exists. --force rewrites temp/upgrade MCP roots to this
231
+ // project + arkgate-mcp. Non-fatal: a permission error (e.g. sandbox) shouldn't fail upgrade.
219
232
  const codexHomeBase = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
220
233
  if (fs.existsSync(codexHomeBase)) {
221
234
  console.log(`\n Refreshing Codex home (${codexHomeBase})…`);
@@ -775,6 +788,13 @@ async function main() {
775
788
  console.error('--require-write-hook is supported by ark start and ark init.');
776
789
  return 2;
777
790
  }
791
+ if (
792
+ args.command !== 'preflight' &&
793
+ (args.changes || args.changeMap || args.config !== 'ark.config.json')
794
+ ) {
795
+ console.error('--changes, --change-map, and --config are supported by ark preflight.');
796
+ return 2;
797
+ }
778
798
  const enforcement = validateHardWriteRequest({
779
799
  root: args.root,
780
800
  host: args.requireWriteHook,
@@ -817,6 +837,33 @@ async function main() {
817
837
  }
818
838
  }
819
839
 
840
+ if (args.command === 'preflight') {
841
+ try {
842
+ if (!args.changes) throw new Error('ark preflight requires --changes <change-set.json>.');
843
+ const configPath = path.isAbsolute(args.config)
844
+ ? args.config
845
+ : path.join(args.root, args.config);
846
+ const config = loadArkConfigContract(
847
+ JSON.parse(fs.readFileSync(configPath, 'utf8')),
848
+ configPath
849
+ ).config;
850
+ const changeMap = args.changeMap ? readChangeMapFile(args.root, args.changeMap) : undefined;
851
+ const result = prepareChangeFromRoot({
852
+ root: args.root,
853
+ config,
854
+ configSource: configPath,
855
+ changes: readChangeSetFile(args.root, args.changes),
856
+ ...(changeMap ? { changeMap: changeMap.input, changeMapSource: changeMap.source } : {}),
857
+ });
858
+ if (args.json) console.log(JSON.stringify(result, null, 2));
859
+ else renderChangePreflight(result);
860
+ return result.valid ? 0 : 1;
861
+ } catch (error) {
862
+ console.error(error instanceof Error ? error.message : String(error));
863
+ return 2;
864
+ }
865
+ }
866
+
820
867
  console.error(`Unknown command: ${args.command}`);
821
868
  console.error(usage());
822
869
  return 2;
@@ -8,13 +8,37 @@
8
8
  * Pure CLI helper (bin/lib/adapter-contract.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
- export const ARK_ANALYSIS_RESULT_SCHEMA_VERSION = '1.0';
11
+ export const ARK_ANALYSIS_RESULT_SCHEMA_VERSION = '1.1';
12
12
  function text(value) {
13
13
  return typeof value === 'string' && value.length > 0 ? value : undefined;
14
14
  }
15
15
  function positiveInteger(value, fallback) {
16
16
  return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
17
17
  }
18
+ function nextActionForDiagnostic(ruleId, evidence, violation) {
19
+ if (ruleId === 'LAYER_IMPORT_VIOLATION') {
20
+ if (evidence.typeOnly ||
21
+ violation.targetTypeOnlyExports === true ||
22
+ violation.namedBindingsTypeOnly === true) {
23
+ return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
24
+ }
25
+ if (violation.peerIsolation === true) {
26
+ return 'Extract the shared dependency to a shared layer, then preflight again.';
27
+ }
28
+ return `Define a port in ${evidence.fromLayer ?? 'the source layer'}, inject the ${evidence.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
29
+ }
30
+ if (ruleId === 'FORBIDDEN_GLOBAL') {
31
+ return `Inject ${evidence.target ?? 'the capability'} through a port, then preflight again.`;
32
+ }
33
+ if (ruleId === 'CIRCULAR_DEPENDENCY') {
34
+ return 'Extract the shared dependency into a third module, then preflight again.';
35
+ }
36
+ if (ruleId === 'RAW_EVENT_PUBLISH')
37
+ return 'Publish through a registered intent creator, then run Ark again.';
38
+ if (ruleId === 'PUBLISH_MISSING_SOURCE')
39
+ return 'Add metadata.source to the publish call, then run Ark again.';
40
+ return `Resolve ${ruleId} without weakening ark.config.json, then run Ark again.`;
41
+ }
18
42
  export function toAdapterDiagnostic(violation, fallbackSeverity = 'error') {
19
43
  const ruleId = text(violation.ruleId) ?? text(violation.code) ?? 'ARK_UNKNOWN';
20
44
  const severity = violation.severity === 'warning' ? 'warning' : fallbackSeverity;
@@ -34,6 +58,7 @@ export function toAdapterDiagnostic(violation, fallbackSeverity = 'error') {
34
58
  column: positiveInteger(violation.column, 1),
35
59
  },
36
60
  evidence,
61
+ nextAction: text(violation.nextAction) ?? nextActionForDiagnostic(ruleId, evidence, violation),
37
62
  };
38
63
  }
39
64
  export function createAdapterResult(input) {
@@ -86,6 +111,7 @@ export const ARK_ANALYSIS_RESULT_SCHEMA = {
86
111
  typeOnly: { type: 'boolean' },
87
112
  },
88
113
  },
114
+ nextAction: { type: 'string', minLength: 1 },
89
115
  },
90
116
  },
91
117
  },
@@ -10,6 +10,7 @@ export {
10
10
  codexPrimaryTable,
11
11
  codexProjectSlug,
12
12
  codexPromptsDir,
13
+ codexSkillsDir,
13
14
  codexScopedTableForRoot,
14
15
  extractCodexArkRootFromToml,
15
16
  extractCodexRootFromBlock,
@@ -77,6 +78,7 @@ export {
77
78
  normalizeToolsList,
78
79
  resolveTools,
79
80
  KNOWN_TOOLS,
81
+ SKILL_TOOL_TARGETS,
80
82
  detectActiveAgentHost,
81
83
  codexConcernIsActive,
82
84
  arkPackageVersion,
@@ -86,7 +88,13 @@ export {
86
88
  skillTemplates,
87
89
  skillTemplateNames,
88
90
  detectCodexHomeGap,
91
+ detectCodexRepoSkillGap,
92
+ assessCodexSkillParity,
93
+ assessSkillCatalogParity,
89
94
  detectSkillGaps,
95
+ agentsMdSkillRefs,
96
+ verifyHostSkillCatalog,
97
+ printSkillAndCodexGapHints,
90
98
  } from './skill-install.mjs';
91
99
 
92
100
  export { detectDeployPathQuality } from './deploy-path.mjs';
@@ -101,6 +109,7 @@ export {
101
109
  export {
102
110
  detectPreCommitArk,
103
111
  detectCiEnforcement,
112
+ classifyArkCheckFlags,
104
113
  detectConfigGateDrift,
105
114
  jobIdsThatRunArkCheck,
106
115
  isArkRequiredStatusCheck,