@moreih29/nexus-core 0.4.0 → 0.5.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 (31) hide show
  1. package/conformance/README.md +5 -1
  2. package/conformance/examples/plan.extension.schema.example.json +25 -0
  3. package/conformance/lifecycle/agent-complete.json +2 -1
  4. package/conformance/lifecycle/agent-resume.json +2 -1
  5. package/conformance/lifecycle/agent-spawn.json +7 -4
  6. package/conformance/lifecycle/session-end.json +3 -2
  7. package/conformance/lifecycle/session-start.json +5 -3
  8. package/conformance/scenarios/full-plan-cycle.json +3 -3
  9. package/conformance/schema/fixture.schema.json +3 -3
  10. package/conformance/state-schemas/agent-tracker.schema.json +10 -5
  11. package/conformance/state-schemas/history.schema.json +11 -1
  12. package/conformance/state-schemas/plan.schema.json +5 -0
  13. package/conformance/state-schemas/runtime.schema.json +13 -4
  14. package/conformance/state-schemas/tasks.schema.json +5 -0
  15. package/conformance/tools/plan-decide.json +7 -7
  16. package/conformance/tools/plan-start.json +1 -1
  17. package/conformance/tools/task-add.json +1 -1
  18. package/conformance/tools/task-close.json +2 -0
  19. package/docs/consumer-implementation-guide.md +1 -1
  20. package/docs/nexus-outputs-contract.md +10 -0
  21. package/docs/nexus-tools-contract.md +12 -2
  22. package/manifest.json +55 -55
  23. package/package.json +5 -1
  24. package/scripts/.gitkeep +0 -0
  25. package/scripts/conformance-coverage.ts +466 -0
  26. package/scripts/import-from-claude-nexus.ts +403 -0
  27. package/scripts/lib/frontmatter.ts +71 -0
  28. package/scripts/lib/lint.ts +216 -0
  29. package/scripts/lib/structure.ts +159 -0
  30. package/scripts/lib/validate.ts +668 -0
  31. package/scripts/validate.ts +90 -0
