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
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadArchitectureChangeMap, loadContract, preflightChange } from './analysis-engine.mjs';
4
+ import { createAdapterResult } from './adapter-contract.mjs';
5
+ import { collectGovernedFiles, isGovernableSourceFile, normalize } from './scan-files.mjs';
6
+ import { isScanExcludedRelative, layerForRelativePath } from '../ark-shared.mjs';
7
+
8
+ function candidatePath(value) {
9
+ if (typeof value !== 'string' || value.trim() === '') {
10
+ throw new Error('Every change requires a non-empty project-relative path.');
11
+ }
12
+ const portable = value.trim().replace(/\\/g, '/').replace(/^\.\//, '');
13
+ if (path.posix.isAbsolute(portable) || /^[A-Za-z]:\//.test(portable)) {
14
+ throw new Error(`Change path must be project-relative: ${value}`);
15
+ }
16
+ const normalized = path.posix.normalize(portable);
17
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.includes('\0')) {
18
+ throw new Error(`Change path escapes the project root: ${value}`);
19
+ }
20
+ return normalized;
21
+ }
22
+
23
+ function isIncluded(relativePath, include) {
24
+ return (include ?? []).some((entry) => {
25
+ const root = String(entry).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
26
+ return root === '.' || relativePath === root || relativePath.startsWith(`${root}/`);
27
+ });
28
+ }
29
+
30
+ function assertGovernedSource(config, relativePath) {
31
+ if (!isGovernableSourceFile(path.basename(relativePath))) {
32
+ throw new Error(`Atomic preflight only accepts governed production source files: ${relativePath}`);
33
+ }
34
+ if (!isIncluded(relativePath, config.include) || isScanExcludedRelative(relativePath, config)) {
35
+ throw new Error(`Change path is outside the configured source scope: ${relativePath}`);
36
+ }
37
+ if (!layerForRelativePath(relativePath, config.layers)) {
38
+ throw new Error(`Change path is not assigned to an architecture layer: ${relativePath}`);
39
+ }
40
+ }
41
+
42
+ function assertInsideProject(root, relativePath) {
43
+ const canonicalRoot = fs.realpathSync(root);
44
+ let existing = path.join(root, relativePath);
45
+ while (!fs.existsSync(existing)) {
46
+ const parent = path.dirname(existing);
47
+ if (parent === existing) break;
48
+ existing = parent;
49
+ }
50
+ const canonicalExisting = fs.realpathSync(existing);
51
+ const relative = path.relative(canonicalRoot, canonicalExisting);
52
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
53
+ throw new Error(`Change path resolves outside the project root: ${relativePath}`);
54
+ }
55
+ }
56
+
57
+ export function normalizeChangeSet(input) {
58
+ if (!Array.isArray(input)) throw new Error('changes must be an array.');
59
+ return input.map((change, index) => {
60
+ if (!change || typeof change !== 'object' || Array.isArray(change)) {
61
+ throw new Error(`changes[${index}] must be an object.`);
62
+ }
63
+ const normalizedPath = candidatePath(change.path);
64
+ if (change.delete === true && change.content === undefined) {
65
+ return { path: normalizedPath, delete: true };
66
+ }
67
+ if (typeof change.content === 'string' && change.delete === undefined) {
68
+ return { path: normalizedPath, content: change.content };
69
+ }
70
+ throw new Error(
71
+ `changes[${index}] must contain either content (string) or delete: true, but not both.`
72
+ );
73
+ });
74
+ }
75
+
76
+ function baseFilesForChange(root, config, changes) {
77
+ const byPath = new Map(
78
+ collectGovernedFiles(root, config).map((absolute) => [
79
+ normalize(path.relative(root, absolute)),
80
+ { path: normalize(path.relative(root, absolute)), content: fs.readFileSync(absolute, 'utf8') },
81
+ ])
82
+ );
83
+ for (const change of changes) {
84
+ if (byPath.has(change.path) || !isGovernableSourceFile(path.basename(change.path))) continue;
85
+ const absolute = path.join(root, change.path);
86
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
87
+ byPath.set(change.path, { path: change.path, content: fs.readFileSync(absolute, 'utf8') });
88
+ }
89
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
90
+ }
91
+
92
+ export function prepareChangeFromRoot({
93
+ root,
94
+ config,
95
+ configSource,
96
+ changes,
97
+ changeMap,
98
+ changeMapSource,
99
+ compilerOptions,
100
+ }) {
101
+ const normalizedChanges = normalizeChangeSet(changes);
102
+ for (const change of normalizedChanges) {
103
+ assertInsideProject(root, change.path);
104
+ assertGovernedSource(config, change.path);
105
+ }
106
+ const contract = loadContract(config, configSource ?? path.join(root, 'ark.config.json'));
107
+ const loadedChangeMap =
108
+ changeMap === undefined
109
+ ? undefined
110
+ : loadArchitectureChangeMap(changeMap, contract.config, changeMapSource);
111
+ const result = preflightChange({
112
+ contract,
113
+ files: baseFilesForChange(root, config, normalizedChanges),
114
+ changes: normalizedChanges,
115
+ ...(loadedChangeMap ? { changeMap: loadedChangeMap } : {}),
116
+ ...(compilerOptions ? { compilerOptions } : {}),
117
+ });
118
+ return {
119
+ ...createAdapterResult({
120
+ valid: result.valid,
121
+ violations: result.violations,
122
+ warnings: result.warnings,
123
+ }),
124
+ ...result,
125
+ };
126
+ }
127
+
128
+ export function renderChangePreflight(result) {
129
+ const convergence = result.convergence;
130
+ if (result.valid) {
131
+ console.log(`✔ Atomic preflight passed for ${result.changes.length} change(s).`);
132
+ console.log(` candidate ${result.candidateTreeHash} · policy ${result.policyHash}`);
133
+ } else {
134
+ const structuralFindings = convergence
135
+ ? convergence.summary.missing + convergence.summary.contradictory + convergence.summary.unplanned
136
+ : 0;
137
+ console.error(
138
+ `Atomic preflight rejected ${result.violations.length + structuralFindings} finding(s):`
139
+ );
140
+ for (const finding of result.diagnostics.filter(({ severity }) => severity === 'error')) {
141
+ console.error(
142
+ ` - ${finding.ruleId} ${finding.location.file}:${finding.location.line} — ${finding.message}`
143
+ );
144
+ console.error(` Next action: ${finding.nextAction}`);
145
+ }
146
+ for (const finding of convergence?.findings ?? []) {
147
+ if (finding.classification !== 'satisfied') {
148
+ console.error(` - ${finding.id} — ${finding.message}`);
149
+ console.error(` Next action: ${finding.nextAction}`);
150
+ }
151
+ }
152
+ console.error('No project file was written. Fix the complete change set and preflight again.');
153
+ }
154
+ if (convergence) {
155
+ const { satisfied, missing, contradictory, unplanned } = convergence.summary;
156
+ const write = convergence.structurallyConverged ? console.log : console.error;
157
+ write(
158
+ `Structural convergence: ${convergence.structurallyConverged ? 'passed' : 'failed'} · satisfied ${satisfied} · missing ${missing} · contradictory ${contradictory} · unplanned ${unplanned}.`
159
+ );
160
+ write('Behavioral completion: not evaluated; run the feature acceptance tests separately.');
161
+ }
162
+ }
163
+
164
+ export function readChangeSetFile(root, requestPath) {
165
+ const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(fs.readFileSync(absolute, 'utf8'));
169
+ } catch (error) {
170
+ throw new Error(
171
+ `Cannot read atomic change set ${absolute}: ${error instanceof Error ? error.message : String(error)}`
172
+ );
173
+ }
174
+ return Array.isArray(parsed) ? parsed : parsed?.changes;
175
+ }
176
+
177
+ export function readChangeMapFile(root, requestPath) {
178
+ const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
179
+ try {
180
+ return { source: absolute, input: JSON.parse(fs.readFileSync(absolute, 'utf8')) };
181
+ } catch (error) {
182
+ throw new Error(
183
+ `Cannot read architecture change map ${absolute}: ${error instanceof Error ? error.message : String(error)}`
184
+ );
185
+ }
186
+ }
@@ -41,6 +41,29 @@ export const KNOWN_FIX_CLASSES = [
41
41
  'break-cycle',
42
42
  'review-contract',
43
43
  ];
