@dzhechkov/harness-core 0.2.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/README.md +24 -0
- package/dist/apply.d.ts +31 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/apply.js +36 -0
- package/dist/apply.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/operations.d.ts +126 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +247 -0
- package/dist/operations.js.map +1 -0
- package/dist/skills.d.ts +36 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +87 -0
- package/dist/skills.js.map +1 -0
- package/dist/targets.d.ts +23 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +26 -0
- package/dist/targets.js.map +1 -0
- package/dist/workflows.d.ts +28 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +103 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +58 -0
- package/src/apply.ts +57 -0
- package/src/index.ts +14 -0
- package/src/operations.ts +388 -0
- package/src/security/boundaries.json +55 -0
- package/src/skills.ts +109 -0
- package/src/targets.ts +33 -0
- package/src/workflows.ts +123 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness operations — `init`, `sync`, `verify`, `doctor` — as pure-ish
|
|
3
|
+
* functions returning structured reports. `@dzhechkov/harness-cli` is a thin
|
|
4
|
+
* argv shell over these.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { claudeAdapter } from '@dzhechkov/adapter-claude';
|
|
13
|
+
|
|
14
|
+
import { applyEmitResult } from './apply.js';
|
|
15
|
+
import { discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
16
|
+
import { TARGETS } from './targets.js';
|
|
17
|
+
import type { TargetName } from './targets.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// init — compile a skills directory for a target and write it (additively)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Options for {@link runInit}. */
|
|
24
|
+
export interface InitOptions {
|
|
25
|
+
readonly target: TargetName;
|
|
26
|
+
/** Source directory of canonical skills. */
|
|
27
|
+
readonly skillsDir: string;
|
|
28
|
+
/** Project root the platform tree is written under. */
|
|
29
|
+
readonly projectRoot: string;
|
|
30
|
+
/** Overwrite existing files. Default `false` — additive. */
|
|
31
|
+
readonly force?: boolean;
|
|
32
|
+
/** When set, install only these skill ids (a preset selection). */
|
|
33
|
+
readonly select?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-skill outcome of {@link runInit}. */
|
|
37
|
+
export interface InitSkillResult {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly written: string[];
|
|
40
|
+
readonly skipped: string[];
|
|
41
|
+
readonly warnings: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The outcome of {@link runInit}. */
|
|
45
|
+
export interface InitReport {
|
|
46
|
+
readonly target: TargetName;
|
|
47
|
+
readonly skillsDir: string;
|
|
48
|
+
readonly projectRoot: string;
|
|
49
|
+
readonly skills: InitSkillResult[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Compile every skill in `skillsDir` for `target` and apply it under `projectRoot`. */
|
|
53
|
+
export async function runInit(options: InitOptions): Promise<InitReport> {
|
|
54
|
+
const adapter = TARGETS[options.target];
|
|
55
|
+
const skills: InitSkillResult[] = [];
|
|
56
|
+
const selection = options.select;
|
|
57
|
+
const ids = discoverSkillIds(options.skillsDir).filter(
|
|
58
|
+
(id) => selection === undefined || selection.includes(id),
|
|
59
|
+
);
|
|
60
|
+
for (const id of ids) {
|
|
61
|
+
const skill = loadSkillFromDir(options.skillsDir, id);
|
|
62
|
+
const emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
|
|
63
|
+
const applied = applyEmitResult(emit, {
|
|
64
|
+
targetRoot: options.projectRoot,
|
|
65
|
+
force: options.force === true,
|
|
66
|
+
});
|
|
67
|
+
skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
|
|
68
|
+
}
|
|
69
|
+
return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// verify — compile + structurally verify every skill for a target
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Options for {@link runVerify}. */
|
|
77
|
+
export interface VerifyOptions {
|
|
78
|
+
readonly skillsDir: string;
|
|
79
|
+
/** Target to verify against. Default `claude-code`. */
|
|
80
|
+
readonly target?: TargetName;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Per-skill outcome of {@link runVerify}. */
|
|
84
|
+
export interface VerifySkillResult {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly ok: boolean;
|
|
87
|
+
readonly errors: string[];
|
|
88
|
+
readonly warnings: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The outcome of {@link runVerify}. */
|
|
92
|
+
export interface VerifyReport {
|
|
93
|
+
readonly target: TargetName;
|
|
94
|
+
readonly skillsDir: string;
|
|
95
|
+
readonly total: number;
|
|
96
|
+
readonly valid: number;
|
|
97
|
+
readonly skills: VerifySkillResult[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Compile every skill for `target` and report whether each verifies. */
|
|
101
|
+
export async function runVerify(options: VerifyOptions): Promise<VerifyReport> {
|
|
102
|
+
const target: TargetName = options.target ?? 'claude-code';
|
|
103
|
+
const adapter = TARGETS[target];
|
|
104
|
+
const skills: VerifySkillResult[] = [];
|
|
105
|
+
for (const id of discoverSkillIds(options.skillsDir)) {
|
|
106
|
+
try {
|
|
107
|
+
const skill = loadSkillFromDir(options.skillsDir, id);
|
|
108
|
+
const emit = await adapter.compile(skill, { targetRoot: '.' });
|
|
109
|
+
const result = await adapter.verify(emit);
|
|
110
|
+
skills.push({ id, ok: result.ok, errors: [...result.errors], warnings: [...result.warnings] });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
skills.push({
|
|
113
|
+
id,
|
|
114
|
+
ok: false,
|
|
115
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
116
|
+
warnings: [],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
target,
|
|
122
|
+
skillsDir: options.skillsDir,
|
|
123
|
+
total: skills.length,
|
|
124
|
+
valid: skills.filter((skill) => skill.ok).length,
|
|
125
|
+
skills,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// sync — canonical pack -> legacy .claude/skills tree (ADR-002)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** Options for {@link runSync}. */
|
|
134
|
+
export interface SyncOptions {
|
|
135
|
+
/** Canonical skill pack directory (single source). */
|
|
136
|
+
readonly canonicalDir?: string;
|
|
137
|
+
/** Multiple canonical pack directories (multi-source). Takes precedence over canonicalDir. */
|
|
138
|
+
readonly canonicalDirs?: readonly string[];
|
|
139
|
+
/** Project root containing the legacy `.claude/skills` tree. */
|
|
140
|
+
readonly projectRoot: string;
|
|
141
|
+
/** Report only, write nothing. */
|
|
142
|
+
readonly dryRun?: boolean;
|
|
143
|
+
/** Overwrite drifted legacy files. Default `false`. */
|
|
144
|
+
readonly force?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Per-skill outcome of {@link runSync}. */
|
|
148
|
+
export interface SyncSkillResult {
|
|
149
|
+
readonly id: string;
|
|
150
|
+
readonly status: 'in-sync' | 'missing' | 'drift';
|
|
151
|
+
readonly written: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The outcome of {@link runSync}. */
|
|
155
|
+
export interface SyncReport {
|
|
156
|
+
readonly dryRun: boolean;
|
|
157
|
+
readonly skills: SyncSkillResult[];
|
|
158
|
+
readonly summary: { total: number; inSync: number; missing: number; drift: number };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Compare each canonical skill (compiled for Claude Code) to the legacy tree. */
|
|
162
|
+
export async function runSync(options: SyncOptions): Promise<SyncReport> {
|
|
163
|
+
const dirs = options.canonicalDirs ?? (options.canonicalDir ? [options.canonicalDir] : []);
|
|
164
|
+
const skills: SyncSkillResult[] = [];
|
|
165
|
+
for (const canonicalDir of dirs) {
|
|
166
|
+
for (const id of discoverSkillIds(canonicalDir)) {
|
|
167
|
+
const skill = loadSkillFromDir(canonicalDir, id);
|
|
168
|
+
const emit = await claudeAdapter.compile(skill, { targetRoot: options.projectRoot });
|
|
169
|
+
|
|
170
|
+
let differ = false;
|
|
171
|
+
let missing = false;
|
|
172
|
+
for (const file of emit.files) {
|
|
173
|
+
const absolutePath = join(options.projectRoot, file.path);
|
|
174
|
+
if (!existsSync(absolutePath)) missing = true;
|
|
175
|
+
else if (readFileSync(absolutePath, 'utf-8') !== file.content) differ = true;
|
|
176
|
+
}
|
|
177
|
+
const status: SyncSkillResult['status'] = differ ? 'drift' : missing ? 'missing' : 'in-sync';
|
|
178
|
+
|
|
179
|
+
let written: string[] = [];
|
|
180
|
+
const shouldWrite = status === 'missing' || (status === 'drift' && options.force === true);
|
|
181
|
+
if (options.dryRun !== true && shouldWrite) {
|
|
182
|
+
written = applyEmitResult(emit, {
|
|
183
|
+
targetRoot: options.projectRoot,
|
|
184
|
+
force: status === 'drift',
|
|
185
|
+
}).written;
|
|
186
|
+
}
|
|
187
|
+
skills.push({ id, status, written });
|
|
188
|
+
}}
|
|
189
|
+
return {
|
|
190
|
+
dryRun: options.dryRun === true,
|
|
191
|
+
skills,
|
|
192
|
+
summary: {
|
|
193
|
+
total: skills.length,
|
|
194
|
+
inSync: skills.filter((skill) => skill.status === 'in-sync').length,
|
|
195
|
+
missing: skills.filter((skill) => skill.status === 'missing').length,
|
|
196
|
+
drift: skills.filter((skill) => skill.status === 'drift').length,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// migrate — detect keysarium/.bto/.keysarium.json installations
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** A detected legacy CLI installation. */
|
|
206
|
+
export interface MigrateDetection {
|
|
207
|
+
readonly manifest: string;
|
|
208
|
+
readonly version: string;
|
|
209
|
+
readonly components: string[];
|
|
210
|
+
readonly fileCount: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The outcome of {@link runMigrate}. */
|
|
214
|
+
export interface MigrateReport {
|
|
215
|
+
readonly projectRoot: string;
|
|
216
|
+
readonly detections: MigrateDetection[];
|
|
217
|
+
readonly skillsFound: number;
|
|
218
|
+
readonly recommendation: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Detect keysarium/bto/etc installations and report migration path. */
|
|
222
|
+
export function runMigrate(options: { projectRoot: string }): MigrateReport {
|
|
223
|
+
const root = options.projectRoot;
|
|
224
|
+
const detections: MigrateDetection[] = [];
|
|
225
|
+
|
|
226
|
+
// Check for known manifests
|
|
227
|
+
const manifests = ['.keysarium.json', '.bto.json', '.analyst-manual.json', '.edu-site.json', '.transcript-site.json', '.feature-adr.json'];
|
|
228
|
+
for (const manifest of manifests) {
|
|
229
|
+
const path = join(root, manifest);
|
|
230
|
+
if (existsSync(path)) {
|
|
231
|
+
try {
|
|
232
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
233
|
+
detections.push({
|
|
234
|
+
manifest,
|
|
235
|
+
version: data.version ?? 'unknown',
|
|
236
|
+
components: data.components ?? [],
|
|
237
|
+
fileCount: Array.isArray(data.files) ? data.files.length : 0,
|
|
238
|
+
});
|
|
239
|
+
} catch {
|
|
240
|
+
detections.push({ manifest, version: 'parse-error', components: [], fileCount: 0 });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Count skills in .claude/skills
|
|
246
|
+
const skillsDir = join(root, '.claude', 'skills');
|
|
247
|
+
let skillsFound = 0;
|
|
248
|
+
if (existsSync(skillsDir)) {
|
|
249
|
+
skillsFound = readdirSync(skillsDir, { withFileTypes: true })
|
|
250
|
+
.filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
|
|
251
|
+
.length;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let recommendation: string;
|
|
255
|
+
if (detections.length === 0) {
|
|
256
|
+
recommendation = 'No legacy installations detected. Use `dz init` to install skills.';
|
|
257
|
+
} else if (detections.length === 1) {
|
|
258
|
+
const d = detections[0]!;
|
|
259
|
+
recommendation = `Found ${d.manifest} (v${d.version}, ${d.fileCount} files). Skills are already in .claude/skills/ — use \`dz sync\` to manage them canonically.`;
|
|
260
|
+
} else {
|
|
261
|
+
recommendation = `Found ${detections.length} legacy manifests. Skills coexist in .claude/skills/. Run \`dz doctor\` to verify health, then \`dz sync\` to adopt canonical management.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { projectRoot: root, detections, skillsFound, recommendation };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// doctor — environment diagnostics
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** A single {@link runDoctor} check. */
|
|
272
|
+
export interface DoctorCheck {
|
|
273
|
+
readonly name: string;
|
|
274
|
+
readonly ok: boolean;
|
|
275
|
+
readonly detail: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The outcome of {@link runDoctor}. */
|
|
279
|
+
export interface DoctorReport {
|
|
280
|
+
readonly node: string;
|
|
281
|
+
readonly checks: DoctorCheck[];
|
|
282
|
+
readonly ok: boolean;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Report environment diagnostics for the harness. */
|
|
286
|
+
export async function runDoctor(options: { projectRoot: string }): Promise<DoctorReport> {
|
|
287
|
+
const checks: DoctorCheck[] = [];
|
|
288
|
+
const root = options.projectRoot;
|
|
289
|
+
|
|
290
|
+
// 1. Node version
|
|
291
|
+
const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
|
|
292
|
+
checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
|
|
293
|
+
|
|
294
|
+
// 2. Key directories
|
|
295
|
+
for (const dir of ['.claude/skills', 'packages/@dzhechkov/skills-meta']) {
|
|
296
|
+
checks.push({
|
|
297
|
+
name: `${dir} present`,
|
|
298
|
+
ok: existsSync(join(root, dir)),
|
|
299
|
+
detail: dir,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 3. Adapter resolvability
|
|
304
|
+
const adapters = ['adapter-claude', 'adapter-codex', 'adapter-hermes', 'adapter-opencode'];
|
|
305
|
+
const foundAdapters = adapters.filter((a) => existsSync(join(root, 'packages/@dzhechkov', a)));
|
|
306
|
+
checks.push({
|
|
307
|
+
name: 'adapters present',
|
|
308
|
+
ok: foundAdapters.length === 4,
|
|
309
|
+
detail: `${foundAdapters.length}/4 adapters found`,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// 4. Package version consistency
|
|
313
|
+
const pkgsDir = join(root, 'packages/@dzhechkov');
|
|
314
|
+
if (existsSync(pkgsDir)) {
|
|
315
|
+
const versions = new Map<string, string>();
|
|
316
|
+
let consistent = true;
|
|
317
|
+
let detail = '';
|
|
318
|
+
try {
|
|
319
|
+
const entries = readdirSync(pkgsDir, { withFileTypes: true });
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (!entry.isDirectory()) continue;
|
|
322
|
+
const pjPath = join(pkgsDir, entry.name, 'package.json');
|
|
323
|
+
if (!existsSync(pjPath)) continue;
|
|
324
|
+
const pj = JSON.parse(readFileSync(pjPath, 'utf-8'));
|
|
325
|
+
if (pj.version) versions.set(entry.name, pj.version as string);
|
|
326
|
+
}
|
|
327
|
+
const uniqueVersions = new Set(versions.values());
|
|
328
|
+
detail = `${versions.size} packages, ${uniqueVersions.size} unique version(s)`;
|
|
329
|
+
// Warn if more than 10 unique versions (some variation is expected in a large monorepo)
|
|
330
|
+
if (uniqueVersions.size > 10) {
|
|
331
|
+
consistent = false;
|
|
332
|
+
detail += ' — high version divergence';
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
consistent = false;
|
|
336
|
+
detail = 'failed to read package versions';
|
|
337
|
+
}
|
|
338
|
+
checks.push({ name: 'package versions', ok: consistent, detail });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 5. Config lint (.agentic-qe/config.yaml)
|
|
342
|
+
const configPath = join(root, '.agentic-qe', 'config.yaml');
|
|
343
|
+
if (existsSync(configPath)) {
|
|
344
|
+
try {
|
|
345
|
+
const { parse: parseYaml } = await import('yaml');
|
|
346
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
347
|
+
const parsed = parseYaml(content);
|
|
348
|
+
const hasProject = typeof parsed?.project === 'string';
|
|
349
|
+
checks.push({
|
|
350
|
+
name: 'aqe config valid',
|
|
351
|
+
ok: hasProject,
|
|
352
|
+
detail: hasProject ? `project: ${parsed.project}` : 'missing project field',
|
|
353
|
+
});
|
|
354
|
+
} catch (error) {
|
|
355
|
+
checks.push({
|
|
356
|
+
name: 'aqe config valid',
|
|
357
|
+
ok: false,
|
|
358
|
+
detail: `parse error: ${error instanceof Error ? error.message : String(error)}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
checks.push({ name: 'aqe config valid', ok: true, detail: 'no .agentic-qe/config.yaml (optional)' });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 6. SQLite memory backend probe
|
|
366
|
+
try {
|
|
367
|
+
const { createRequire } = await import('node:module');
|
|
368
|
+
const req = createRequire(join(root, 'package.json'));
|
|
369
|
+
req.resolve('better-sqlite3');
|
|
370
|
+
checks.push({ name: 'sqlite backend', ok: true, detail: 'better-sqlite3 available' });
|
|
371
|
+
} catch {
|
|
372
|
+
checks.push({ name: 'sqlite backend', ok: true, detail: 'better-sqlite3 not installed (JSON fallback)' });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 7. Skills directory health
|
|
376
|
+
const skillsDir = join(root, '.claude', 'skills');
|
|
377
|
+
if (existsSync(skillsDir)) {
|
|
378
|
+
const skillDirs = readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
379
|
+
const withSkillMd = skillDirs.filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
380
|
+
checks.push({
|
|
381
|
+
name: 'skills health',
|
|
382
|
+
ok: withSkillMd.length === skillDirs.length,
|
|
383
|
+
detail: `${withSkillMd.length}/${skillDirs.length} skill dirs have SKILL.md`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { node: process.version, checks, ok: checks.every((check) => check.ok) };
|
|
388
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"description": "Input boundary catalog for DZ harness — every external input surface and its validator.",
|
|
4
|
+
"version": "0.1.0-RC",
|
|
5
|
+
"boundaries": [
|
|
6
|
+
{
|
|
7
|
+
"id": "cli-args",
|
|
8
|
+
"description": "Raw argv from the `dz` CLI — parsed by the internal parseArgs function that splits positional, --key value, and --flag tokens.",
|
|
9
|
+
"validator": "packages/@dzhechkov/harness-cli/src/cli.ts",
|
|
10
|
+
"scan": "parseArgs"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "mcp-tool-inputs",
|
|
14
|
+
"description": "JSON-RPC tool call parameters received by the MCP server — each tool's inputSchema is a Zod schema that validates before the handler runs.",
|
|
15
|
+
"validator": "packages/@dzhechkov/mcp-server-tools/src/server.ts",
|
|
16
|
+
"scan": "zod-inputSchema"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "skills-dir",
|
|
20
|
+
"description": "Filesystem path to the skills directory — resolved to an absolute path and checked with existsSync before any I/O.",
|
|
21
|
+
"validator": "packages/@dzhechkov/harness-core/src/skills.ts",
|
|
22
|
+
"scan": "existsSync"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "emit-paths",
|
|
26
|
+
"description": "Skill id and asset paths used during emit — assertSafeId rejects empty, dot-dot, and slash-containing ids; normalizeAssetPath rejects traversal.",
|
|
27
|
+
"validator": "packages/@dzhechkov/core/src/skill-emit.ts",
|
|
28
|
+
"scan": "assertSafeId"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "preset-name",
|
|
32
|
+
"description": "The --preset value supplied to `dz init` — validated by isPresetName which checks against the PRESETS const object.",
|
|
33
|
+
"validator": "packages/@dzhechkov/harness-presets/src/presets.ts",
|
|
34
|
+
"scan": "isPresetName"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "target-name",
|
|
38
|
+
"description": "The --target value supplied to `dz init` / `dz verify` — validated by isTargetName which checks against the TARGETS const object.",
|
|
39
|
+
"validator": "packages/@dzhechkov/harness-core/src/targets.ts",
|
|
40
|
+
"scan": "isTargetName"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "memory-record-id",
|
|
44
|
+
"description": "The unique record id for memory backend put/query — typed as string in the MemoryRecord interface; no further runtime validation beyond TypeScript.",
|
|
45
|
+
"validator": "packages/@dzhechkov/memory/src/backend.ts",
|
|
46
|
+
"scan": "string-type"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "config-yaml",
|
|
50
|
+
"description": "The .agentic-qe/config.yaml file parsed by `dz doctor` — validated by yaml.parse with a structural check (typeof parsed?.project === 'string').",
|
|
51
|
+
"validator": "packages/@dzhechkov/harness-core/src/operations.ts",
|
|
52
|
+
"scan": "yaml.parse"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill discovery + loading — the consolidated filesystem loader.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join, relative } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { parse as parseYaml } from 'yaml';
|
|
11
|
+
|
|
12
|
+
import { ClaudeSkillFrontmatterSchema, parseSkillDocument } from '@dzhechkov/core';
|
|
13
|
+
import type { CanonicalSkill, SkillAsset } from '@dzhechkov/core';
|
|
14
|
+
|
|
15
|
+
/** A discovered skill — id plus its description, for listings. */
|
|
16
|
+
export interface SkillSummary {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly description: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Detailed skill info. */
|
|
22
|
+
export interface SkillInfo {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly description: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly trustTier: number | undefined;
|
|
27
|
+
readonly version: string | number | undefined;
|
|
28
|
+
readonly assetCount: number;
|
|
29
|
+
readonly assetPaths: string[];
|
|
30
|
+
readonly frontmatter: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Recursively list every file under `dir`. */
|
|
34
|
+
function walkFiles(dir: string): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
const full = join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) out.push(...walkFiles(full));
|
|
39
|
+
else if (entry.isFile()) out.push(full);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Return the ids of every `<skillsDir>/<id>/SKILL.md`, sorted. */
|
|
45
|
+
export function discoverSkillIds(skillsDir: string): string[] {
|
|
46
|
+
if (!existsSync(skillsDir)) return [];
|
|
47
|
+
return readdirSync(skillsDir, { withFileTypes: true })
|
|
48
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, 'SKILL.md')))
|
|
49
|
+
.map((entry) => entry.name)
|
|
50
|
+
.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Discover every skill in `skillsDir`, returning id + description. */
|
|
54
|
+
export function listSkills(skillsDir: string): SkillSummary[] {
|
|
55
|
+
return discoverSkillIds(skillsDir).map((id) => {
|
|
56
|
+
const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
|
|
57
|
+
const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
|
|
58
|
+
return { id, description: frontmatter.description };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get detailed info about a single skill without loading all assets. */
|
|
63
|
+
export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefined {
|
|
64
|
+
const skillDir = join(skillsDir, id);
|
|
65
|
+
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
66
|
+
if (!existsSync(skillMdPath)) return undefined;
|
|
67
|
+
const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
|
|
68
|
+
const fm = parseYaml(document.frontmatterYaml) as Record<string, unknown>;
|
|
69
|
+
const parsed = ClaudeSkillFrontmatterSchema.parse(fm);
|
|
70
|
+
const assetPaths = walkFiles(skillDir)
|
|
71
|
+
.filter((p) => p !== skillMdPath)
|
|
72
|
+
.map((p) => relative(skillDir, p).split('\\').join('/'))
|
|
73
|
+
.sort();
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
description: parsed.description,
|
|
77
|
+
name: parsed.name ?? id,
|
|
78
|
+
trustTier: fm['trust_tier'] as number | undefined,
|
|
79
|
+
version: parsed.version as string | number | undefined,
|
|
80
|
+
assetCount: assetPaths.length,
|
|
81
|
+
assetPaths,
|
|
82
|
+
frontmatter: fm,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load one `<skillsDir>/<id>/` directory into a {@link CanonicalSkill}: its
|
|
88
|
+
* `SKILL.md` document plus every other file as a bundled asset.
|
|
89
|
+
*
|
|
90
|
+
* @throws if the skill directory has no `SKILL.md`.
|
|
91
|
+
*/
|
|
92
|
+
export function loadSkillFromDir(skillsDir: string, id: string): CanonicalSkill {
|
|
93
|
+
const skillDir = join(skillsDir, id);
|
|
94
|
+
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
95
|
+
if (!existsSync(skillMdPath)) {
|
|
96
|
+
throw new Error(`skill not found: ${JSON.stringify(id)} (looked in ${skillsDir})`);
|
|
97
|
+
}
|
|
98
|
+
const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
|
|
99
|
+
const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
|
|
100
|
+
const assets: SkillAsset[] = walkFiles(skillDir)
|
|
101
|
+
.filter((path) => path !== skillMdPath)
|
|
102
|
+
.map((path) => ({
|
|
103
|
+
path: relative(skillDir, path).split('\\').join('/'),
|
|
104
|
+
encoding: 'utf-8' as const,
|
|
105
|
+
content: readFileSync(path, 'utf-8'),
|
|
106
|
+
}))
|
|
107
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
108
|
+
return { id, frontmatter, document, assets };
|
|
109
|
+
}
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--target` names → platform adapters.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { claudeAdapter } from '@dzhechkov/adapter-claude';
|
|
8
|
+
import { codexAdapter } from '@dzhechkov/adapter-codex';
|
|
9
|
+
import { hermesAdapter } from '@dzhechkov/adapter-hermes';
|
|
10
|
+
import { opencodeAdapter } from '@dzhechkov/adapter-opencode';
|
|
11
|
+
import type { Adapter } from '@dzhechkov/core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The targets the harness can initialise. The key is the CLI `--target` name
|
|
15
|
+
* (`claude-code`, not `claude`); the value is the adapter that emits for it.
|
|
16
|
+
*/
|
|
17
|
+
export const TARGETS = {
|
|
18
|
+
'claude-code': claudeAdapter,
|
|
19
|
+
codex: codexAdapter,
|
|
20
|
+
opencode: opencodeAdapter,
|
|
21
|
+
hermes: hermesAdapter,
|
|
22
|
+
} as const satisfies Record<string, Adapter>;
|
|
23
|
+
|
|
24
|
+
/** A valid `--target` name. */
|
|
25
|
+
export type TargetName = keyof typeof TARGETS;
|
|
26
|
+
|
|
27
|
+
/** Every supported `--target` name. */
|
|
28
|
+
export const TARGET_NAMES = Object.keys(TARGETS) as TargetName[];
|
|
29
|
+
|
|
30
|
+
/** Type guard: is `value` a supported `--target` name? */
|
|
31
|
+
export function isTargetName(value: string): value is TargetName {
|
|
32
|
+
return Object.prototype.hasOwnProperty.call(TARGETS, value);
|
|
33
|
+
}
|