@@ -0,0 +1,668 @@
1
+ import Ajv2020 from 'ajv/dist/2020';
2
+ import type { ValidateFunction } from 'ajv';
3
+ import addFormats from 'ajv-formats';
4
+ import ajvErrors from 'ajv-errors';
5
+ import { glob } from 'tinyglobby';
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import { createHash } from 'node:crypto';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import path from 'node:path';
10
+ import { parseFrontmatter, frontmatterLineToSourceLine } from './frontmatter.ts';
11
+
12
+ export interface ValidationResult {
13
+ file: string;
14
+ gate: string;
15
+ severity: 'error' | 'warning';
16
+ line?: number;
17
+ message: string;
18
+ }
19
+
20
+ // ─── Manifest types ──────────────────────────────────────────────────────────
21
+
22
+ interface AgentMeta {
23
+ id: string;
24
+ name: string;
25
+ alias_ko?: string;
26
+ description: string;
27
+ task?: string;
28
+ category: string;
29
+ capabilities: string[];
30
+ resume_tier: string;
31
+ model_tier: string;
32
+ }
33
+
34
+ interface SkillMeta {
35
+ id: string;
36
+ name: string;
37
+ alias_ko?: string;
38
+ description: string;
39
+ summary?: string;
40
+ harness_docs_refs?: string[];
41
+ triggers: string[];
42
+ manual_only?: boolean;
43
+ }
44
+
45
+ interface CapabilityEntry {
46
+ id: string;
47
+ description: string;
48
+ intent: string;
49
+ blocks_semantic_classes: string[];
50
+ prose_guidance: string;
51
+ }
52
+
53
+ interface SimpleEntry {
54
+ id: string;
55
+ description: string;
56
+ }
57
+
58
+ interface TagEntry {
59
+ id: string;
60
+ trigger: string;
61
+ type: 'skill' | 'inline_action';
62
+ description: string;
63
+ skill?: string;
64
+ handler?: string;
65
+ variants?: string[];
66
+ }
67
+
68
+ interface Vocab {
69
+ capabilities: CapabilityEntry[];
70
+ categories: SimpleEntry[];
71
+ resume_tiers: SimpleEntry[];
72
+ tags: TagEntry[];
73
+ }
74
+
75
+ interface ManifestAgent extends AgentMeta {
76
+ body_hash: string;
77
+ }
78
+
79
+ interface ManifestSkill extends SkillMeta {
80
+ body_hash: string;
81
+ }
82
+
83
+ interface Manifest {
84
+ nexus_core_version: string;
85
+ nexus_core_commit: string;
86
+ schema_contract_version: string;
87
+ agents: ManifestAgent[];
88
+ skills: ManifestSkill[];
89
+ vocabulary: {
90
+ capabilities: CapabilityEntry[];
91
+ categories: SimpleEntry[];
92
+ resume_tiers: SimpleEntry[];
93
+ tags: TagEntry[];
94
+ };
95
+ }
96
+
97
+ // ─── AJV setup ───────────────────────────────────────────────────────────────
98
+
99
+ let ajvInstance: Ajv2020 | null = null;
100
+ let schemaDir = '';
101
+
102
+ interface LoadedSchemas {
103
+ agentValidator: ValidateFunction;
104
+ skillValidator: ValidateFunction;
105
+ vocabValidator: ValidateFunction;
106
+ manifestValidator: ValidateFunction;
107
+ }
108
+
109
+ let cachedSchemas: LoadedSchemas | null = null;
110
+
111
+ /** G1: Load and compile JSON schemas. Must be called before runAll. */
112
+ export async function loadSchemas(root: string): Promise<void> {
113
+ schemaDir = path.join(root, 'schema');
114
+
115
+ const ajv = new Ajv2020({
116
+ strict: true,
117
+ allErrors: true,
118
+ verbose: true,
119
+ loadSchema: async (uri: string) => {
120
+ // Resolve relative $ref URIs within schema directory
121
+ const basename = path.basename(uri);
122
+ const schemaPath = path.join(schemaDir, basename);
123
+ const content = await readFile(schemaPath, 'utf8');
124
+ return JSON.parse(content) as Record<string, unknown>;
125
+ },
126
+ });
127
+ addFormats(ajv);
128
+ ajvErrors(ajv);
129
+
130
+ const [commonRaw, agentRaw, skillRaw, vocabRaw, manifestRaw] = await Promise.all([
131
+ readFile(path.join(schemaDir, 'common.schema.json'), 'utf8'),
132
+ readFile(path.join(schemaDir, 'agent.schema.json'), 'utf8'),
133
+ readFile(path.join(schemaDir, 'skill.schema.json'), 'utf8'),
134
+ readFile(path.join(schemaDir, 'vocabulary.schema.json'), 'utf8'),
135
+ readFile(path.join(schemaDir, 'manifest.schema.json'), 'utf8'),
136
+ ]);
137
+
138
+ const commonSchema = JSON.parse(commonRaw) as Record<string, unknown>;
139
+ const agentSchema = JSON.parse(agentRaw) as Record<string, unknown>;
140
+ const skillSchema = JSON.parse(skillRaw) as Record<string, unknown>;
141
+ const vocabSchema = JSON.parse(vocabRaw) as Record<string, unknown>;
142
+ const manifestSchema = JSON.parse(manifestRaw) as Record<string, unknown>;
143
+
144
+ ajv.addSchema(commonSchema);
145
+ ajv.addSchema(vocabSchema);
146
+
147
+ const agentValidator = await ajv.compileAsync(agentSchema);
148
+ const skillValidator = await ajv.compileAsync(skillSchema);
149
+
150
+ // Vocabulary files use named $defs — compile per sub-schema
151
+ const vocabDefs = (vocabSchema['$defs'] ?? {}) as Record<string, Record<string, unknown>>;
152
+ const capabilityFileSchema = {
153
+ ...vocabDefs['capabilityFile'],
154
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
155
+ $id: 'vocabulary-capability-file',
156
+ $defs: vocabDefs,
157
+ };
158
+ const categoryFileSchema = {
159
+ ...vocabDefs['categoryFile'],
160
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
161
+ $id: 'vocabulary-category-file',
162
+ $defs: vocabDefs,
163
+ };
164
+ const resumeTierFileSchema = {
165
+ ...vocabDefs['resumeTierFile'],
166
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
167
+ $id: 'vocabulary-resume-tier-file',
168
+ $defs: vocabDefs,
169
+ };
170
+ const tagFileSchema = {
171
+ ...vocabDefs['tagFile'],
172
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
173
+ $id: 'vocabulary-tag-file',
174
+ $defs: vocabDefs,
175
+ };
176
+
177
+ const capabilityValidator = await ajv.compileAsync(capabilityFileSchema);
178
+ const categoryValidator = await ajv.compileAsync(categoryFileSchema);
179
+ const resumeTierValidator = await ajv.compileAsync(resumeTierFileSchema);
180
+ const tagValidator = await ajv.compileAsync(tagFileSchema);
181
+
182
+ const manifestAjv = new Ajv2020({
183
+ strict: false,
184
+ allErrors: true,
185
+ loadSchema: async (uri: string) => {
186
+ const basename = path.basename(uri);
187
+ const schemaPath = path.join(schemaDir, basename);
188
+ const content = await readFile(schemaPath, 'utf8');
189
+ return JSON.parse(content) as Record<string, unknown>;
190
+ },
191
+ });
192
+ addFormats(manifestAjv);
193
+ ajvErrors(manifestAjv);
194
+ manifestAjv.addSchema(commonSchema);
195
+ const manifestValidator = await manifestAjv.compileAsync(manifestSchema);
196
+
197
+ ajvInstance = ajv;
198
+ cachedSchemas = {
199
+ agentValidator,
200
+ skillValidator,
201
+ // Store vocabulary validators and manifest as composite
202
+ vocabValidator: capabilityValidator, // placeholder — we handle vocab separately below
203
+ manifestValidator,
204
+ };
205
+
206
+ // Store all vocab validators for internal use
207
+ _vocabValidators = { capabilityValidator, categoryValidator, resumeTierValidator, tagValidator };
208
+ }
209
+
210
+ interface VocabValidators {
211
+ capabilityValidator: ValidateFunction;
212
+ categoryValidator: ValidateFunction;
213
+ resumeTierValidator: ValidateFunction;
214
+ tagValidator: ValidateFunction;
215
+ }
216
+
217
+ let _vocabValidators: VocabValidators | null = null;
218
+
219
+ // ─── offsetToLine helper ──────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * Converts a character offset in source text to a 1-based line number.
223
+ * Works for both frontmatter and body regions.
224
+ */
225
+ export function offsetToLine(source: string, offset: number): number {
226
+ return source.slice(0, offset).split('\n').length;
227
+ }
228
+
229
+ // ─── G1: Schema validation ────────────────────────────────────────────────────
230
+
231
+ async function validateAgentMeta(
232
+ filePath: string,
233
+ rel: string
234
+ ): Promise<{ result: ValidationResult[]; data: AgentMeta | null }> {
235
+ if (!cachedSchemas) return { result: [], data: null };
236
+ const results: ValidationResult[] = [];
237
+
238
+ let source: string;
239
+ try {
240
+ source = await readFile(filePath, 'utf8');
241
+ } catch (err) {
242
+ return {
243
+ result: [{ file: rel, gate: 'G1-schema', severity: 'error', message: `Cannot read file: ${(err as Error).message}` }],
244
+ data: null,
245
+ };
246
+ }
247
+
248
+ let data: unknown;
249
+ try {
250
+ data = parseYaml(source);
251
+ } catch (err) {
252
+ return {
253
+ result: [{ file: rel, gate: 'G1-schema', severity: 'error', message: `YAML parse error: ${(err as Error).message}` }],
254
+ data: null,
255
+ };
256
+ }
257
+
258
+ const valid = cachedSchemas.agentValidator(data);
259
+ if (!valid) {
260
+ for (const e of cachedSchemas.agentValidator.errors ?? []) {
261
+ results.push({
262
+ file: rel,
263
+ gate: 'G1-schema',
264
+ severity: 'error',
265
+ message: `${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`,
266
+ });
267
+ }
268
+ return { result: results, data: null };
269
+ }
270
+
271
+ return { result: [], data: data as AgentMeta };
272
+ }
273
+
274
+ async function validateSkillMeta(
275
+ filePath: string,
276
+ rel: string
277
+ ): Promise<{ result: ValidationResult[]; data: SkillMeta | null }> {
278
+ if (!cachedSchemas) return { result: [], data: null };
279
+
280
+ let source: string;
281
+ try {
282
+ source = await readFile(filePath, 'utf8');
283
+ } catch (err) {
284
+ return {
285
+ result: [{ file: rel, gate: 'G1-schema', severity: 'error', message: `Cannot read file: ${(err as Error).message}` }],
286
+ data: null,
287
+ };
288
+ }
289
+
290
+ let data: unknown;
291
+ try {
292
+ data = parseYaml(source);
293
+ } catch (err) {
294
+ return {
295
+ result: [{ file: rel, gate: 'G1-schema', severity: 'error', message: `YAML parse error: ${(err as Error).message}` }],
296
+ data: null,
297
+ };
298
+ }
299
+
300
+ const valid = cachedSchemas.skillValidator(data);
301
+ if (!valid) {
302
+ const results: ValidationResult[] = [];
303
+ for (const e of cachedSchemas.skillValidator.errors ?? []) {
304
+ results.push({
305
+ file: rel,
306
+ gate: 'G1-schema',
307
+ severity: 'error',
308
+ message: `${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`,
309
+ });
310
+ }
311
+ return { result: results, data: null };
312
+ }
313
+
314
+ return { result: [], data: data as SkillMeta };
315
+ }
316
+
317
+ // ─── G2-G5: Referential integrity ────────────────────────────────────────────
318
+
319
+ function checkCapabilityIntegrity(
320
+ agents: Array<{ meta: AgentMeta; rel: string }>,
321
+ capabilityIds: Set<string>
322
+ ): ValidationResult[] {
323
+ const results: ValidationResult[] = [];
324
+ for (const { meta, rel } of agents) {
325
+ for (const cap of meta.capabilities) {
326
+ if (!capabilityIds.has(cap)) {
327
+ results.push({
328
+ file: rel,
329
+ gate: 'G2-capability-integrity',
330
+ severity: 'error',
331
+ message: `Unknown capability '${cap}' — not defined in vocabulary/capabilities.yml`,
332
+ });
333
+ }
334
+ }
335
+ }
336
+ return results;
337
+ }
338
+
339
+ function checkCategoryIntegrity(
340
+ agents: Array<{ meta: AgentMeta; rel: string }>,
341
+ categoryIds: Set<string>
342
+ ): ValidationResult[] {
343
+ const results: ValidationResult[] = [];
344
+ for (const { meta, rel } of agents) {
345
+ if (!categoryIds.has(meta.category)) {
346
+ results.push({
347
+ file: rel,
348
+ gate: 'G3-category-integrity',
349
+ severity: 'error',
350
+ message: `Unknown category '${meta.category}' — not defined in vocabulary/categories.yml`,
351
+ });
352
+ }
353
+ }
354
+ return results;
355
+ }
356
+
357
+ function checkResumeTierIntegrity(
358
+ agents: Array<{ meta: AgentMeta; rel: string }>,
359
+ resumeTierIds: Set<string>
360
+ ): ValidationResult[] {
361
+ const results: ValidationResult[] = [];
362
+ for (const { meta, rel } of agents) {
363
+ if (!resumeTierIds.has(meta.resume_tier)) {
364
+ results.push({
365
+ file: rel,
366
+ gate: 'G4-resume-tier-integrity',
367
+ severity: 'error',
368
+ message: `Unknown resume_tier '${meta.resume_tier}' — not defined in vocabulary/resume-tiers.yml`,
369
+ });
370
+ }
371
+ }
372
+ return results;
373
+ }
374
+
375
+ function checkTagIntegrity(
376
+ skills: Array<{ meta: SkillMeta; rel: string }>,
377
+ tags: TagEntry[]
378
+ ): ValidationResult[] {
379
+ // G5: skill.triggers references must be tag ids of type=skill
380
+ const skillTagIds = new Set(tags.filter((t) => t.type === 'skill').map((t) => t.id));
381
+ const results: ValidationResult[] = [];
382
+ for (const { meta, rel } of skills) {
383
+ for (const trigger of meta.triggers ?? []) {
384
+ if (!skillTagIds.has(trigger)) {
385
+ results.push({
386
+ file: rel,
387
+ gate: 'G5-tag-integrity',
388
+ severity: 'error',
389
+ message: `Trigger '${trigger}' is not a known skill-type tag id in vocabulary/tags.yml`,
390
+ });
391
+ }
392
+ }
393
+ }
394
+ return results;
395
+ }
396
+
397
+ // ─── G5': Capability entry integrity ─────────────────────────────────────────
398
+
399
+ const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/;
400
+
401
+ export function checkCapabilityEntryIntegrity(capabilities: CapabilityEntry[]): ValidationResult[] {
402
+ const results: ValidationResult[] = [];
403
+ for (const cap of capabilities) {
404
+ if (!SNAKE_CASE_RE.test(cap.intent)) {
405
+ results.push({
406
+ file: 'vocabulary/capabilities.yml',
407
+ gate: 'G5-capability-integrity',
408
+ severity: 'error',
409
+ message: `Capability '${cap.id}': 'intent' must match snake_case /^[a-z][a-z0-9_]*$/, got '${cap.intent}'`,
410
+ });
411
+ }
412
+ if (!cap.blocks_semantic_classes || cap.blocks_semantic_classes.length === 0) {
413
+ results.push({
414
+ file: 'vocabulary/capabilities.yml',
415
+ gate: 'G5-capability-integrity',
416
+ severity: 'error',
417
+ message: `Capability '${cap.id}': 'blocks_semantic_classes' must have at least 1 entry`,
418
+ });
419
+ } else {
420
+ for (const cls of cap.blocks_semantic_classes) {
421
+ if (!SNAKE_CASE_RE.test(cls)) {
422
+ results.push({
423
+ file: 'vocabulary/capabilities.yml',
424
+ gate: 'G5-capability-integrity',
425
+ severity: 'error',
426
+ message: `Capability '${cap.id}': class '${cls}' must match snake_case /^[a-z][a-z0-9_]*$/`,
427
+ });
428
+ }
429
+ }
430
+ }
431
+ if (!cap.prose_guidance || cap.prose_guidance.trim().length < 40) {
432
+ results.push({
433
+ file: 'vocabulary/capabilities.yml',
434
+ gate: 'G5-capability-integrity',
435
+ severity: 'error',
436
+ message: `Capability '${cap.id}': 'prose_guidance' must be at least 40 characters`,
437
+ });
438
+ }
439
+ }
440
+ return results;
441
+ }
442
+
443
+ // ─── Vocabulary loading ───────────────────────────────────────────────────────
444
+
445
+ async function loadVocab(root: string): Promise<{ vocab: Vocab | null; results: ValidationResult[] }> {
446
+ if (!_vocabValidators) return { vocab: null, results: [] };
447
+ const results: ValidationResult[] = [];
448
+
449
+ const vocabDir = path.join(root, 'vocabulary');
450
+
451
+ async function loadYaml<T>(filename: string, validator: ValidateFunction): Promise<T | null> {
452
+ const filePath = path.join(vocabDir, filename);
453
+ const rel = path.join('vocabulary', filename);
454
+ let source: string;
455
+ try {
456
+ source = await readFile(filePath, 'utf8');
457
+ } catch {
458
+ // Vocabulary file absent — skip silently (no agents/skills to validate)
459
+ return null;
460
+ }
461
+
462
+ let data: unknown;
463
+ try {
464
+ data = parseYaml(source);
465
+ } catch (err) {
466
+ results.push({ file: rel, gate: 'G1-schema', severity: 'error', message: `YAML parse error: ${(err as Error).message}` });
467
+ return null;
468
+ }
469
+
470
+ const valid = validator(data);
471
+ if (!valid) {
472
+ for (const e of validator.errors ?? []) {
473
+ results.push({
474
+ file: rel,
475
+ gate: 'G1-schema',
476
+ severity: 'error',
477
+ message: `${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`,
478
+ });
479
+ }
480
+ return null;
481
+ }
482
+
483
+ return data as T;
484
+ }
485
+
486
+ const [capData, catData, resumeData, tagData] = await Promise.all([
487
+ loadYaml<{ capabilities: CapabilityEntry[] }>('capabilities.yml', _vocabValidators.capabilityValidator),
488
+ loadYaml<{ categories: SimpleEntry[] }>('categories.yml', _vocabValidators.categoryValidator),
489
+ loadYaml<{ resume_tiers: SimpleEntry[] }>('resume-tiers.yml', _vocabValidators.resumeTierValidator),
490
+ loadYaml<{ tags: TagEntry[] }>('tags.yml', _vocabValidators.tagValidator),
491
+ ]);
492
+
493
+ if (!capData || !catData || !resumeData || !tagData) {
494
+ return { vocab: null, results };
495
+ }
496
+
497
+ return {
498
+ vocab: {
499
+ capabilities: capData.capabilities,
500
+ categories: catData.categories,
501
+ resume_tiers: resumeData.resume_tiers,
502
+ tags: tagData.tags,
503
+ },
504
+ results,
505
+ };
506
+ }
507
+
508
+ // ─── body_hash ────────────────────────────────────────────────────────────────
509
+
510
+ async function computeBodyHash(bodyPath: string): Promise<string> {
511
+ const content = await readFile(bodyPath, 'utf8');
512
+ // Normalize line endings before hashing
513
+ const normalized = content.replace(/\r\n/g, '\n').trimEnd() + '\n';
514
+ const hash = createHash('sha256').update(normalized, 'utf8').digest('hex');
515
+ return `sha256:${hash}`;
516
+ }
517
+
518
+ // ─── Manifest generation ──────────────────────────────────────────────────────
519
+
520
+ /** Generate manifest.json structure from validated agents, skills, and vocabulary. */
521
+ export async function generateManifest(
522
+ agents: Array<{ meta: AgentMeta; dir: string }>,
523
+ skills: Array<{ meta: SkillMeta; dir: string }>,
524
+ vocab: Vocab,
525
+ version: string,
526
+ commit: string
527
+ ): Promise<Manifest> {
528
+ const agentEntries: ManifestAgent[] = await Promise.all(
529
+ agents.map(async ({ meta, dir }) => {
530
+ const body_hash = await computeBodyHash(path.join(dir, 'body.md'));
531
+ return { ...meta, body_hash };
532
+ })
533
+ );
534
+
535
+ const skillEntries: ManifestSkill[] = await Promise.all(
536
+ skills.map(async ({ meta, dir }) => {
537
+ const body_hash = await computeBodyHash(path.join(dir, 'body.md'));
538
+ return { ...meta, body_hash };
539
+ })
540
+ );
541
+
542
+ return {
543
+ nexus_core_version: version,
544
+ nexus_core_commit: commit,
545
+ schema_contract_version: '2.0',
546
+ agents: agentEntries,
547
+ skills: skillEntries,
548
+ vocabulary: {
549
+ capabilities: vocab.capabilities,
550
+ categories: vocab.categories,
551
+ resume_tiers: vocab.resume_tiers,
552
+ tags: vocab.tags,
553
+ },
554
+ };
555
+ }
556
+
557
+ /** Write manifest to <root>/manifest.json. */
558
+ export async function writeManifest(root: string, manifest: Manifest): Promise<void> {
559
+ const dest = path.join(root, 'manifest.json');
560
+ await writeFile(dest, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
561
+ }
562
+
563
+ // ─── runAll ───────────────────────────────────────────────────────────────────
564
+
565
+ /**
566
+ * G1-G5: per-file fail-forward schema + referential integrity validation.
567
+ * On success (no errors), generates and writes manifest.json.
568
+ */
569
+ export async function runAll(root: string): Promise<ValidationResult[]> {
570
+ const allResults: ValidationResult[] = [];
571
+
572
+ // Load vocabulary first (G1 for vocab files)
573
+ const { vocab, results: vocabResults } = await loadVocab(root);
574
+ allResults.push(...vocabResults);
575
+
576
+ // Collect capability/category/resumeTier/tag ID sets (may be empty if vocab missing)
577
+ const capabilityIds = new Set((vocab?.capabilities ?? []).map((c) => c.id));
578
+ const categoryIds = new Set((vocab?.categories ?? []).map((c) => c.id));
579
+ const resumeTierIds = new Set((vocab?.resume_tiers ?? []).map((r) => r.id));
580
+ const tags = vocab?.tags ?? [];
581
+
582
+ // Discover agent directories
583
+ const agentMetaPaths = await glob(['agents/*/meta.yml'], {
584
+ cwd: root,
585
+ absolute: true,
586
+ onlyFiles: true,
587
+ });
588
+
589
+ const validAgents: Array<{ meta: AgentMeta; rel: string; dir: string }> = [];
590
+
591
+ for (const metaPath of agentMetaPaths) {
592
+ const rel = path.relative(root, metaPath);
593
+ const { result, data } = await validateAgentMeta(metaPath, rel);
594
+ allResults.push(...result);
595
+ if (data) {
596
+ validAgents.push({ meta: data, rel, dir: path.dirname(metaPath) });
597
+ }
598
+ // per-file fail-forward: if G1 fails for this file, G2-G5 checks skip it (data is null)
599
+ }
600
+
601
+ // Discover skill directories
602
+ const skillMetaPaths = await glob(['skills/*/meta.yml'], {
603
+ cwd: root,
604
+ absolute: true,
605
+ onlyFiles: true,
606
+ });
607
+
608
+ const validSkills: Array<{ meta: SkillMeta; rel: string; dir: string }> = [];
609
+
610
+ for (const metaPath of skillMetaPaths) {
611
+ const rel = path.relative(root, metaPath);
612
+ const { result, data } = await validateSkillMeta(metaPath, rel);
613
+ allResults.push(...result);
614
+ if (data) {
615
+ validSkills.push({ meta: data, rel, dir: path.dirname(metaPath) });
616
+ }
617
+ }
618
+
619
+ // G2-G5: referential integrity (only on files that passed G1)
620
+ if (vocab) {
621
+ allResults.push(...checkCapabilityIntegrity(validAgents, capabilityIds));
622
+ allResults.push(...checkCategoryIntegrity(validAgents, categoryIds));
623
+ allResults.push(...checkResumeTierIntegrity(validAgents, resumeTierIds));
624
+ allResults.push(...checkTagIntegrity(validSkills, tags));
625
+ // G5': capability entry field integrity
626
+ allResults.push(...checkCapabilityEntryIntegrity(vocab.capabilities));
627
+ }
628
+
629
+ // Manifest generation — only on full success (no errors)
630
+ const hasErrors = allResults.some((r) => r.severity === 'error');
631
+ if (!hasErrors && vocab) {
632
+ try {
633
+ // Determine version and commit from env or package.json
634
+ let version = process.env['npm_package_version'] ?? '0.0.0';
635
+ if (version === '0.0.0') {
636
+ try {
637
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) as { version?: string };
638
+ version = pkg.version ?? '0.0.0';
639
+ } catch {
640
+ // ignore
641
+ }
642
+ }
643
+
644
+ let commit = process.env['GITHUB_SHA'] ?? 'local';
645
+ if (commit === 'local') {
646
+ try {
647
+ const { execSync } = await import('node:child_process');
648
+ const sha = execSync('git rev-parse --short HEAD', { cwd: root, encoding: 'utf8' }).trim();
649
+ if (sha) commit = sha;
650
+ } catch {
651
+ // ignore — keep 'local'
652
+ }
653
+ }
654
+
655
+ const manifest = await generateManifest(validAgents, validSkills, vocab, version, commit);
656
+ await writeManifest(root, manifest);
657
+ } catch (err) {
658
+ allResults.push({
659
+ file: 'manifest.json',
660
+ gate: 'G1-schema',
661
+ severity: 'error',
662
+ message: `Manifest generation failed: ${(err as Error).message}`,
663
+ });
664
+ }
665
+ }
666
+
667
+ return allResults;
668
+ }