44
+ /** One deterministic re-entry action shared by human and machine denial surfaces. */
45
+ export function deterministicNextAction(violation) {
46
+ switch (violation.ruleId) {
47
+ case 'LAYER_IMPORT_VIOLATION':
48
+ if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
49
+ return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
50
+ }
51
+ if (violation.peerIsolation) {
52
+ return 'Extract the shared dependency to a shared layer, then preflight again.';
53
+ }
54
+ return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
55
+ case 'FORBIDDEN_GLOBAL':
56
+ return `Inject ${violation.target ?? 'the capability'} through a port, then preflight again.`;
57
+ case 'CIRCULAR_DEPENDENCY':
58
+ return 'Extract the shared dependency into a third module, then preflight again.';
59
+ case 'RAW_EVENT_PUBLISH':
60
+ return 'Publish through a registered intent creator, then run Ark again.';
61
+ case 'PUBLISH_MISSING_SOURCE':
62
+ return 'Add metadata.source to the publish call, then run Ark again.';
63
+ default:
64
+ return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
65
+ }
66
+ }
44
67
  /**
45
68
  * Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
46
69
  * Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
@@ -214,5 +237,6 @@ export function enrichViolationWithFixClass(violation) {
214
237
  enriched.enthusiastHint =
215
238
  'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
216
239
  }
240
+ enriched.nextAction = deterministicNextAction(violation);
217
241
  return enriched;
218
242
  }
@@ -3,7 +3,8 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { codexPromptsDir } from './codex-home.mjs';
6
+ import { arkCommand } from '../ark-shared.mjs';
7
+ import { codexPromptsDir, codexSkillsDir } from './codex-home.mjs';
7
8
  import { __packageRoot, isCompactRouterAgentsContent, readJson } from './gate-files.mjs';
8
9
 
9
10
  export function normalizeToolsList(tools) {
@@ -144,13 +145,19 @@ export const KNOWN_TOOLS = [
144
145
  ];
145
146
 
146
147
  // One canonical markdown per skill (templates/skills/*.md, shipped in the npm
147
- // package); installed into each tool's slash-command location. The YAML
148
- // frontmatter (name/description) is understood or harmlessly ignored by every
149
- // host. Kiro has no command mechanism — its steering rule file is the only gate.
148
+ // package); installed into each tool's slash-command / skill-catalog location.
149
+ // The YAML frontmatter (name/description) is understood or harmlessly ignored
150
+ // by every host. Kiro has no command mechanism — its steering rule file is the
151
+ // only gate.
152
+ //
153
+ // Codex: discovers Agent Skills directories with SKILL.md — repo path is the
154
+ // official `.agents/skills/<name>/SKILL.md` (not dead `.codex/prompts/*.md`).
155
+ // Home install uses `$CODEX_HOME/skills/<name>/SKILL.md` via --codex-home.
150
156
  export const SKILL_TOOL_TARGETS = {
151
157
  claude: (name) => `.claude/skills/${name}/SKILL.md`,
152
158
  cursor: (name) => `.cursor/commands/${name}.md`,
153
- codex: (name) => `.codex/prompts/${name}.md`,
159
+ // Official Codex REPO skill scope (Agent Skills standard).
160
+ codex: (name) => `.agents/skills/${name}/SKILL.md`,
154
161
  // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
155
162
  grok: (name) => `.grok/skills/${name}/SKILL.md`,
156
163
  windsurf: (name) => `.windsurf/workflows/${name}.md`,
@@ -254,6 +261,122 @@ export function skillTemplateNames() {
254
261
  .map((entry) => path.basename(entry.name, '.md'));
255
262
  }
256
263
 
264
+ /**
265
+ * Count present / stale / legacy-only skill files for one catalog root.
266
+ * @param {string[]} skillNames
267
+ * @param {(name: string) => string} skillFile path builder
268
+ * @param {string|null} packageVersion
269
+ * @param {{ legacyFile?: (name: string) => string }} [opts]
270
+ */
271
+ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion, opts = {}) {
272
+ const expectedCount = skillNames.length;
273
+ const present = [];
274
+ let stale = 0;
275
+ for (const name of skillNames) {
276
+ const file = skillFile(name);
277
+ if (!fs.existsSync(file)) continue;
278
+ present.push(name);
279
+ if (packageVersion) {
280
+ const installed = installedSkillVersion(file);
281
+ if (installed === null || isVersionOlder(installed, packageVersion)) stale += 1;
282
+ }
283
+ }
284
+ let legacyCount = 0;
285
+ if (typeof opts.legacyFile === 'function') {
286
+ for (const name of skillNames) {
287
+ if (fs.existsSync(opts.legacyFile(name))) legacyCount += 1;
288
+ }
289
+ }
290
+ const presentCount = present.length;
291
+ const missing = expectedCount - presentCount;
292
+ const legacyPromptsOnly = presentCount === 0 && legacyCount > 0;
293
+ const hasLegacyPrompts = legacyCount > 0;
294
+ const ok = missing === 0 && stale === 0 && !legacyPromptsOnly;
295
+ return {
296
+ ok,
297
+ missing,
298
+ stale,
299
+ presentCount,
300
+ expectedCount,
301
+ packageVersion: packageVersion ?? null,
302
+ legacyPromptsOnly,
303
+ hasLegacyPrompts,
304
+ legacyCount,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Repo + home Codex skill parity against the shipping package skill set.
310
+ * Producer trees (templates/skills) and projects without AGENTS.md return null.
311
+ *
312
+ * @param {string} root
313
+ * @returns {null | {
314
+ * packageVersion: string|null,
315
+ * expectedCount: number,
316
+ * repo: object,
317
+ * home: object,
318
+ * skillsDir: string,
319
+ * promptsDir: string,
320
+ * needsAttention: boolean,
321
+ * homeNeedsAttention: boolean,
322
+ * repoNeedsAttention: boolean,
323
+ * }}
324
+ */
325
+ export function assessCodexSkillParity(root) {
326
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
327
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
328
+ const skillNames = skillTemplateNames();
329
+ if (skillNames.length === 0) return null;
330
+
331
+ const packageVersion = arkPackageVersion();
332
+ const skillsDir = codexSkillsDir();
333
+ const promptsDir = codexPromptsDir();
334
+ const repoSkill = (name) => path.join(root, SKILL_TOOL_TARGETS.codex(name));
335
+ const repoLegacy = (name) => path.join(root, '.codex', 'prompts', `${name}.md`);
336
+ const homeSkill = (name) => path.join(skillsDir, name, 'SKILL.md');
337
+ const homeLegacy = (name) => path.join(promptsDir, `${name}.md`);
338
+
339
+ const repo = assessSkillCatalogParity(skillNames, repoSkill, packageVersion, {
340
+ legacyFile: repoLegacy,
341
+ });
342
+ const home = assessSkillCatalogParity(skillNames, homeSkill, packageVersion, {
343
+ legacyFile: homeLegacy,
344
+ });
345
+
346
+ // Repo catalog matters when .codex is present (Codex host adopted) or repo skills/prompts exist.
347
+ const repoInPlay =
348
+ fs.existsSync(path.join(root, '.codex')) ||
349
+ repo.presentCount > 0 ||
350
+ repo.hasLegacyPrompts;
351
+ // Home is "in play" only when ark skills or legacy prompts were actually installed there
352
+ // (empty $CODEX_HOME/skills is optional multi-project — not debt).
353
+ const homeInPlay = home.presentCount > 0 || home.hasLegacyPrompts;
354
+
355
+ if (!repoInPlay && !homeInPlay) return null;
356
+
357
+ const repoNeedsAttention =
358
+ repoInPlay && (repo.missing > 0 || repo.stale > 0 || repo.legacyPromptsOnly);
359
+ const homeNeedsAttention =
360
+ homeInPlay && (home.missing > 0 || home.stale > 0 || home.legacyPromptsOnly);
361
+
362
+ return {
363
+ packageVersion,
364
+ expectedCount: skillNames.length,
365
+ repo: { ...repo, inPlay: repoInPlay },
366
+ home: {
367
+ ...home,
368
+ inPlay: homeInPlay,
369
+ skillsDir,
370
+ promptsDir,
371
+ },
372
+ skillsDir,
373
+ promptsDir,
374
+ repoNeedsAttention,
375
+ homeNeedsAttention,
376
+ needsAttention: repoNeedsAttention || homeNeedsAttention,
377
+ };
378
+ }
379
+
257
380
  // A normal ark-check run is the reliable discovery point for new /ark-* skills.
258
381
  // Ark ships no install lifecycle script (a postinstall banner would be blocked by
259
382
  // modern package managers' script-approval policy anyway, so careful users never
@@ -263,24 +386,94 @@ export function skillTemplateNames() {
263
386
  // Advisory only — never affects the exit code. Copilot has no reliable directory
264
387
  // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
265
388
  export function detectCodexHomeGap(root) {
266
- if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
267
- if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
268
- const skillNames = skillTemplateNames();
269
- if (skillNames.length === 0) return null;
270
- const dir = codexPromptsDir();
271
- if (!fs.existsSync(dir)) return null;
272
- const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
273
- if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
274
- const version = arkPackageVersion();
275
- const missing = skillNames.length - present.length;
276
- let stale = 0;
277
- if (version) {
278
- for (const name of present) {
279
- const installed = installedSkillVersion(path.join(dir, `${name}.md`));
280
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
389
+ const parity = assessCodexSkillParity(root);
390
+ if (!parity || !parity.homeNeedsAttention) return null;
391
+ const { home, packageVersion, expectedCount, skillsDir } = parity;
392
+ return {
393
+ missing: home.missing,
394
+ stale: home.stale,
395
+ legacyPromptsOnly: Boolean(home.legacyPromptsOnly),
396
+ hasLegacyPrompts: Boolean(home.hasLegacyPrompts),
397
+ presentCount: home.presentCount,
398
+ expectedCount,
399
+ packageVersion,
400
+ skillsDir,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Repo-side Codex gaps: missing/stale .agents/skills or legacy .codex/prompts only.
406
+ * @param {string} root
407
+ * @returns {null | { missing: number, stale: number, legacyPromptsOnly: boolean, hasLegacyPrompts: boolean, presentCount: number, expectedCount: number, packageVersion: string|null }}
408
+ */
409
+ export function detectCodexRepoSkillGap(root) {
410
+ const parity = assessCodexSkillParity(root);
411
+ if (!parity || !parity.repoNeedsAttention) return null;
412
+ const { repo, packageVersion, expectedCount } = parity;
413
+ return {
414
+ missing: repo.missing,
415
+ stale: repo.stale,
416
+ legacyPromptsOnly: Boolean(repo.legacyPromptsOnly),
417
+ hasLegacyPrompts: Boolean(repo.hasLegacyPrompts),
418
+ presentCount: repo.presentCount,
419
+ expectedCount,
420
+ packageVersion,
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Skill names referenced as `/ark-*` in AGENTS.md (or any instruction text).
426
+ * @param {string} text
427
+ * @returns {string[]}
428
+ */
429
+ export function agentsMdSkillRefs(text) {
430
+ if (!text || typeof text !== 'string') return [];
431
+ const refs = new Set();
432
+ const re = /\/(ark-[a-z0-9-]+)/g;
433
+ let match;
434
+ while ((match = re.exec(text)) !== null) refs.add(match[1]);
435
+ return [...refs].sort();
436
+ }
437
+
438
+ /**
439
+ * Verify that every `/ark-*` skill referenced by AGENTS.md (and known to this
440
+ * package) is present in each selected host's skill catalog path.
441
+ *
442
+ * Compact routers intentionally omit `/ark-*` — they verify as ok with no checks.
443
+ *
444
+ * @param {string} root
445
+ * @param {Iterable<string>} tools
446
+ * @param {{ skillNames?: string[], agentsText?: string }} [options]
447
+ * @returns {{ ok: boolean, missing: Array<{ tool: string, name: string, path: string }>, referenced: string[], checkedTools: string[], compact?: boolean }}
448
+ */
449
+ export function verifyHostSkillCatalog(root, tools, options = {}) {
450
+ const skillNames = new Set(options.skillNames ?? skillTemplateNames());
451
+ let agentsText = options.agentsText;
452
+ if (agentsText == null) {
453
+ try {
454
+ agentsText = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
455
+ } catch {
456
+ return { ok: true, missing: [], referenced: [], checkedTools: [] };
457
+ }
458
+ }
459
+ if (isCompactRouterAgentsContent(agentsText)) {
460
+ return { ok: true, missing: [], referenced: [], checkedTools: [], compact: true };
461
+ }
462
+ const referenced = agentsMdSkillRefs(agentsText).filter((name) => skillNames.has(name));
463
+ const missing = [];
464
+ const checkedTools = [];
465
+ for (const tool of tools) {
466
+ const target = SKILL_TOOL_TARGETS[tool];
467
+ if (!target) continue;
468
+ checkedTools.push(tool);
469
+ for (const name of referenced) {
470
+ const relativePath = target(name);
471
+ if (!fs.existsSync(path.join(root, relativePath))) {
472
+ missing.push({ tool, name, path: relativePath });
473
+ }
281
474
  }
282
475
  }
283
- return missing > 0 || stale > 0 ? { missing, stale } : null;
476
+ return { ok: missing.length === 0, missing, referenced, checkedTools };
284
477
  }
285
478
 
286
479
  export function detectSkillGaps(root) {
@@ -325,7 +518,94 @@ export function detectSkillGaps(root) {
325
518
  if (installed === null || isVersionOlder(installed, version)) stale += 1;
326
519
  }
327
520
  }
328
- if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
521
+ let legacyPromptsOnly = false;
522
+ let hasLegacyPrompts = false;
523
+ if (tool === 'codex') {
524
+ const legacyCount = skillNames.filter((name) =>
525
+ fs.existsSync(path.join(root, '.codex', 'prompts', `${name}.md`))
526
+ ).length;
527
+ hasLegacyPrompts = legacyCount > 0;
528
+ // Flat prompts without any SKILL.md catalog entries are not loadable.
529
+ legacyPromptsOnly = hasLegacyPrompts && missing === skillNames.length;
530
+ }
531
+ if (missing > 0 || stale > 0 || legacyPromptsOnly) {
532
+ gaps.push({
533
+ tool,
534
+ missing,
535
+ stale,
536
+ ...(legacyPromptsOnly ? { legacyPromptsOnly: true } : {}),
537
+ ...(hasLegacyPrompts ? { hasLegacyPrompts: true } : {}),
538
+ });
539
+ }
329
540
  }
330
541
  return gaps;
331
542
  }
543
+
544
+ /**
545
+ * Human-facing skill / Codex catalog gap lines for ark-check (non-JSON).
546
+ * @param {string} root
547
+ * @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, color: { dim: Function, yellow: Function } }} opts
548
+ */
549
+ export function printSkillAndCodexGapHints(root, opts) {
550
+ const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
551
+ if (skillGaps?.length > 0) {
552
+ const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
553
+ // Report Codex legacy separately; never suppress missing/stale for other hosts.
554
+ const remaining = skillGaps.filter((gap) => !(gap.tool === 'codex' && gap.legacyPromptsOnly));
555
+ const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
556
+ const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
557
+ const tools = remaining.map((gap) => gap.tool).join(', ');
558
+ if (legacyCodex) {
559
+ console.log(
560
+ color.yellow(
561
+ 'Codex has legacy flat .codex/prompts/ark-*.md only — those are not loadable as skills. ' +
562
+ `Install the real catalog: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --tools codex --force')}`
563
+ )
564
+ );
565
+ }
566
+ if (missingTotal > 0) {
567
+ console.log(
568
+ color.dim(
569
+ `${missingTotal} /ark-* skill(s) not installed for ${tools} (this Ark version ships them). ` +
570
+ `Install: ${arkCommand(root, 'ark-check', '--install-agent-gates')}`
571
+ )
572
+ );
573
+ }
574
+ if (staleTotal > 0) {
575
+ console.log(
576
+ color.dim(
577
+ `${staleTotal} /ark-* skill(s) outdated for ${tools} (this Ark ships newer versions). ` +
578
+ `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
579
+ )
580
+ );
581
+ }
582
+ }
583
+ if (codexHomeGap) {
584
+ const parts = [];
585
+ if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
586
+ if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
587
+ if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
588
+ const deferred = !codexSessionActive;
589
+ const deferredNote = deferred
590
+ ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
591
+ : ' ';
592
+ const msg =
593
+ `Codex home skill catalog (${codexSkillsDir()}) behind this Ark (${parts.join(', ')}).` +
594
+ deferredNote +
595
+ `Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
596
+ `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`;
597
+ console.log(deferred ? color.dim(msg) : color.yellow(msg));
598
+ }
599
+ if (codexRepoSkillGap && codexSessionActive) {
600
+ const parts = [];
601
+ if (codexRepoSkillGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
602
+ if (codexRepoSkillGap.missing > 0) parts.push(`${codexRepoSkillGap.missing} missing`);
603
+ if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} outdated`);
604
+ console.log(
605
+ color.yellow(
606
+ `Codex repo skill catalog (.agents/skills) needs refresh (${parts.join(', ')}). ` +
607
+ `Fix: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --tools codex --force')}`
608
+ )
609
+ );
610
+ }
611
+ }
@@ -12,6 +12,7 @@ const color = {
12
12
 
13
13
  /** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
14
14
  import { baselineKey, baselineOccurrenceKeys } from './baseline-key.mjs';
15
+ import { toAdapterDiagnostic } from './adapter-contract.mjs';
15
16
  export { baselineKey, baselineOccurrenceKeys };
16
17
 
17
18
  export function readBaseline(root, baselinePath) {
@@ -56,8 +57,7 @@ export function printViolation(violation) {
56
57
  console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
57
58
  }
58
59
  console.error(` ${violation.message}`);
59
- const hint = FIX_HINTS[violation.ruleId];
60
- if (hint) console.error(` ${color.dim(`fix: ${hint}`)}`);
60
+ console.error(` ${color.dim(`Next action: ${toAdapterDiagnostic(violation).nextAction}`)}`);
61
61
  console.error('');
62
62
  }
63
63