@aikdna/kdna-core 0.7.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-core",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "KDNA core library — pure logic for loading, validating, linting, and rendering KDNA domain cognition packages. Zero Node.js dependencies.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -47,5 +47,9 @@
47
47
  "ajv-formats": {
48
48
  "optional": true
49
49
  }
50
+ },
51
+ "dependencies": {
52
+ "@noble/hashes": "^2.2.0",
53
+ "cbor-x": "^1.6.4"
50
54
  }
51
55
  }
@@ -1,18 +1,28 @@
1
1
  /**
2
2
  * KDNA Asset Reader — direct .kdna container access.
3
3
  *
4
- * This module intentionally uses only Node.js built-ins. It reads ZIP central
5
- * directory records directly so runtimes can inspect, verify, and load .kdna
6
- * assets without persistent extraction to a domain directory.
4
+ * Supports KDNA Container v2 (payload.kdnab via CBOR).
5
+ * V1 plaintext containers (KDNA_Core.json etc. as ZIP entries) are rejected.
7
6
  */
8
7
 
9
8
  const fs = require('fs');
10
9
  const crypto = require('crypto');
11
10
  const zlib = require('zlib');
11
+ const cbor = require('cbor-x');
12
12
  const { loadDomainFromFiles, formatContext } = require('./loader');
13
13
 
14
14
  const STANDARD_ENTRIES = [
15
15
  'kdna.json',
16
+ 'payload.kdnab',
17
+ 'KDNA_Core.json',
18
+ 'KDNA_Patterns.json',
19
+ 'KDNA_Scenarios.json',
20
+ 'KDNA_Cases.json',
21
+ 'KDNA_Reasoning.json',
22
+ 'KDNA_Evolution.json',
23
+ ];
24
+
25
+ const V1_ENTRIES = [
16
26
  'KDNA_Core.json',
17
27
  'KDNA_Patterns.json',
18
28
  'KDNA_Scenarios.json',
@@ -274,6 +284,53 @@ function verifyMediaType(asset, errors) {
274
284
  }
275
285
  }
276
286
 
