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.
- package/CHANGELOG.md +82 -1
- package/README.md +29 -9
- package/bin/ark-check.mjs +69 -54
- package/bin/ark-mcp.mjs +267 -26
- package/bin/ark.mjs +50 -3
- package/bin/lib/adapter-contract.mjs +27 -1
- package/bin/lib/agent-gates.mjs +9 -0
- package/bin/lib/analysis-engine.mjs +7 -1169
- package/bin/lib/ci-and-commands.mjs +4 -0
- package/bin/lib/codex-home.mjs +10 -1
- package/bin/lib/doctor-plan.mjs +37 -9
- package/bin/lib/host-support-matrix.mjs +6 -2
- package/bin/lib/install-migrate.mjs +81 -25
- package/bin/lib/mcp-adoption.mjs +8 -0
- package/bin/lib/policy-delta-io.mjs +161 -0
- package/bin/lib/prepare-change.mjs +186 -0
- package/bin/lib/remediation.mjs +24 -0
- package/bin/lib/skill-install.mjs +302 -22
- package/bin/lib/violations.mjs +2 -2
- package/bin/lib/weakest-link.mjs +61 -12
- package/bin/lib/write-path-capabilities.mjs +70 -2
- package/bin/lib/write-path-detect.mjs +18 -11
- package/dist/eslint/index.cjs +3 -977
- package/dist/eslint/index.js +3 -931
- package/dist/index.cjs +6 -1960
- package/dist/index.d.cts +152 -5
- package/dist/index.d.ts +152 -5
- package/dist/index.js +6 -1908
- package/docs/agent-guide.md +16 -2
- package/docs/ai-gates.md +35 -3
- package/docs/configuration.md +44 -0
- package/docs/package-surface.md +8 -1
- package/docs/threat-model.md +7 -4
- package/package.json +6 -5
- package/schemas/ark.analysis-result.schema.json +5 -1
- package/schemas/ark.change-map.schema.json +77 -0
- package/server.json +2 -2
- package/templates/skills/ark-upgrade.md +9 -5
- package/docs/ark-check-example.json +0 -87
- package/docs/demos/03-copilot-autopilot.md +0 -93
- package/docs/migrate-from-ark-runtime-kernel.md +0 -174
- 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
|
+
}
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
148
|
-
// frontmatter (name/description) is understood or harmlessly ignored
|
|
149
|
-
// host. Kiro has no command mechanism — its steering rule file is the
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
if (
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/lib/violations.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|