@aikdna/kdna-studio-cli 0.1.3 → 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.
@@ -11,6 +11,7 @@ const {
11
11
  evidence: evidenceApi,
12
12
  compile: compileApi,
13
13
  quality,
14
+ creator: creatorApi,
14
15
  } = require('@aikdna/kdna-studio-core');
15
16
 
16
17
  const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
@@ -18,12 +19,20 @@ const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
18
19
  function usage() {
19
20
  console.log(`kdna-studio — Studio-compatible KDNA authoring CLI
20
21
 
21
- Usage:
22
- kdna-studio create <project-dir> [--name <@scope/name|name>]
22
+ Identity:
23
+ kdna-studio identity init [--name <display-name>]
24
+ kdna-studio identity show
25
+
26
+ Create (three entry paths):
27
+ kdna-studio create <project-dir> --name <@scope/name> # blank
28
+ kdna-studio create <project-dir> --from-kdna <file.kdna> --name <@scope/name>
29
+ kdna-studio create <project-dir> --from-folder <source-dir> --name <@scope/name>
30
+
31
+ Authoring:
23
32
  kdna-studio import <project> <source-file>
24
33
  kdna-studio card list <project>
25
34
  kdna-studio card add <project> <type> --field key=value [--field key=value]
26
- kdna-studio card approve <project> <card-id> --by <id> --statement <text>
35
+ kdna-studio card approve <project> <card-id> --by <id> --statement <text> [--sign]
27
36
  kdna-studio lock <project>
28
37
  kdna-studio compile <project> --out <dir>
29
38
  kdna-studio export <project> --out <file.kdna> [--sign]
@@ -110,18 +119,267 @@ function parseFields(args) {
110
119
 
111
120
  function cmdCreate(args) {
112
121
  const dir = args[0];
113
- if (!dir) fail('Usage: kdna-studio create <project-dir> [--name <name>]');
122
+ if (!dir) fail('Usage: kdna-studio create <project-dir> --name <name> [--from-kdna <file> | --from-folder <dir>]');
114
123
  const abs = path.resolve(dir);
115
124
  if (fs.existsSync(abs)) fail(`Directory already exists: ${abs}`);
125
+
116
126
  const name = option(args, '--name', path.basename(abs));
127
+ const fromKdna = option(args, '--from-kdna');
128
+ const fromFolder = option(args, '--from-folder');
129
+
130
+ let sourceMode = 'blank';
131
+ let lineage = null;
132
+ let creatorIdentity = null;
133
+
134
+ // Load creator identity if available
135
+ try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* no identity yet */ }
136
+
137
+ if (fromKdna) {
138
+ sourceMode = 'kdna_asset';
139
+ const kdnaData = importFromKdna(fromKdna);
140
+ lineage = kdnaData.lineage;
141
+ if (kdnaData.cards) {
142
+ const project = projectApi.createProject(name, 'domain', {
143
+ author: { name: option(args, '--author-name', ''), id: option(args, '--author-id', '') },
144
+ sourceMode,
145
+ creatorIdentity,
146
+ lineage,
147
+ });
148
+ project.cards = kdnaData.cards;
149
+ if (project.stages?.judgment_cards) {
150
+ project.stages.judgment_cards.total = kdnaData.cards.length;
151
+ project.stages.judgment_cards.status = 'in_progress';
152
+ }
153
+ fs.mkdirSync(abs, { recursive: true });
154
+ writeProject(path.join(abs, 'studio.project.json'), project);
155
+ console.log(`Created Studio project (source_mode: ${sourceMode}, imported: ${kdnaData.cards.length} cards): ${abs}`);
156
+ return;
157
+ }
158
+ } else if (fromFolder) {
159
+ sourceMode = 'source_folder';
160
+ importFromFolder(fromFolder, abs, name);
161
+ return;
162
+ }
163
+
117
164
  const project = projectApi.createProject(name, 'domain', {
118
165
  author: { name: option(args, '--author-name', ''), id: option(args, '--author-id', '') },
166
+ sourceMode,
167
+ creatorIdentity,
168
+ lineage,
169
+ sourcePath: fromFolder ? path.resolve(fromFolder) : null,
119
170
  });
171
+
172
+ if (fromKdna || fromFolder) {
173
+ fs.mkdirSync(abs, { recursive: true });
174
+ writeProject(path.join(abs, 'studio.project.json'), project);
175
+ if (fromFolder) {
176
+ // Cards were already added by importFromFolder
177
+ }
178
+ console.log(`Created Studio project (source_mode: ${sourceMode}): ${abs}`);
179
+ return;
180
+ }
181
+
120
182
  fs.mkdirSync(abs, { recursive: true });
121
183
  writeProject(path.join(abs, 'studio.project.json'), project);
122
184
  console.log(`Created Studio project: ${abs}`);
123
185
  }
124
186
 
187
+ /**
188
+ * Import cards from an existing .kdna asset into a new Studio project.
189
+ * Cards are imported as draft — they do NOT inherit trust from the source.
190
+ */
191
+ function importFromKdna(kdnaPath) {
192
+ const absKdna = path.resolve(kdnaPath);
193
+ if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
194
+ if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
195
+
196
+ const zipBuf = fs.readFileSync(absKdna);
197
+ const entries = readZipEntries(zipBuf);
198
+ if (!entries.has('kdna.json')) fail('Not a valid .kdna asset: missing kdna.json');
199
+
200
+ const manifest = JSON.parse(entries.get('kdna.json').toString());
201
+ const lineage = {
202
+ type: 'fork',
203
+ parent_name: manifest.name || null,
204
+ parent_asset_uid: manifest.asset_uid || null,
205
+ parent_version: manifest.version || null,
206
+ parent_asset_digest: manifest.content_digest || null,
207
+ };
208
+
209
+ // Extract cards from KDNA files
210
+ const importedCards = [];
211
+ if (entries.has('KDNA_Core.json')) {
212
+ const core = JSON.parse(entries.get('KDNA_Core.json').toString());
213
+ for (const ax of (core.axioms || [])) {
214
+ const fields = {};
215
+ for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk']) {
216
+ if (k in ax) fields[k] = ax[k];
217
+ }
218
+ importedCards.push(cardApi.createCard('axiom', fields));
219
+ }
220
+ for (const ont of (core.ontology || [])) {
221
+ importedCards.push(cardApi.createCard('ontology', {
222
+ one_sentence: ont.one_sentence || ont.essence || '',
223
+ essence: ont.essence || '', boundary: ont.boundary || '',
224
+ trigger_signal: ont.trigger_signal || '',
225
+ }));
226
+ }
227
+ for (const b of (core.boundaries || [])) {
228
+ importedCards.push(cardApi.createCard('boundary', {
229
+ scope: b.scope || '', out_of_scope: b.out_of_scope || '',
230
+ acceptable_exceptions: b.acceptable_exceptions || [],
231
+ }));
232
+ }
233
+ }
234
+ if (entries.has('KDNA_Patterns.json')) {
235
+ const pat = JSON.parse(entries.get('KDNA_Patterns.json').toString());
236
+ for (const ms of (pat.misunderstandings || [])) {
237
+ importedCards.push(cardApi.createCard('misunderstanding', {
238
+ wrong: ms.wrong || '', correct: ms.correct || '',
239
+ key_distinction: ms.key_distinction || '', why: ms.why || '',
240
+ }));
241
+ }
242
+ for (const sc of (pat.self_check || [])) {
243
+ const q = typeof sc === 'string' ? sc : sc.question || '';
244
+ importedCards.push(cardApi.createCard('self_check', { question: q }));
245
+ }
246
+ }
247
+
248
+ return { lineage, cards: importedCards };
249
+ }
250
+
251
+ /**
252
+ * Import cards from a legacy source folder into a new Studio project.
253
+ * Reads KDNA_*.json files and converts entries to draft judgment cards.
254
+ * Outputs a schema audit report.
255
+ */
256
+ function importFromFolder(sourceDir, projectDir, projectName) {
257
+ const absSource = path.resolve(sourceDir);
258
+ if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
259
+ if (!fs.statSync(absSource).isDirectory()) fail('--from-folder requires a directory');
260
+
261
+ const audit = { filesFound: [], cardsImported: 0, missingFields: [], schemaWarnings: [] };
262
+ const importedCards = [];
263
+
264
+ function loadJson(filename) {
265
+ const p = path.join(absSource, filename);
266
+ if (!fs.existsSync(p)) return null;
267
+ audit.filesFound.push(filename);
268
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
269
+ catch { audit.schemaWarnings.push(`${filename}: invalid JSON`); return null; }
270
+ }
271
+
272
+ const core = loadJson('KDNA_Core.json');
273
+ const patterns = loadJson('KDNA_Patterns.json');
274
+ const scenarios = loadJson('KDNA_Scenarios.json');
275
+ const cases = loadJson('KDNA_Cases.json');
276
+ const reasoning = loadJson('KDNA_Reasoning.json');
277
+ const evolution = loadJson('KDNA_Evolution.json');
278
+
279
+ if (core) {
280
+ for (const ax of (core.axioms || [])) {
281
+ const fields = {};
282
+ for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk']) {
283
+ if (key in ax) fields[key] = ax[key];
284
+ else audit.missingFields.push(`axiom.${ax.id || '?'}.${key}`);
285
+ }
286
+ importedCards.push(cardApi.createCard('axiom', fields));
287
+ }
288
+ for (const ont of (core.ontology || [])) {
289
+ importedCards.push(cardApi.createCard('ontology', {
290
+ one_sentence: ont.one_sentence || ont.essence || '',
291
+ essence: ont.essence || '', boundary: ont.boundary || '',
292
+ trigger_signal: ont.trigger_signal || '',
293
+ }));
294
+ }
295
+ for (const b of (core.boundaries || [])) {
296
+ importedCards.push(cardApi.createCard('boundary', {
297
+ scope: b.scope || '', out_of_scope: b.out_of_scope || '',
298
+ acceptable_exceptions: b.acceptable_exceptions || [],
299
+ }));
300
+ }
301
+ for (const r of (core.risks || core.risk_model || [])) {
302
+ importedCards.push(cardApi.createCard('risk', r.fields || r));
303
+ }
304
+ }
305
+
306
+ if (patterns) {
307
+ for (const ms of (patterns.misunderstandings || [])) {
308
+ importedCards.push(cardApi.createCard('misunderstanding', {
309
+ wrong: ms.wrong || '', correct: ms.correct || '',
310
+ key_distinction: ms.key_distinction || '', why: ms.why || '',
311
+ }));
312
+ }
313
+ for (const sc of (patterns.self_check || [])) {
314
+ const q = typeof sc === 'string' ? sc : sc.question || '';
315
+ importedCards.push(cardApi.createCard('self_check', { question: q }));
316
+ }
317
+ }
318
+
319
+ audit.cardsImported = importedCards.length;
320
+
321
+ const project = projectApi.createProject(projectName, 'domain', {
322
+ sourceMode: 'source_folder',
323
+ sourcePath: absSource,
324
+ creatorIdentity: null,
325
+ lineage: { type: 'migrated', parent_name: null, parent_asset_uid: null, parent_version: null, parent_asset_digest: null },
326
+ });
327
+ project.cards = importedCards;
328
+ if (project.stages?.judgment_cards) {
329
+ project.stages.judgment_cards.total = importedCards.length;
330
+ project.stages.judgment_cards.status = 'in_progress';
331
+ }
332
+
333
+ fs.mkdirSync(projectDir, { recursive: true });
334
+ writeProject(path.join(projectDir, 'studio.project.json'), project);
335
+ console.log(JSON.stringify({ audit, imported: importedCards.length, source_mode: 'source_folder' }, null, 2));
336
+ }
337
+
338
+ /**
339
+ * Minimal ZIP central directory parser — reads entries into a Map.
340
+ */
341
+ function readZipEntries(buf) {
342
+ const entries = new Map();
343
+ // Find end-of-central-directory record
344
+ let eocdOffset = buf.length - 22;
345
+ while (eocdOffset >= 0) {
346
+ if (buf.readUInt32LE(eocdOffset) === 0x06054b50) break;
347
+ eocdOffset--;
348
+ }
349
+ if (eocdOffset < 0) throw new Error('Not a valid ZIP file');
350
+
351
+ const centralDirOffset = buf.readUInt32LE(eocdOffset + 16);
352
+ let offset = centralDirOffset;
353
+
354
+ while (offset < eocdOffset) {
355
+ const sig = buf.readUInt32LE(offset);
356
+ if (sig !== 0x02014b50) break;
357
+ const nameLen = buf.readUInt16LE(offset + 28);
358
+ const extraLen = buf.readUInt16LE(offset + 30);
359
+ const commentLen = buf.readUInt16LE(offset + 32);
360
+ const compMethod = buf.readUInt16LE(offset + 10);
361
+ const compSize = buf.readUInt32LE(offset + 20);
362
+ const uncompSize = buf.readUInt32LE(offset + 24);
363
+ const localOffset = buf.readUInt32LE(offset + 42);
364
+ const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen);
365
+
366
+ // Read local file header to get data
367
+ const localNameLen = buf.readUInt16LE(localOffset + 26);
368
+ const localExtraLen = buf.readUInt16LE(localOffset + 28);
369
+ const dataStart = localOffset + 30 + localNameLen + localExtraLen;
370
+
371
+ if (compMethod === 0) {
372
+ entries.set(name, buf.subarray(dataStart, dataStart + uncompSize));
373
+ } else if (compMethod === 8) {
374
+ entries.set(name, zlib.inflateRawSync(buf.subarray(dataStart, dataStart + compSize)));
375
+ }
376
+
377
+ offset += 46 + nameLen + extraLen + commentLen;
378
+ }
379
+
380
+ return entries;
381
+ }
382
+
125
383
  function cmdImport(args) {
126
384
  const [projectInput, source] = args;
127
385
  if (!projectInput || !source) fail('Usage: kdna-studio import <project> <source-file>');
@@ -163,7 +421,7 @@ function cmdCard(args) {
163
421
  const projectInput = args[1];
164
422
  const cardId = args[2];
165
423
  if (!projectInput || !cardId) {
166
- fail('Usage: kdna-studio card approve <project> <card-id> --by <id> --statement <text>');
424
+ fail('Usage: kdna-studio card approve <project> <card-id> --by <id> --statement <text> [--sign]');
167
425
  }
168
426
  const by = option(args, '--by');
169
427
  const statement = option(args, '--statement');
@@ -173,17 +431,35 @@ function cmdCard(args) {
173
431
  if (idx < 0) fail(`Card not found: ${cardId}`);
174
432
  let card = project.cards[idx];
175
433
  if (card.status === 'draft') card = cardApi.transitionCard(card, 'revised', { by });
176
- project.cards[idx] = cardApi.lockCard(card, {
434
+
435
+ const lockPayload = {
177
436
  by,
178
437
  statement,
179
438
  checked: { applies_when: true, does_not_apply_when: true, failure_risk: true },
180
- });
439
+ };
440
+
441
+ // Sign Human Lock with creator identity if --sign is passed
442
+ if (args.includes('--sign')) {
443
+ const identity = creatorApi.loadIdentity();
444
+ if (!identity) fail('No creator identity. Run: kdna-studio identity init --name "Your Name"', EXIT.TRUST_FAILED);
445
+ lockPayload.creator_id = identity.creator_id;
446
+ try {
447
+ lockPayload.signature = creatorApi.signHumanLock(
448
+ cardId, statement, cardApi.cardJudgmentFingerprint(card),
449
+ );
450
+ } catch (e) {
451
+ fail(`Failed to sign Human Lock: ${e.message}`, EXIT.TRUST_FAILED);
452
+ }
453
+ }
454
+
455
+ project.cards[idx] = cardApi.lockCard(card, lockPayload);
181
456
  if (project.stages?.judgment_cards) {
182
457
  project.stages.judgment_cards.locked = project.cards.filter((c) => c.locked).length;
183
458
  project.stages.judgment_cards.total = project.cards.length;
184
459
  }
185
460
  writeProject(projectPath, project);
186
- console.log(`Approved and Human Locked: ${cardId}`);
461
+ const signed = lockPayload.signature ? ' (signed)' : '';
462
+ console.log(`Approved and Human Locked: ${cardId}${signed}`);
187
463
  return;
188
464
  }
189
465
  fail('Usage: kdna-studio card <list|add|approve> ...');
@@ -320,6 +596,7 @@ function manifestForSigning(manifest) {
320
596
  delete copy.signature;
321
597
  delete copy.asset_digest;
322
598
  delete copy.container_sha256;
599
+ delete copy.content_digest;
323
600
  return copy;
324
601
  }
325
602
 
@@ -372,21 +649,48 @@ function cmdExport(args) {
372
649
  const files = { ...result.files };
373
650
  files['README.md'] = compileApi.generateReadme(project);
374
651
  files.LICENSE = project.license?.type || 'UNSPECIFIED';
652
+ files.mimetype = 'application/vnd.aikdna.kdna+zip';
653
+
654
+ // Recompute content_digest after README.md / LICENSE / mimetype are added
655
+ // so that manifest, receipt, and provenance report all agree with the final ZIP contents.
656
+ const finalDigest = compileApi.computeContentDigest(files);
657
+ result.identity.content_digest = finalDigest;
658
+
659
+ // Update kdna.json manifest with final digest
660
+ const manifest = JSON.parse(files['kdna.json']);
661
+ manifest.content_digest = finalDigest;
662
+ if (manifest.authoring) manifest.authoring.content_digest = finalDigest;
663
+ files['kdna.json'] = JSON.stringify(manifest, null, 2);
664
+
665
+ // Update build-receipt.json
666
+ let receiptData = JSON.parse(files['build-receipt.json']);
667
+ receiptData.content_digest = finalDigest;
668
+ files['build-receipt.json'] = JSON.stringify(receiptData, null, 2);
669
+
670
+ // Update provenance report
671
+ const provenance = JSON.parse(files['reports/provenance-report.json']);
672
+ provenance.content_digest = finalDigest;
673
+ provenance.content_fingerprint = finalDigest;
674
+ files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
675
+
375
676
  if (args.includes('--sign')) applySignature(files);
376
677
 
377
- const entries = [['mimetype', 'application/vnd.aikdna.kdna+zip']];
378
- for (const name of Object.keys(files).sort()) entries.push([name, files[name]]);
678
+ const entries = [['mimetype', files.mimetype]];
679
+ for (const name of Object.keys(files).filter(k => k !== 'mimetype').sort()) {
680
+ entries.push([name, files[name]]);
681
+ }
379
682
  const zip = buildZip(entries);
380
683
  const outPath = path.resolve(out);
381
684
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
382
685
  fs.writeFileSync(outPath, zip);
383
686
 
384
687
  const assetDigest = `sha256:${crypto.createHash('sha256').update(zip).digest('hex')}`;
385
- const receipt = JSON.parse(files['build-receipt.json']);
386
- receipt.asset_path = outPath;
387
- receipt.asset_digest = assetDigest;
388
- receipt.signature_status = args.includes('--sign') ? 'signed' : 'unsigned';
389
- fs.writeFileSync(path.join(path.dirname(outPath), 'build-receipt.json'), JSON.stringify(receipt, null, 2));
688
+ receiptData = JSON.parse(files['build-receipt.json']);
689
+ receiptData.asset_path = outPath;
690
+ receiptData.asset_digest = assetDigest;
691
+ receiptData.signature_status = args.includes('--sign') ? 'signed' : 'unsigned';
692
+ fs.writeFileSync(path.join(path.dirname(outPath), 'build-receipt.json'), JSON.stringify(receiptData, null, 2));
693
+ fs.writeFileSync(path.join(path.dirname(outPath), 'kdna.json'), files['kdna.json']);
390
694
  fs.writeFileSync(path.join(path.dirname(outPath), 'provenance-report.json'), files['reports/provenance-report.json']);
391
695
  fs.writeFileSync(path.join(path.dirname(outPath), 'quality-gate-report.json'), files['reports/quality-gate-report.json']);
392
696
  fs.writeFileSync(path.join(path.dirname(outPath), 'human-lock-report.json'), files['reports/human-lock-report.json']);
@@ -396,6 +700,37 @@ function cmdExport(args) {
396
700
  console.log(`Build ID: ${result.identity.build_id}`);
397
701
  }
398
702
 
703
+ function cmdIdentity(args) {
704
+ const sub = args[0];
705
+ if (sub === 'init') {
706
+ const name = option(args, '--name', process.env.USER || process.env.USERNAME || '');
707
+ try {
708
+ const identity = creatorApi.initIdentity(name);
709
+ console.log(`Creator identity initialized:`);
710
+ console.log(` creator_id: ${identity.creator_id}`);
711
+ console.log(` display_name: ${identity.display_name}`);
712
+ console.log(` identity_dir: ${identity.identity_dir}`);
713
+ console.log(` public_key saved: ${identity.public_key_path}`);
714
+ console.log(`\nBack up your private key at: ${creatorApi.privateKeyPath()}`);
715
+ } catch (err) {
716
+ fail(`Identity already exists. Use 'kdna-studio identity show' to view. ${err.message}`);
717
+ }
718
+ return;
719
+ }
720
+ if (sub === 'show') {
721
+ const identity = creatorApi.loadIdentity();
722
+ if (!identity) fail('No identity found. Run: kdna-studio identity init --name "Your Name"');
723
+ console.log(JSON.stringify({
724
+ creator_id: identity.creator_id,
725
+ display_name: identity.display_name,
726
+ verified: identity.verified || false,
727
+ created_at: identity.created_at,
728
+ }, null, 2));
729
+ return;
730
+ }
731
+ fail('Usage: kdna-studio identity <init|show>');
732
+ }
733
+
399
734
  function cmdReport(args) {
400
735
  const { project } = readProject(args[0]);
401
736
  const readiness = quality.computeReadiness(project);
@@ -418,6 +753,7 @@ try {
418
753
  else if (cmd === 'lock') cmdLock(args.slice(1));
419
754
  else if (cmd === 'compile') cmdCompile(args.slice(1));
420
755
  else if (cmd === 'export') cmdExport(args.slice(1));
756
+ else if (cmd === 'identity') cmdIdentity(args.slice(1));
421
757
  else if (cmd === 'report') cmdReport(args.slice(1));
422
758
  else {
423
759
  usage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Official KDNA Studio command-line authoring entry for creating, locking, compiling, and exporting trusted .kdna assets.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -37,6 +37,6 @@
37
37
  "node": ">=18"
38
38
  },
39
39
  "dependencies": {
40
- "@aikdna/kdna-studio-core": "^1.3.3"
40
+ "@aikdna/kdna-studio-core": "^1.4.0"
41
41
  }
42
42
  }
package/tests/cli.test.js CHANGED
@@ -14,6 +14,7 @@ function run(args, options = {}) {
14
14
  env: {
15
15
  ...process.env,
16
16
  KDNA_IDENTITY_DIR: path.join(options.tmp || os.tmpdir(), 'no-identity'),
17
+ ...(options.env || {}),
17
18
  },
18
19
  encoding: 'utf8',
19
20
  });
@@ -43,6 +44,10 @@ function createLockedProject(t) {
43
44
  '--field',
44
45
  'one_sentence=Prefer specific evidence over broad claims',
45
46
  '--field',
47
+ 'full_statement=When evaluating a claim the agent must weigh the specificity of the supporting evidence. A narrow claim backed by concrete data should be preferred over a broad claim supported only by general principles.',
48
+ '--field',
49
+ 'why=Without this axiom the agent may accept broad plausible-sounding claims as equally credible as narrow evidence-backed ones leading to false balance.',
50
+ '--field',
46
51
  'applies_when=["reviewing content"]',
47
52
  '--field',
48
53
  'does_not_apply_when=["pure formatting"]',
@@ -146,6 +151,11 @@ test('runs the trusted authoring workflow through compile and export', (t) => {
146
151
  assert.equal(manifest.authoring.human_confirmed, true);
147
152
  assert.ok(manifest.authoring.human_lock_count >= 1);
148
153
 
154
+ // Content digest must be present and non-trivial
155
+ assert.ok(manifest.content_digest, 'manifest must have content_digest');
156
+ assert.match(manifest.content_digest, /^sha256:[0-9a-f]{64}$/);
157
+ assert.ok(manifest.authoring.content_digest, 'authoring must have content_digest');
158
+
149
159
  const outFile = path.join(tmp, 'dist', 'example.kdna');
150
160
  result = run(['export', projectDir, '--out', outFile], { tmp });
151
161
  assert.equal(result.status, 0, result.stderr);
@@ -155,6 +165,19 @@ test('runs the trusted authoring workflow through compile and export', (t) => {
155
165
  const receipt = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'build-receipt.json'), 'utf8'));
156
166
  assert.equal(receipt.signature_status, 'unsigned');
157
167
  assert.match(receipt.asset_digest, /^sha256:/);
168
+
169
+ // ── Digest chain consistency ──────────────────────────────────
170
+ // All three sources MUST agree on content_digest.
171
+ const exportedManifest = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'kdna.json'), 'utf8'));
172
+ const exportedProvenance = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'provenance-report.json'), 'utf8'));
173
+ assert.equal(exportedManifest.content_digest, receipt.content_digest,
174
+ 'manifest.content_digest !== build-receipt.content_digest');
175
+ assert.equal(exportedProvenance.content_digest, receipt.content_digest,
176
+ 'provenance-report.content_digest !== build-receipt.content_digest');
177
+ assert.ok(exportedManifest.content_digest.startsWith('sha256:'),
178
+ 'content_digest must be sha256: prefix');
179
+ assert.equal(exportedManifest.content_digest.length, 71,
180
+ 'content_digest must be sha256: + 64 hex chars');
158
181
  });
159
182
 
160
183
  test('refuses signing without runtime identity keys', (t) => {
@@ -164,3 +187,262 @@ test('refuses signing without runtime identity keys', (t) => {
164
187
  assert.equal(result.status, 5);
165
188
  assert.match(result.stderr, /Signing requires KDNA identity keys/);
166
189
  });
190
+
191
+ // ── Identity ────────────────────────────────────────────────────────
192
+
193
+ test('identity init creates local creator identity', (t) => {
194
+ const tmp = tmpDir();
195
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
196
+ const identityDir = path.join(tmp, 'identity');
197
+
198
+ const result = run(['identity', 'init', '--name', 'Test Creator'], {
199
+ tmp,
200
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
201
+ });
202
+ assert.equal(result.status, 0, result.stderr);
203
+ assert.match(result.stdout, /Creator identity initialized/);
204
+ assert.match(result.stdout, /kdna:creator:ed25519:/);
205
+
206
+ // Verify files were created
207
+ assert.ok(fs.existsSync(path.join(identityDir, 'kdna.key')));
208
+ assert.ok(fs.existsSync(path.join(identityDir, 'kdna.pub')));
209
+ assert.ok(fs.existsSync(path.join(identityDir, 'creator.json')));
210
+ const creatorJson = JSON.parse(fs.readFileSync(path.join(identityDir, 'creator.json'), 'utf8'));
211
+ assert.match(creatorJson.creator_id, /^kdna:creator:ed25519:/);
212
+ assert.equal(creatorJson.display_name, 'Test Creator');
213
+ assert.equal(creatorJson.verified, false);
214
+
215
+ // Double init should fail
216
+ const result2 = run(['identity', 'init', '--name', 'Duplicate'], {
217
+ tmp,
218
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
219
+ });
220
+ assert.equal(result2.status, 2);
221
+ assert.match(result2.stderr, /already exists/);
222
+ });
223
+
224
+ test('identity show displays current identity', (t) => {
225
+ const tmp = tmpDir();
226
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
227
+ const identityDir = path.join(tmp, 'identity');
228
+
229
+ // Init first
230
+ run(['identity', 'init', '--name', 'Test User'], {
231
+ tmp,
232
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
233
+ });
234
+
235
+ const result = run(['identity', 'show'], {
236
+ tmp,
237
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
238
+ });
239
+ assert.equal(result.status, 0, result.stderr);
240
+ const info = JSON.parse(result.stdout);
241
+ assert.match(info.creator_id, /^kdna:creator:ed25519:/);
242
+ assert.equal(info.display_name, 'Test User');
243
+ });
244
+
245
+ test('identity show fails when not initialized', (t) => {
246
+ const tmp = tmpDir();
247
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
248
+
249
+ const result = run(['identity', 'show'], {
250
+ tmp,
251
+ env: { ...process.env, KDNA_IDENTITY_DIR: path.join(tmp, 'no-such') },
252
+ });
253
+ assert.equal(result.status, 2);
254
+ assert.match(result.stderr, /No identity found/);
255
+ });
256
+
257
+ // ── Create from folder ──────────────────────────────────────────────
258
+
259
+ test('create --from-folder imports legacy JSON source, outputs audit', (t) => {
260
+ const tmp = tmpDir();
261
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
262
+
263
+ const sourceDir = path.join(tmp, 'legacy-source');
264
+ fs.mkdirSync(sourceDir, { recursive: true });
265
+
266
+ // Create legacy KDNA files
267
+ const core = {
268
+ meta: { domain: 'test', version: '0.1.0', created: '2026-01-01', purpose: 'test', load_condition: 'always' },
269
+ axioms: [{
270
+ id: 'ax_legacy',
271
+ one_sentence: 'Test axiom from legacy folder.',
272
+ full_statement: 'A complete testable explanation from a legacy source folder.',
273
+ why: 'Without this legacy axiom, agent judgment defaults to untrained behavior.',
274
+ applies_when: ['testing legacy import'],
275
+ does_not_apply_when: ['production'],
276
+ failure_risk: 'May produce incorrect results from untrained judgment.',
277
+ }],
278
+ ontology: [{ id: 'ont_legacy', one_sentence: 'Legacy concept.', essence: 'Operational meaning.', boundary: 'Not the opposite.', trigger_signal: 'keyword' }],
279
+ boundaries: [{ id: 'bnd_legacy', scope: 'Legacy scope.', out_of_scope: 'Not covered by legacy.', acceptable_exceptions: [] }],
280
+ stances: [],
281
+ };
282
+ fs.writeFileSync(path.join(sourceDir, 'KDNA_Core.json'), JSON.stringify(core));
283
+ fs.writeFileSync(path.join(sourceDir, 'KDNA_Patterns.json'), JSON.stringify({
284
+ meta: { domain: 'test', version: '0.1.0', created: '2026-01-01', purpose: 'patterns', load_condition: 'always' },
285
+ misunderstandings: [{ id: 'ms_legacy', wrong: 'Legacy wrong belief.', correct: 'Correct understanding.', key_distinction: 'The key distinction between wrong and correct.' }],
286
+ self_check: ['Did I verify the legacy import?'],
287
+ }));
288
+
289
+ const projectDir = path.join(tmp, 'project');
290
+ const result = run(['create', projectDir, '--from-folder', sourceDir, '--name', '@test/legacy-import'], { tmp });
291
+ assert.equal(result.status, 0, result.stderr);
292
+
293
+ const output = JSON.parse(result.stdout);
294
+ assert.ok(output.audit, 'must have audit info');
295
+ assert.ok(output.audit.filesFound.includes('KDNA_Core.json'));
296
+ assert.ok(output.audit.filesFound.includes('KDNA_Patterns.json'));
297
+ assert.ok(output.imported > 0, `imported cards: ${output.imported}`);
298
+ assert.equal(output.source_mode, 'source_folder');
299
+
300
+ // Read project and verify cards were imported
301
+ const projectPath = path.join(projectDir, 'studio.project.json');
302
+ assert.ok(fs.existsSync(projectPath));
303
+ const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
304
+ assert.equal(project.source_mode, 'source_folder');
305
+ assert.ok(project.cards.length > 0);
306
+ assert.equal(project.lineage.type, 'migrated');
307
+ });
308
+
309
+ // ── Create from KDNA ────────────────────────────────────────────────
310
+
311
+ test('create --from-kdna forks an existing .kdna asset', (t) => {
312
+ const tmp = tmpDir();
313
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
314
+
315
+ // First create and export a "parent" project
316
+ const parentTmp = tmpDir();
317
+ t.after(() => fs.rmSync(parentTmp, { recursive: true, force: true }));
318
+ const { projectDir: parentDir } = createLockedProject(t);
319
+ const parentDist = path.join(parentTmp, 'dist');
320
+ fs.mkdirSync(parentDist, { recursive: true });
321
+ const parentKdna = path.join(parentDist, 'parent.kdna');
322
+ run(['export', parentDir, '--out', parentKdna], { tmp: parentTmp });
323
+
324
+ // Now fork from that .kdna
325
+ const projectDir = path.join(tmp, 'forked-project');
326
+ const result = run(['create', projectDir, '--from-kdna', parentKdna, '--name', '@test/fork'], { tmp });
327
+ assert.equal(result.status, 0, result.stderr);
328
+ assert.match(result.stdout, /Created Studio project/);
329
+ assert.match(result.stdout, /kdna_asset/);
330
+
331
+ const projectPath = path.join(projectDir, 'studio.project.json');
332
+ assert.ok(fs.existsSync(projectPath));
333
+ const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
334
+ assert.equal(project.source_mode, 'kdna_asset');
335
+ assert.ok(project.lineage, 'must have lineage');
336
+ assert.equal(project.lineage.type, 'fork');
337
+ assert.ok(project.lineage.parent_name, 'must record parent name');
338
+ });
339
+
340
+ // ── Card approve --sign ─────────────────────────────────────────────
341
+
342
+ test('card approve --sign binds Human Lock to creator identity', (t) => {
343
+ const tmp = tmpDir();
344
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
345
+ const identityDir = path.join(tmp, 'identity');
346
+
347
+ // Initialize creator identity
348
+ const init = run(['identity', 'init', '--name', 'Signer'], {
349
+ tmp,
350
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
351
+ });
352
+ assert.equal(init.status, 0, init.stderr);
353
+
354
+ // Create project with an axiom
355
+ const projectDir = path.join(tmp, 'project');
356
+ let result = run(['create', projectDir, '--name', '@test/signed'], { tmp });
357
+ assert.equal(result.status, 0, result.stderr);
358
+
359
+ result = run([
360
+ 'card', 'add', projectDir, 'axiom',
361
+ '--field', 'one_sentence=Prefer specific evidence over broad claims',
362
+ '--field', 'full_statement=When evaluating a claim the agent must weigh the specificity of the supporting evidence. A narrow claim backed by concrete data should be preferred.',
363
+ '--field', 'why=Without this axiom the agent may accept broad plausible-sounding claims as equally credible as narrow evidence-backed ones.',
364
+ '--field', 'applies_when=["reviewing content"]',
365
+ '--field', 'does_not_apply_when=["pure formatting"]',
366
+ '--field', 'failure_risk=generic advice',
367
+ ], { tmp });
368
+ assert.equal(result.status, 0, result.stderr);
369
+ const cardId = result.stdout.match(/Added card: (\S+)/)?.[1];
370
+
371
+ // Approve with --sign
372
+ result = run([
373
+ 'card', 'approve', projectDir, cardId,
374
+ '--by', 'signer',
375
+ '--statement', 'I confirm this judgment with my creator identity.',
376
+ '--sign',
377
+ ], {
378
+ tmp,
379
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
380
+ });
381
+ assert.equal(result.status, 0, result.stderr);
382
+ assert.match(result.stdout, /signed/);
383
+
384
+ // Verify human_lock has creator_id and signature
385
+ const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
386
+ const lockedCard = project.cards.find(c => c.id === cardId);
387
+ assert.ok(lockedCard.human_lock, 'must have human_lock');
388
+ assert.ok(lockedCard.human_lock.creator_id, 'must have creator_id');
389
+ assert.match(lockedCard.human_lock.creator_id, /^kdna:creator:ed25519:/);
390
+ assert.ok(lockedCard.human_lock.signature, 'must have signature');
391
+ assert.match(lockedCard.human_lock.signature, /^ed25519:/);
392
+ });
393
+
394
+ // ── Blank create with default source_mode ───────────────────────────
395
+
396
+ test('create (blank) sets source_mode=blank by default', (t) => {
397
+ const tmp = tmpDir();
398
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
399
+ const projectDir = path.join(tmp, 'project');
400
+
401
+ const result = run(['create', projectDir, '--name', '@test/blank'], { tmp });
402
+ assert.equal(result.status, 0, result.stderr);
403
+
404
+ const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
405
+ assert.equal(project.source_mode, 'blank');
406
+ });
407
+
408
+ // ── export includes creator/lineage in manifest ─────────────────────
409
+
410
+ test('export includes creator and lineage in kdna.json', (t) => {
411
+ const tmp = tmpDir();
412
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
413
+ const identityDir = path.join(tmp, 'identity');
414
+
415
+ // Init identity
416
+ run(['identity', 'init', '--name', 'Exporter'], {
417
+ tmp,
418
+ env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
419
+ });
420
+
421
+ // Create, add axiom, approve
422
+ const projectDir = path.join(tmp, 'project');
423
+ run(['create', projectDir, '--name', '@test/exported'], { tmp });
424
+ run([
425
+ 'card', 'add', projectDir, 'axiom',
426
+ '--field', 'one_sentence=Test axiom for lineage.',
427
+ '--field', 'full_statement=This is a complete testable explanation for the agent to apply in judgment scenarios.',
428
+ '--field', 'why=Without this axiom the agent would make incorrect judgment calls.',
429
+ '--field', 'applies_when=["testing"]',
430
+ '--field', 'does_not_apply_when=["not testing"]',
431
+ '--field', 'failure_risk=test may pass incorrectly',
432
+ ], { tmp });
433
+ let result = run(['card', 'list', projectDir], { tmp });
434
+ const cardId = result.stdout.match(/(\S+)\s+axiom/)?.[1];
435
+ run(['card', 'approve', projectDir, cardId, '--by', 'tester', '--statement', 'Verified.'], { tmp });
436
+ run(['lock', projectDir], { tmp });
437
+
438
+ const distDir = path.join(tmp, 'dist');
439
+ const outFile = path.join(distDir, 'exported.kdna');
440
+ result = run(['export', projectDir, '--out', outFile], { tmp });
441
+ assert.equal(result.status, 0, result.stderr);
442
+
443
+ // Check exported kdna.json has new fields
444
+ const exportedManifest = JSON.parse(fs.readFileSync(path.join(distDir, 'kdna.json'), 'utf8'));
445
+ assert.equal(exportedManifest.authoring.source_mode, 'blank');
446
+ assert.ok(exportedManifest.lineage, 'must have lineage');
447
+ assert.equal(exportedManifest.lineage.type, 'original');
448
+ });