287
+ /**
288
+ * Verify Human Lock signatures on locked axiom cards.
289
+ * Reconstructs signing payload (cardId|statement|fingerprint) and verifies
290
+ * against the creator's public key from kdna.json manifest.author.
291
+ */
292
+ function verifyHumanLockSignatures(coreData, manifest, errors, warnings) {
293
+ const publicKeyPEM = manifest?.author?.public_key_pem;
294
+ if (!publicKeyPEM) {
295
+ // Skip if no public key available (unsigned assets are valid)
296
+ return;
297
+ }
298
+ const axioms = coreData?.axioms || [];
299
+ if (!Array.isArray(axioms) || axioms.length === 0) return;
300
+
301
+ let verified = 0, missing = 0, invalid = 0;
302
+ for (const ax of axioms) {
303
+ const hl = ax.human_lock;
304
+ if (!hl || !hl.signature) {
305
+ // Only warn for locked cards without signature
306
+ if (ax.status === 'locked' || ax.status === 'tested' || ax.status === 'published') {
307
+ missing++;
308
+ }
309
+ continue;
310
+ }
311
+ try {
312
+ const payload = [ax.id, hl.statement || '', hl.judgment_fingerprint || ''].join('\n');
313
+ const sig = Buffer.from(String(hl.signature).replace(/^ed25519:/, ''), 'hex');
314
+ const key = crypto.createPublicKey(publicKeyPEM);
315
+ const ok = crypto.verify(null, Buffer.from(payload), key, sig);
316
+ if (ok) { verified++; }
317
+ else { invalid++; }
318
+ } catch {
319
+ invalid++;
320
+ }
321
+ }
322
+
323
+ if (invalid > 0) {
324
+ errors.push(`${invalid} Human Lock signature(s) failed verification — judgment may have been altered`);
325
+ }
326
+ if (missing > 0) {
327
+ warnings.push(`${missing} locked card(s) have no Human Lock signature`);
328
+ }
329
+ if (verified > 0 && invalid === 0) {
330
+ // All signed locks verified — good
331
+ }
332
+ }
333
+
277
334
  function validateManifestIdentity(manifest, errors, _warnings) {
278
335
  if (manifest.kdna_spec) {
279
336
  errors.push('kdna.json: kdna_spec is not allowed. Use spec_version.');
@@ -326,6 +383,22 @@ function readManifest(asset) {
326
383
  function readDataMapSync(asset, entries = STANDARD_ENTRIES, options = {}) {
327
384
  const dataMap = {};
328
385
  const manifest = readManifest(asset);
386
+
387
+ // ── KDNA Container v2: decode CBOR payload ──
388
+ if (asset.entries.has('payload.kdnab')) {
389
+ const payloadBuf = asset.readEntry('payload.kdnab');
390
+ const payload = cbor.decode(payloadBuf);
391
+ if (payload && payload.judgment) {
392
+ if (payload.judgment.core) dataMap['KDNA_Core.json'] = payload.judgment.core;
393
+ if (payload.judgment.patterns) dataMap['KDNA_Patterns.json'] = payload.judgment.patterns;
394
+ if (payload.judgment.scenarios) dataMap['KDNA_Scenarios.json'] = payload.judgment.scenarios;
395
+ if (payload.judgment.cases) dataMap['KDNA_Cases.json'] = payload.judgment.cases;
396
+ if (payload.judgment.reasoning) dataMap['KDNA_Reasoning.json'] = payload.judgment.reasoning;
397
+ if (payload.judgment.evolution) dataMap['KDNA_Evolution.json'] = payload.judgment.evolution;
398
+ }
399
+ return dataMap;
400
+ }
401
+
329
402
  const encrypted = encryptedEntries(manifest).filter((entryName) => entries.includes(entryName));
330
403
  if (encrypted.length && typeof options.decryptEntry !== 'function') {
331
404
  throw new Error(`encrypted entries require decryptEntry hook: ${encrypted.join(', ')}`);
@@ -395,6 +468,15 @@ function verifySync(asset, options = {}) {
395
468
  if (options.requireSignature || manifest.signature) {
396
469
  signature_valid = verifySignature(asset, manifest, errors, warnings);
397
470
  }
471
+ // ── Human Lock signature verification ──────────────────
472
+ if (asset.entries.has('KDNA_Core.json')) {
473
+ try {
474
+ const coreData = parseJson(asset.readEntry('KDNA_Core.json'), 'KDNA_Core.json');
475
+ verifyHumanLockSignatures(coreData, manifest, errors, warnings);
476
+ } catch (e) {
477
+ warnings.push(`Human Lock signature check skipped: ${e.message}`);
478
+ }
479
+ }
398
480
  } catch (e) {
399
481
  errors.push(e.message);
400
482
  }
@@ -1,5 +1,12 @@
1
1
  const crypto = require('crypto');
2
2
 
3
+ let argon2id;
4
+ try {
5
+ ({ argon2id } = require('@noble/hashes/argon2.js'));
6
+ } catch {
7
+ // Optional: password-protected assets require @noble/hashes
8
+ }
9
+
3
10
  // ── Profile constants ──────────────────────────────────────────────
4
11
 
5
12
  /**
@@ -18,9 +25,19 @@ const LICENSED_ENTRY_PROFILE = 'kdna-licensed-entry-v1';
18
25
  */
19
26
  const LICENSED_EXPERIMENTAL_PROFILE = 'kdna-licensed-entry-experimental';
20
27
 
28
+ /**
29
+ * RFC-0009 compliant profile.
30
+ * - Argon2id password-based key derivation
31
+ * - AES-256-KW content encryption key wrapping
32
+ * - AES-256-GCM content encryption
33
+ * - Dual key slots: password + recovery
34
+ */
35
+ const PASSWORD_PROTECTED_PROFILE = 'kdna-password-protected-v1';
36
+
21
37
  const RFC_KDF = 'HKDF-SHA256';
22
38
  const RFC_KEY_WRAPPING = 'AES-256-KW';
23
39
  const LEGACY_KDF = 'scrypt-sha256';
40
+ const PASSWORD_KDF = 'Argon2id';
24
41
  const ALG = 'AES-256-GCM';
25
42
 
26
43
  // ── Helpers ───────────────────────────────────────────────────────
@@ -246,6 +263,163 @@ function decryptLicensedEntryV1(envelopeValue, options = {}) {
246
263
  ]);
247
264
  }
248
265
 
266
+ // ── RFC-0009: Password-protected encryption ───────────────────────
267
+
268
+ function ensureArgon2id() {
269
+ if (!argon2id) {
270
+ throw new Error(
271
+ 'password-protected assets require @noble/hashes. Install: npm install @noble/hashes',
272
+ );
273
+ }
274
+ }
275
+
276
+ function derivePasswordKey(password, params = {}) {
277
+ ensureArgon2id();
278
+ const {
279
+ salt,
280
+ memory_kib = 65536,
281
+ iterations = 3,
282
+ parallelism = 4,
283
+ } = params;
284
+ if (!salt) throw new Error('salt is required for Argon2id');
285
+ const saltBuf = decodeBase64(salt, 'salt');
286
+ const passwordBuf = toBuffer(password, 'password');
287
+ const key = argon2id(passwordBuf, saltBuf, {
288
+ t: iterations,
289
+ m: memory_kib,
290
+ p: parallelism,
291
+ dkLen: 32,
292
+ });
293
+ return Buffer.from(key);
294
+ }
295
+
296
+ function generateRecoveryCode() {
297
+ const raw = crypto.randomBytes(32); // 256 bits
298
+ const hex = raw.toString('hex').toUpperCase();
299
+ const groups = hex.match(/.{4}/g);
300
+ return `kdna-recover-${groups.join('-')}`;
301
+ }
302
+
303
+ function decodeRecoveryCode(code) {
304
+ if (typeof code !== 'string' || !code.startsWith('kdna-recover-')) {
305
+ throw new Error('recovery code must start with "kdna-recover-"');
306
+ }
307
+ const hex = code.slice('kdna-recover-'.length).replace(/-/g, '');
308
+ if (!/^[0-9A-Fa-f]{64}$/.test(hex)) {
309
+ throw new Error('recovery code format is invalid');
310
+ }
311
+ return Buffer.from(hex, 'hex');
312
+ }
313
+
314
+ function encryptProtectedEntry(plaintext, options = {}) {
315
+ const { entryName, manifest = {}, password, includeRecovery = true, recoveryCode } = options;
316
+ if (!entryName) throw new Error('entryName is required');
317
+ if (!password) throw new Error('password is required for protected encryption');
318
+
319
+ const cek = generateCEK();
320
+
321
+ // Password slot
322
+ const salt = crypto.randomBytes(16);
323
+ const passwordKdf = {
324
+ name: PASSWORD_KDF,
325
+ salt: salt.toString('base64'),
326
+ memory_kib: 65536,
327
+ iterations: 3,
328
+ parallelism: 4,
329
+ };
330
+ const passwordKey = derivePasswordKey(password, passwordKdf);
331
+ const passwordWrappedKey = wrapCEK(cek, passwordKey);
332
+
333
+ const keySlots = [
334
+ {
335
+ slot: 'password',
336
+ wrap: RFC_KEY_WRAPPING,
337
+ wrapped_key: passwordWrappedKey.toString('base64'),
338
+ },
339
+ ];
340
+
341
+ // Recovery slot
342
+ if (includeRecovery) {
343
+ const recoveryKey = recoveryCode ? decodeRecoveryCode(recoveryCode) : crypto.randomBytes(32);
344
+ const recoveryWrappedKey = wrapCEK(cek, recoveryKey);
345
+ keySlots.push({
346
+ slot: 'recovery',
347
+ wrap: RFC_KEY_WRAPPING,
348
+ wrapped_key: recoveryWrappedKey.toString('base64'),
349
+ });
350
+ }
351
+
352
+ const iv = crypto.randomBytes(12);
353
+ const cipher = crypto.createCipheriv('aes-256-gcm', cek, iv);
354
+ cipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
355
+ const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
356
+
357
+ return {
358
+ profile: PASSWORD_PROTECTED_PROFILE,
359
+ alg: ALG,
360
+ kdf: PASSWORD_KDF,
361
+ key_wrapping: RFC_KEY_WRAPPING,
362
+ password_kdf: passwordKdf,
363
+ key_slots: keySlots,
364
+ iv: iv.toString('base64'),
365
+ tag: cipher.getAuthTag().toString('base64'),
366
+ ciphertext: ciphertext.toString('base64'),
367
+ };
368
+ }
369
+
370
+ function decryptProtectedEntry(envelopeValue, options = {}) {
371
+ const { entryName, manifest = {}, password, recoveryCode } = options;
372
+ if (!entryName) throw new Error('entryName is required');
373
+ if (!password && !recoveryCode) {
374
+ throw new Error('password or recoveryCode is required for protected decryption');
375
+ }
376
+
377
+ const envelope = normalizeEnvelope(envelopeValue);
378
+ if (envelope.profile !== PASSWORD_PROTECTED_PROFILE) {
379
+ throw new Error(
380
+ `unsupported encrypted entry profile: ${envelope.profile || 'unknown'} (expected ${PASSWORD_PROTECTED_PROFILE})`,
381
+ );
382
+ }
383
+ if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
384
+ if (envelope.kdf !== PASSWORD_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
385
+ if (envelope.key_wrapping !== RFC_KEY_WRAPPING) {
386
+ throw new Error(`unsupported encrypted entry key_wrapping: ${envelope.key_wrapping}`);
387
+ }
388
+
389
+ let cek;
390
+ if (password) {
391
+ const passwordKey = derivePasswordKey(password, envelope.password_kdf);
392
+ const passwordSlot = envelope.key_slots.find((s) => s.slot === 'password');
393
+ if (!passwordSlot) throw new Error('password slot missing from envelope');
394
+ cek = unwrapCEK(decodeBase64(passwordSlot.wrapped_key, 'wrapped_key'), passwordKey);
395
+ } else {
396
+ const recoveryKey = decodeRecoveryCode(recoveryCode);
397
+ const recoverySlot = envelope.key_slots.find((s) => s.slot === 'recovery');
398
+ if (!recoverySlot) throw new Error('recovery slot missing from envelope');
399
+ cek = unwrapCEK(decodeBase64(recoverySlot.wrapped_key, 'wrapped_key'), recoveryKey);
400
+ }
401
+
402
+ const decipher = crypto.createDecipheriv('aes-256-gcm', cek, decodeBase64(envelope.iv, 'iv'));
403
+ decipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
404
+ decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
405
+ return Buffer.concat([
406
+ decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
407
+ decipher.final(),
408
+ ]);
409
+ }
410
+
411
+ function createPasswordDecryptEntry(options = {}) {
412
+ const { password } = options;
413
+ return ({ entryName, ciphertext, manifest }) =>
414
+ decryptProtectedEntry(ciphertext, { entryName, manifest, password });
415
+ }
416
+
417
+ function createRecoveryDecryptEntry(options = {}) {
418
+ const { recoveryCode } = options;
419
+ return ({ entryName, ciphertext, manifest }) =>
420
+ decryptProtectedEntry(ciphertext, { entryName, manifest, recoveryCode });
421
+ }
422
+
249
423
  // ── Legacy / experimental profile (pre-RFC, backward compat) ───────
250
424
 
251
425
  function deriveLicensedEntryKeyLegacy(options = {}) {
@@ -329,10 +503,12 @@ module.exports = {
329
503
  // Profiles
330
504
  LICENSED_ENTRY_PROFILE,
331
505
  LICENSED_EXPERIMENTAL_PROFILE,
506
+ PASSWORD_PROTECTED_PROFILE,
332
507
  ALG,
333
508
  RFC_KDF,
334
509
  RFC_KEY_WRAPPING,
335
510
  LEGACY_KDF,
511
+ PASSWORD_KDF,
336
512
 
337
513
  // RFC-0008 compliant
338
514
  deriveWrappingKey,
@@ -342,6 +518,15 @@ module.exports = {
342
518
  encryptLicensedEntryV1,
343
519
  decryptLicensedEntryV1,
344
520
 
521
+ // RFC-0009 compliant
522
+ derivePasswordKey,
523
+ generateRecoveryCode,
524
+ decodeRecoveryCode,
525
+ encryptProtectedEntry,
526
+ decryptProtectedEntry,
527
+ createPasswordDecryptEntry,
528
+ createRecoveryDecryptEntry,
529
+
345
530
  // Legacy
346
531
  deriveLicensedEntryKey: deriveLicensedEntryKeyLegacy,
347
532
  encryptLicensedEntryLegacy,
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ const compose = require('./compose');
9
9
  const assetReader = require('./asset-reader');
10
10
  const cryptoProfile = require('./crypto-profile');
11
11
  const publicApi = require('./public-api');
12
+ const workpackPure = require('./workpack-pure');
12
13
 
13
14
  module.exports = {
14
15
  ...publicApi,
@@ -19,4 +20,5 @@ module.exports = {
19
20
  ...compose,
20
21
  ...assetReader,
21
22
  ...cryptoProfile,
23
+ ...workpackPure,
22
24
  };
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @aikdna/kdna-core — Work Pack validation (pure logic)
3
+ *
4
+ * Zero-dependency pure functions for validating KDNA Work Pack
5
+ * manifests against the Work Pack schema. Does not depend on ajv
6
+ * at source level — the validator is injected.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // ── Embedded Work Pack Schema v0.1 ──────────────────────────────────
13
+ // Self-contained schema with resolved $refs for zero-dependency validation.
14
+
15
+ const WORK_PACK_SCHEMA = {
16
+ $schema: 'http://json-schema.org/draft-07/schema#',
17
+ $id: 'https://aikdna.com/schemas/work-pack.schema.json',
18
+ title: 'KDNA Work Pack Manifest',
19
+ type: 'object',
20
+ required: ['format', 'format_version', 'name', 'version', 'description', 'status', 'kdna'],
21
+ properties: {
22
+ format: { type: 'string', const: 'kdna-workpack' },
23
+ format_version: { type: 'string', pattern: '^\\d+\\.\\d+$' },
24
+ name: { type: 'string', pattern: '^[a-z0-9]+(-[a-z0-9]+)*$', maxLength: 64 },
25
+ version: { type: 'string', pattern: '^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?(\\+[a-zA-Z0-9.]+)?$' },
26
+ description: { type: 'string', maxLength: 280 },
27
+ status: { type: 'string', enum: ['draft', 'experimental', 'stable', 'deprecated'] },
28
+ access: { type: 'string', enum: ['open', 'licensed', 'runtime', 'enterprise', 'partner'], default: 'open' },
29
+ license: { type: 'string', default: 'Apache-2.0' },
30
+ kdna: {
31
+ type: 'object',
32
+ oneOf: [
33
+ {
34
+ required: ['mode', 'asset'],
35
+ properties: {
36
+ mode: { const: 'single' },
37
+ asset: {
38
+ type: 'object',
39
+ required: ['name', 'version', 'role'],
40
+ properties: {
41
+ name: { type: 'string', pattern: '^[a-z0-9_]+$' },
42
+ version: { type: 'string' },
43
+ digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
44
+ role: { type: 'string', enum: ['primary', 'constraint', 'fallback'] },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ },
50
+ {
51
+ required: ['mode', 'assets'],
52
+ properties: {
53
+ mode: { const: 'cluster' },
54
+ assets: {
55
+ type: 'array',
56
+ minItems: 2,
57
+ items: {
58
+ type: 'object',
59
+ required: ['name', 'version', 'role'],
60
+ properties: {
61
+ name: { type: 'string', pattern: '^[a-z0-9_]+$' },
62
+ version: { type: 'string' },
63
+ digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
64
+ role: { type: 'string', enum: ['primary', 'constraint', 'fallback'] },
65
+ },
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ },
70
+ },
71
+ ],
72
+ },
73
+ skills: {
74
+ type: 'array',
75
+ default: [],
76
+ items: {
77
+ type: 'object',
78
+ required: ['name'],
79
+ properties: {
80
+ name: { type: 'string', pattern: '^[a-z0-9]+([_-][a-z0-9]+)*$', maxLength: 64 },
81
+ type: { type: 'string' },
82
+ required: { type: 'boolean', default: true },
83
+ mcp_server: { type: ['string', 'null'], default: null },
84
+ fallback: { type: ['string', 'null'], default: null },
85
+ },
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ templates: {
90
+ type: 'object',
91
+ default: {},
92
+ properties: {
93
+ task: { type: 'string' },
94
+ output: { type: 'string' },
95
+ },
96
+ },
97
+ review_gates: {
98
+ type: 'array',
99
+ default: [],
100
+ items: { type: 'string' },
101
+ },
102
+ risk_policy: { type: 'string' },
103
+ trace_policy: { type: 'string' },
104
+ evals: { type: 'string' },
105
+ },
106
+ additionalProperties: false,
107
+ };
108
+
109
+ // ── Public API ─────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Validate a Work Pack manifest against the schema.
113
+ *
114
+ * @param {object} manifest — parsed workpack.json content
115
+ * @param {object} [opts]
116
+ * @param {object} [opts.ajv] — optional pre-configured Ajv instance
117
+ * @returns {{ valid: boolean, errors: string[] }}
118
+ */
119
+ function validateWorkPackManifest(manifest, opts = {}) {
120
+ const Ajv = opts.ajv || _lazyAjv();
121
+ let validate;
122
+ try {
123
+ validate = Ajv.compile(WORK_PACK_SCHEMA);
124
+ } catch (e) {
125
+ return { valid: false, errors: [`Schema compilation error: ${e.message}`] };
126
+ }
127
+ const ok = validate(manifest);
128
+ if (ok) return { valid: true, errors: [] };
129
+ const errors = (validate.errors || []).map(
130
+ (e) => `${e.instancePath || '/'}: ${e.message}`
131
+ );
132
+ return { valid: false, errors };
133
+ }
134
+
135
+ /**
136
+ * Check structural completeness — verify all referenced files exist.
137
+ *
138
+ * @param {object} manifest — parsed workpack.json
139
+ * @param {string} rootDir — directory containing workpack.json
140
+ * @returns {{ complete: boolean, missing: string[] }}
141
+ */
142
+ function checkWorkPackStructure(manifest, rootDir) {
143
+ const missing = [];
144
+ const refs = [];
145
+
146
+ if (manifest.templates) {
147
+ if (manifest.templates.task) refs.push(manifest.templates.task);
148
+ if (manifest.templates.output) refs.push(manifest.templates.output);
149
+ }
150
+ if (manifest.review_gates) refs.push(...manifest.review_gates);
151
+ if (manifest.risk_policy) refs.push(manifest.risk_policy);
152
+ if (manifest.trace_policy) refs.push(manifest.trace_policy);
153
+ if (manifest.evals) refs.push(manifest.evals);
154
+
155
+ for (const ref of refs) {
156
+ const fullPath = path.resolve(rootDir, ref);
157
+ if (!fs.existsSync(fullPath)) missing.push(ref);
158
+ }
159
+
160
+ return { complete: missing.length === 0, missing };
161
+ }
162
+
163
+ /**
164
+ * Inspect a Work Pack — return a structured summary.
165
+ *
166
+ * @param {object} manifest — parsed workpack.json
167
+ * @param {string} rootDir — directory containing workpack.json
168
+ * @returns {object}
169
+ */
170
+ function inspectWorkPack(manifest, rootDir) {
171
+ const kdnaMode = manifest.kdna?.mode || 'unknown';
172
+ const kdnaCount =
173
+ kdnaMode === 'single' ? 1 : (manifest.kdna?.assets || []).length;
174
+
175
+ const structure = checkWorkPackStructure(manifest, rootDir);
176
+
177
+ return {
178
+ name: manifest.name,
179
+ version: manifest.version,
180
+ description: manifest.description,
181
+ status: manifest.status,
182
+ access: manifest.access || 'open',
183
+ license: manifest.license || 'Apache-2.0',
184
+ format_version: manifest.format_version,
185
+ kdna: {
186
+ mode: kdnaMode,
187
+ assets: kdnaMode === 'single'
188
+ ? [{ name: manifest.kdna.asset.name, version: manifest.kdna.asset.version, role: manifest.kdna.asset.role }]
189
+ : (manifest.kdna?.assets || []).map(a => ({ name: a.name, version: a.version, role: a.role })),
190
+ },
191
+ skills: (manifest.skills || []).map(s => ({
192
+ name: s.name,
193
+ type: s.type || 'unspecified',
194
+ required: s.required !== false,
195
+ fallback: s.fallback || null,
196
+ })),
197
+ templates: manifest.templates
198
+ ? { task: manifest.templates.task || null, output: manifest.templates.output || null }
199
+ : null,
200
+ review_gates: (manifest.review_gates || []).length,
201
+ has_risk_policy: !!manifest.risk_policy,
202
+ has_trace_policy: !!manifest.trace_policy,
203
+ has_evals: !!manifest.evals,
204
+ structural_complete: structure.complete,
205
+ missing_files: structure.missing,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Load a Work Pack from a directory.
211
+ *
212
+ * @param {string} dirPath — path to Work Pack directory
213
+ * @returns {{ manifest: object|null, error: string|null }}
214
+ */
215
+ function loadWorkPack(dirPath) {
216
+ const wpPath = path.join(dirPath, 'workpack.json');
217
+ if (!fs.existsSync(wpPath)) {
218
+ return { manifest: null, error: `workpack.json not found in ${dirPath}` };
219
+ }
220
+ let manifest;
221
+ try {
222
+ manifest = JSON.parse(fs.readFileSync(wpPath, 'utf8'));
223
+ } catch (e) {
224
+ return { manifest: null, error: `Invalid JSON in workpack.json: ${e.message}` };
225
+ }
226
+ return { manifest, error: null };
227
+ }
228
+
229
+ // ── Internal ────────────────────────────────────────────────────────
230
+
231
+ let _ajvInstance = null;
232
+
233
+ function _lazyAjv() {
234
+ if (_ajvInstance) return _ajvInstance;
235
+ try {
236
+ const Ajv = require('ajv');
237
+ const addFormats = require('ajv-formats');
238
+ _ajvInstance = new Ajv({ allErrors: true, strict: false });
239
+ addFormats(_ajvInstance);
240
+ return _ajvInstance;
241
+ } catch (e) {
242
+ throw new Error(
243
+ 'ajv is required for Work Pack validation. Install: npm install ajv ajv-formats'
244
+ );
245
+ }
246
+ }
247
+
248
+ module.exports = {
249
+ WORK_PACK_SCHEMA,
250
+ validateWorkPackManifest,
251
+ checkWorkPackStructure,
252
+ inspectWorkPack,
253
+ loadWorkPack,
254
+ };