@aikdna/kdna-studio-cli 0.8.2 → 0.8.4

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.
@@ -99,6 +99,40 @@ function option(args, name, fallback = null) {
99
99
  return value;
100
100
  }
101
101
 
102
+ /**
103
+ * Resolve a passphrase from CLI flags, environment, or stdin.
104
+ *
105
+ * SECURITY: passing a passphrase as `--passphrase <value>` exposes it in
106
+ * `ps aux` output and shell history. The recommended path is one of:
107
+ * 1. --passphrase-stdin — read from stdin (pipe-friendly, no TTY hang)
108
+ * 2. KDNA_PASSPHRASE env var (less secure but no process-list leak)
109
+ * 3. --passphrase <pw> — fallback only; prints a warning
110
+ *
111
+ * Returns the passphrase string, or null if none was provided.
112
+ */
113
+ function resolvePassphrase(args) {
114
+ if (args.includes('--passphrase-stdin')) {
115
+ if (process.stdin.isTTY) {
116
+ fail(
117
+ '--passphrase-stdin requires the passphrase to be piped in on stdin.\n' +
118
+ 'Example: read -s PW && echo "$PW" | kdna-studio export ... --sign --passphrase-stdin'
119
+ );
120
+ }
121
+ try {
122
+ return fs.readFileSync(0, 'utf8').trim();
123
+ } catch (e) {
124
+ fail(`Could not read passphrase from stdin: ${e.message}`);
125
+ }
126
+ }
127
+ if (process.env.KDNA_PASSPHRASE) return process.env.KDNA_PASSPHRASE;
128
+ const fromFlag = option(args, '--passphrase');
129
+ if (fromFlag) {
130
+ console.error('Warning: --passphrase <value> exposes the secret in `ps aux` and shell history. Prefer --passphrase-stdin or KDNA_PASSPHRASE env var.');
131
+ return fromFlag;
132
+ }
133
+ return null;
134
+ }
135
+
102
136
  function resolveApiKey(args) {
103
137
  // 1. KDNA_API_KEY environment variable (preferred)
104
138
  if (process.env.KDNA_API_KEY) return process.env.KDNA_API_KEY;
@@ -287,10 +321,27 @@ function cardsFromV1Payload(payload) {
287
321
  failure_risk: ax.failure_risk || '',
288
322
  }, ax.id || null);
289
323
  }
324
+ for (const ont of (payload.core?.ontology || [])) {
325
+ pushImportedCard(cards, 'ontology', ont, ont.id || null);
326
+ }
327
+ for (const fw of (payload.core?.frameworks || [])) {
328
+ pushImportedCard(cards, 'framework', fw, fw.id || null);
329
+ }
330
+ for (const stance of (payload.core?.stances || [])) {
331
+ pushImportedCard(cards, 'stance', stance, stance.id || null);
332
+ }
333
+ for (const aesthetic of (payload.core?.aesthetics || [])) {
334
+ pushImportedCard(cards, 'aesthetic', aesthetic, aesthetic.id || null);
335
+ }
290
336
  for (const boundary of (payload.core?.boundaries || [])) {
291
337
  pushImportedCard(cards, 'boundary', boundary, boundary.id || null);
292
338
  }
293
- for (const risk of (payload.core?.risk_model?.risks || [])) {
339
+ // risk_model may be either an array of risk items (legacy) or
340
+ // `{risks: [...]}` (current). Treat both shapes; ignore everything else
341
+ // rather than crashing with "object is not iterable".
342
+ const riskModel = payload.core?.risk_model;
343
+ const riskItems = Array.isArray(riskModel) ? riskModel : (riskModel?.risks || []);
344
+ for (const risk of riskItems) {
294
345
  pushImportedCard(cards, 'risk', risk, risk.id || null);
295
346
  }
296
347
  for (const pattern of (payload.patterns || [])) {
@@ -304,7 +355,16 @@ function cardsFromV1Payload(payload) {
304
355
  for (const item of (payload.cases || [])) {
305
356
  pushImportedCard(cards, 'case', item, item.id || null);
306
357
  }
307
- for (const selfCheck of (payload.reasoning?.self_checks || [])) {
358
+ // Accept both `self_check` (canonical) and `self_checks` (legacy schema
359
+ // name) so we round-trip assets written by either version of the
360
+ // payload-profile schema. Bug (#53): prior version used
361
+ // `arr || []`, which would crash with "object is not iterable" if
362
+ // the field was a truthy non-array (e.g. a single `{question: ...}`
363
+ // object instead of an array). The fix guards with Array.isArray.
364
+ const rawSelfCheck = payload.reasoning?.self_check
365
+ || payload.reasoning?.self_checks;
366
+ const selfCheckArr = Array.isArray(rawSelfCheck) ? rawSelfCheck : [];
367
+ for (const selfCheck of selfCheckArr) {
308
368
  const fields = typeof selfCheck === 'string' ? { question: selfCheck } : selfCheck;
309
369
  pushImportedCard(cards, 'self_check', fields, fields.id || null);
310
370
  }
@@ -320,7 +380,14 @@ function cardsFromV1Payload(payload) {
320
380
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
321
381
  }
322
382
  for (const stage of (payload.evolution?.stages || [])) {
323
- pushImportedCard(cards, 'evolution_stage', stage, stage.id || null);
383
+ // Distinguish source-authored stages (preserved identity) from
384
+ // audit-log-derived stages (synthesised). The compile path tags
385
+ // source_authored: true; import those verbatim and skip the rest
386
+ // so the importer doesn't double-count what was already in the
387
+ // locked judgment cards.
388
+ if (stage.source_authored === true) {
389
+ pushImportedCard(cards, 'evolution_stage', stage, stage.id || null);
390
+ }
324
391
  }
325
392
  return cards;
326
393
  }
@@ -376,7 +443,7 @@ function cardsFromLegacyPayload(payload) {
376
443
  failure_risk: ms.failure_risk || '',
377
444
  }, ms.id || null);
378
445
  }
379
- for (const sc of (patterns.self_check || [])) {
446
+ for (const sc of (Array.isArray(patterns.self_check) ? patterns.self_check : [])) {
380
447
  const question = typeof sc === 'string' ? sc : sc.question || '';
381
448
  pushImportedCard(cards, 'self_check', { question }, sc.id || null);
382
449
  }
@@ -566,52 +633,65 @@ function importFromKdna(kdnaPath) {
566
633
  parent_asset_digest: manifest.content_digest || manifest.asset_digest || null,
567
634
  };
568
635
 
569
- // Extract cards from KDNA files
636
+ // Extract cards. Modern v1 packages carry `kdna.json` + `payload.kdnab`
637
+ // and use the unified judgment profile. Legacy packages split content
638
+ // across KDNA_*.json files. Prefer payload.kdnab when present — it
639
+ // covers all 8+ card types (axiom / boundary / risk / aesthetic /
640
+ // ontology / misunderstanding / self_check / scenario / case /
641
+ // reasoning / evolution_stage / stance / framework / term / banned_term)
642
+ // via cardsFromPayload / cardsFromV1Payload.
570
643
  const importedCards = [];
571
- if (entries.has('KDNA_Core.json')) {
572
- const core = JSON.parse(entries.get('KDNA_Core.json').toString());
573
- for (const ax of (core.axioms || [])) {
574
- const fields = {};
575
- for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk']) {
576
- if (k in ax) fields[k] = ax[k];
577
- }
578
- importedCards.push(cardApi.createCard('axiom', fields));
579
- }
580
- for (const ont of (core.ontology || [])) {
581
- importedCards.push(cardApi.createCard('ontology', {
582
- one_sentence: ont.one_sentence || ont.essence || '',
583
- essence: ont.essence || '', boundary: ont.boundary || '',
584
- trigger_signal: ont.trigger_signal || '',
585
- }));
586
- }
587
- for (const b of (core.boundaries || [])) {
588
- importedCards.push(cardApi.createCard('boundary', {
589
- scope: b.scope || '', out_of_scope: b.out_of_scope || '',
590
- acceptable_exceptions: b.acceptable_exceptions || [],
591
- }));
592
- }
593
- }
594
- if (entries.has('KDNA_Patterns.json')) {
595
- const pat = JSON.parse(entries.get('KDNA_Patterns.json').toString());
596
- for (const ms of (pat.misunderstandings || [])) {
597
- importedCards.push(cardApi.createCard('misunderstanding', {
598
- wrong: ms.wrong || '', correct: ms.correct || '',
599
- key_distinction: ms.key_distinction || '', why: ms.why || '',
600
- }));
601
- }
602
- for (const sc of (pat.self_check || [])) {
603
- const q = typeof sc === 'string' ? sc : sc.question || '';
604
- importedCards.push(cardApi.createCard('self_check', { question: q }));
605
- }
606
- }
607
-
608
- if (importedCards.length === 0 && entries.has('payload.kdnab')) {
644
+ if (entries.has('payload.kdnab')) {
609
645
  try {
610
646
  const payload = decodePayload(entries.get('payload.kdnab'), manifest);
611
647
  importedCards.push(...cardsFromPayload(payload));
612
648
  } catch (e) {
613
649
  fail(`Could not import cards from payload.kdnab: ${e.message}`);
614
650
  }
651
+ } else if (entries.has('KDNA_Core.json') || entries.has('KDNA_Patterns.json')) {
652
+ // Legacy fallback — only used when payload.kdnab is absent. This path
653
+ // intentionally covers a strict subset of card types and is not the
654
+ // recommended import path.
655
+ if (entries.has('KDNA_Core.json')) {
656
+ const core = JSON.parse(entries.get('KDNA_Core.json').toString());
657
+ for (const ax of (core.axioms || [])) {
658
+ const fields = {};
659
+ for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk']) {
660
+ if (k in ax) fields[k] = ax[k];
661
+ }
662
+ importedCards.push(cardApi.createCard('axiom', fields));
663
+ }
664
+ for (const ont of (core.ontology || [])) {
665
+ importedCards.push(cardApi.createCard('ontology', {
666
+ one_sentence: ont.one_sentence || ont.essence || '',
667
+ essence: ont.essence || '', boundary: ont.boundary || '',
668
+ trigger_signal: ont.trigger_signal || '',
669
+ }));
670
+ }
671
+ for (const b of (core.boundaries || [])) {
672
+ importedCards.push(cardApi.createCard('boundary', {
673
+ scope: b.scope || '', out_of_scope: b.out_of_scope || '',
674
+ acceptable_exceptions: b.acceptable_exceptions || [],
675
+ }));
676
+ }
677
+ }
678
+ if (entries.has('KDNA_Patterns.json')) {
679
+ const pat = JSON.parse(entries.get('KDNA_Patterns.json').toString());
680
+ for (const ms of (pat.misunderstandings || [])) {
681
+ importedCards.push(cardApi.createCard('misunderstanding', {
682
+ wrong: ms.wrong || '', correct: ms.correct || '',
683
+ key_distinction: ms.key_distinction || '', why: ms.why || '',
684
+ }));
685
+ }
686
+ for (const sc of (Array.isArray(pat.self_check) ? pat.self_check : [])) {
687
+ const q = typeof sc === 'string' ? sc : sc.question || '';
688
+ importedCards.push(cardApi.createCard('self_check', { question: q }));
689
+ }
690
+ }
691
+ }
692
+
693
+ if (importedCards.length === 0) {
694
+ fail(`No cards could be extracted from ${absKdna}. Expected payload.kdnab (v1) or KDNA_Core.json + KDNA_Patterns.json (legacy).`);
615
695
  }
616
696
 
617
697
  return { lineage, cards: importedCards, source_manifest: manifest };
@@ -667,6 +747,24 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
667
747
  const reasoning = loadJson('KDNA_Reasoning.json');
668
748
  const evolution = loadJson('KDNA_Evolution.json');
669
749
 
750
+ // If none of the standard KDNA_*.json files were found, that almost
751
+ // always means the user pointed at the wrong directory or the source uses
752
+ // a non-standard filename. Report what was *actually* present so they can
753
+ // rename or move their files.
754
+ const expectedFiles = ['KDNA_Core.json', 'KDNA_Patterns.json', 'KDNA_Scenarios.json',
755
+ 'KDNA_Cases.json', 'KDNA_Reasoning.json', 'KDNA_Evolution.json', 'kdna.json'];
756
+ const loadedCount = audit.filesFound.length;
757
+ if (loadedCount === 0) {
758
+ let dirContents = [];
759
+ try { dirContents = fs.readdirSync(absSource); } catch { /* ignore */ }
760
+ fail(
761
+ `No KDNA source files found in ${absSource}.\n` +
762
+ `Expected one of: ${expectedFiles.join(', ')}\n` +
763
+ `Directory contains ${dirContents.length} entr${dirContents.length === 1 ? 'y' : 'ies'}: ` +
764
+ (dirContents.length > 0 ? dirContents.slice(0, 20).join(', ') + (dirContents.length > 20 ? ', ...' : '') : '(empty)')
765
+ );
766
+ }
767
+
670
768
  if (core) {
671
769
  for (const ax of (core.axioms || [])) {
672
770
  const fields = {};
@@ -689,7 +787,15 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
689
787
  acceptable_exceptions: b.acceptable_exceptions || [],
690
788
  }));
691
789
  }
692
- for (const r of (core.risks || core.risk_model || [])) {
790
+ // Accept risks as a top-level array (`risks`) or wrapped in `risk_model`
791
+ // (either as `{risks: [...]}` or as an array itself).
792
+ let risksSource = core.risks;
793
+ if (!Array.isArray(risksSource)) {
794
+ if (Array.isArray(core.risk_model)) risksSource = core.risk_model;
795
+ else if (Array.isArray(core.risk_model?.risks)) risksSource = core.risk_model.risks;
796
+ else risksSource = [];
797
+ }
798
+ for (const r of risksSource) {
693
799
  importedCards.push(cardApi.createCard('risk', r.fields || r));
694
800
  }
695
801
  // NEW: import stances
@@ -730,7 +836,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
730
836
  key_distinction: ms.key_distinction || '', why: ms.why || '',
731
837
  }));
732
838
  }
733
- for (const sc of (patterns.self_check || [])) {
839
+ for (const sc of (Array.isArray(patterns.self_check) ? patterns.self_check : [])) {
734
840
  const q = typeof sc === 'string' ? sc : sc.question || '';
735
841
  importedCards.push(cardApi.createCard('self_check', { question: q }));
736
842
  }
@@ -756,12 +862,23 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
756
862
  }
757
863
  }
758
864
 
759
- // NEW: import reasoning chains
865
+ // NEW: import reasoning chains. Field names must match what compileReasoning
866
+ // reads (chain/principle/concrete_action/axiom/one_sentence) so the
867
+ // round-trip preserves the source's structure. Bug: prior code stored
868
+ // {name, conclusion, so_what}, none of which the compile path recognised,
869
+ // so the cards were silently rewritten as axiom-derived fallback chains
870
+ // — looking identical to misunderstanding cards and losing the source's
871
+ // identity.
760
872
  if (reasoning && reasoning.reasoning_chains) {
761
873
  for (const rc of reasoning.reasoning_chains) {
762
874
  importedCards.push(cardApi.createCard('reasoning', {
763
- name: rc.name || rc.id || '', conclusion: rc.conclusion || '',
764
- so_what: rc.so_what || '',
875
+ axiom: rc.axiom || '',
876
+ one_sentence: rc.one_sentence || rc.conclusion || '',
877
+ chain: Array.isArray(rc.chain) ? rc.chain : (rc.logic || []),
878
+ principle: rc.principle || rc.name || '',
879
+ concrete_action: rc.concrete_action || rc.so_what || '',
880
+ so_what: rc.so_what || rc.concrete_action || '',
881
+ conclusion: rc.conclusion || '',
765
882
  }));
766
883
  }
767
884
  }
@@ -945,13 +1062,23 @@ function cmdImport(args) {
945
1062
  return;
946
1063
  }
947
1064
  let content;
1065
+ let fd = null;
948
1066
  try {
949
- const fd = fs.openSync(filePath, 'r');
1067
+ fd = fs.openSync(filePath, 'r');
950
1068
  const buf = Buffer.alloc(120000);
951
1069
  const bytesRead = fs.readSync(fd, buf, 0, 120000, 0);
952
- fs.closeSync(fd);
953
1070
  content = buf.toString('utf8', 0, bytesRead);
954
1071
  } catch { console.warn(`Cannot read: ${name}`); skipped++; return; }
1072
+ finally {
1073
+ // Bug (#51): prior version called fs.closeSync only on the
1074
+ // success path, so any readSync failure left the file descriptor
1075
+ // open. Under bulk import (300 files), a single error mid-loop
1076
+ // could exhaust the per-process fd limit and silently break
1077
+ // later imports.
1078
+ if (fd !== null) {
1079
+ try { fs.closeSync(fd); } catch { /* best effort */ }
1080
+ }
1081
+ }
955
1082
 
956
1083
  const evidence = evidenceApi.createEvidenceEntry('text', name, content, filePath);
957
1084
  evidenceApi.addEvidence(project, evidence);
@@ -1158,10 +1285,10 @@ function cmdCard(args) {
1158
1285
  return null;
1159
1286
  }
1160
1287
  lockPayload.creator_id = identity.creator_id;
1161
- const passphrase = option(args, '--passphrase');
1288
+ const passphrase = resolvePassphrase(args);
1162
1289
  try {
1163
1290
  if (identity.encrypted && !passphrase) {
1164
- errors.push(`${card.id}: Private key is encrypted — provide --passphrase`);
1291
+ errors.push(`${card.id}: Private key is encrypted — provide --passphrase-stdin or KDNA_PASSPHRASE`);
1165
1292
  return null;
1166
1293
  }
1167
1294
  lockPayload.signature = creatorApi.signHumanLock(
@@ -1274,6 +1401,12 @@ function cmdMigrate(args) {
1274
1401
  let projectPath = null;
1275
1402
  let project = null;
1276
1403
 
1404
+ // Wrap everything in try / finally so a mid-migrate failure (e.g. missing
1405
+ // critical fields, gate blocked, signing error) still cleans up tmpDir.
1406
+ // Bug: prior code only cleaned up on the v1 and v2 success paths; any
1407
+ // non-v1 export path that threw leaked `/tmp/kdna-migrate-*` entries
1408
+ // indefinitely.
1409
+ try {
1277
1410
  // Step 1: import dev source, or use an existing Studio project directly.
1278
1411
  let creatorIdentity = null;
1279
1412
  try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* proceed without */ }
@@ -1289,6 +1422,13 @@ function cmdMigrate(args) {
1289
1422
  projectPath = path.join(tmpDir, 'studio.project.json');
1290
1423
  }
1291
1424
 
1425
+ // Bug (#54): --allow-incomplete used to be read AFTER the critical
1426
+ // fields check, so a caller asking to bypass the gate was rejected
1427
+ // for the wrong reason. Resolve the flag here so the critical-missing
1428
+ // check honours it (and reports the bypass correctly) instead of
1429
+ // always failing.
1430
+ const allowIncompleteEarly = args.includes('--allow-incomplete');
1431
+
1292
1432
  // Reject if critical judgment fields are missing from axioms
1293
1433
  const criticalMissing = [];
1294
1434
  for (const card of (project.cards || [])) {
@@ -1301,13 +1441,21 @@ function cmdMigrate(args) {
1301
1441
  }
1302
1442
  }
1303
1443
  if (criticalMissing.length > 0) {
1304
- fail(
1305
- `Cannot migrate: ${criticalMissing.length} critical fields missing from axioms.\n` +
1306
- ` These fields are required for domain routing (kdna-loader uses them to\n` +
1307
- ` decide when to load this domain). Add them to your source files first.\n` +
1308
- ` Missing:\n ` + criticalMissing.slice(0, 10).join('\n ') +
1309
- (criticalMissing.length > 10 ? `\n ... and ${criticalMissing.length - 10} more` : '')
1310
- );
1444
+ if (allowIncompleteEarly) {
1445
+ console.warn(
1446
+ `--allow-incomplete: bypassing ${criticalMissing.length} critical axiom-field check(s). ` +
1447
+ `The exported .kdna will load but kdna-loader may not match this domain on those signals.`,
1448
+ );
1449
+ } else {
1450
+ fail(
1451
+ `Cannot migrate: ${criticalMissing.length} critical fields missing from axioms.\n` +
1452
+ ` These fields are required for domain routing (kdna-loader uses them to\n` +
1453
+ ` decide when to load this domain). Add them to your source files first,\n` +
1454
+ ` or pass --allow-incomplete to bypass.\n` +
1455
+ ` Missing:\n ` + criticalMissing.slice(0, 10).join('\n ') +
1456
+ (criticalMissing.length > 10 ? `\n ... and ${criticalMissing.length - 10} more` : '')
1457
+ );
1458
+ }
1311
1459
  }
1312
1460
 
1313
1461
  // Step 2: approve and Human Lock all cards
@@ -1325,7 +1473,7 @@ function cmdMigrate(args) {
1325
1473
  const identity = creatorApi.loadIdentity();
1326
1474
  if (!identity) fail('No creator identity. Run: kdna-studio identity init --name "Your Name"', EXIT.TRUST_FAILED);
1327
1475
  lockPayload.creator_id = identity.creator_id;
1328
- const passphrase = option(args, '--passphrase');
1476
+ const passphrase = resolvePassphrase(args);
1329
1477
  try {
1330
1478
  lockPayload.signature = creatorApi.signHumanLock(
1331
1479
  card.id, statement, cardApi.cardJudgmentFingerprint(card), null, passphrase,
@@ -1383,6 +1531,17 @@ function cmdMigrate(args) {
1383
1531
  // Cleanup temp
1384
1532
  if (tmpDir) {
1385
1533
  try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* best effort */ }
1534
+ tmpDir = null;
1535
+ }
1536
+ } finally {
1537
+ // Single, guaranteed cleanup point for tmpDir even when an earlier
1538
+ // step (critical-missing check, gate block, signing failure) bails
1539
+ // out via `fail()`. `fail()` ultimately calls process.exit, but
1540
+ // if a future caller swallows that or the runtime traps the throw,
1541
+ // we still leave the filesystem clean.
1542
+ if (tmpDir) {
1543
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* best effort */ }
1544
+ }
1386
1545
  }
1387
1546
  }
1388
1547
 
@@ -1545,11 +1704,27 @@ function stableStringify(value) {
1545
1704
  }
1546
1705
 
1547
1706
  function manifestForSigning(manifest) {
1548
- const copy = { ...manifest };
1707
+ // Mirrors the kdna-core implementation in
1708
+ // packages/kdna-core/src/asset-reader.js#manifestForSignature /
1709
+ // manifestForDigest. Anything not stripped here will end up in the
1710
+ // signing payload, and a mismatch between producer (this CLI) and
1711
+ // verifier (kdna-core) will cause the signature check to fail.
1712
+ //
1713
+ // Bug: prior version did not strip `_source` (only set by eval/loader
1714
+ // helpers) and did not recursively strip `authoring.content_digest`,
1715
+ // so a payload with either field would diverge from kdna-core's
1716
+ // canonicalisation and report a false-positive verification failure.
1717
+ const copy = { ...(manifest || {}) };
1549
1718
  delete copy.signature;
1550
1719
  delete copy.asset_digest;
1551
1720
  delete copy.container_sha256;
1552
1721
  delete copy.content_digest;
1722
+ delete copy._source;
1723
+ if (copy.authoring && typeof copy.authoring === 'object') {
1724
+ const auth = { ...copy.authoring };
1725
+ delete auth.content_digest;
1726
+ copy.authoring = auth;
1727
+ }
1553
1728
  return copy;
1554
1729
  }
1555
1730
 
@@ -1579,7 +1754,7 @@ function publicKeyFingerprint(publicKeyPem) {
1579
1754
  function applySignature(files, passphrase = null) {
1580
1755
  const paths = identityPaths();
1581
1756
  if (!fs.existsSync(paths.privateKey) || !fs.existsSync(paths.publicKey)) {
1582
- fail('Signing requires KDNA identity keys. Run: kdna identity init', EXIT.TRUST_FAILED);
1757
+ fail('Signing requires KDNA identity keys. Run: kdna-studio identity init', EXIT.TRUST_FAILED);
1583
1758
  }
1584
1759
  const manifest = JSON.parse(files['kdna.json']);
1585
1760
  const publicKeyPem = fs.readFileSync(paths.publicKey, 'utf8');
@@ -1617,9 +1792,34 @@ function cmdExport(args) {
1617
1792
  // Use args.includes() for --password-stdin since it is a boolean flag
1618
1793
  // (option() rejects it for "missing value").
1619
1794
  const useStdin = args.includes('--password-stdin');
1620
- const password = useStdin
1621
- ? fs.readFileSync(0, 'utf8').trim()
1622
- : option(args, '--password') || option(args, '--passphrase');
1795
+ let password;
1796
+ if (useStdin) {
1797
+ // fs.readFileSync(0) on a TTY waits forever for input and never
1798
+ // returns — the export appears to hang. Refuse up front with a
1799
+ // clear pointer to the right invocation.
1800
+ if (process.stdin.isTTY) {
1801
+ fail(
1802
+ '--password-stdin requires the password to be piped in on stdin.\n' +
1803
+ 'Example: echo "$KDNA_PASSWORD" | kdna-studio export <project> --format v1 --out <file.kdna> --password-stdin\n' +
1804
+ 'If you are running interactively, omit --password-stdin and you will be prompted.'
1805
+ );
1806
+ }
1807
+ try {
1808
+ password = fs.readFileSync(0, 'utf8').trim();
1809
+ } catch (e) {
1810
+ fail(`Could not read password from stdin: ${e.message}`);
1811
+ }
1812
+ } else {
1813
+ // Bug (#55): prior version only consulted --password / --passphrase
1814
+ // here, so a caller who set KDNA_PASSPHRASE for the signing path
1815
+ // (works via resolvePassphrase) found that the v1 encryption
1816
+ // password was silently null. Resolve from the same sources as
1817
+ // the signing path so the two stay in sync.
1818
+ password = process.env.KDNA_PASSPHRASE
1819
+ || process.env.KDNA_PASSWORD
1820
+ || option(args, '--password')
1821
+ || option(args, '--passphrase');
1822
+ }
1623
1823
  exportProjectV1(project, option(args, '--name') || project.name, path.resolve(out), {
1624
1824
  allowIncomplete: args.includes('--allow-incomplete'),
1625
1825
  password,
@@ -1654,7 +1854,7 @@ function cmdExport(args) {
1654
1854
  provenance.content_fingerprint = finalDigest;
1655
1855
  files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
1656
1856
 
1657
- if (args.includes('--sign')) applySignature(files, option(args, '--passphrase'));
1857
+ if (args.includes('--sign')) applySignature(files, resolvePassphrase(args));
1658
1858
 
1659
1859
  const entries = [['mimetype', files.mimetype]];
1660
1860
  for (const name of Object.keys(files).filter(k => k !== 'mimetype').sort()) {
@@ -1744,7 +1944,7 @@ function cmdIdentity(args) {
1744
1944
  const sub = args[0];
1745
1945
  if (sub === 'init') {
1746
1946
  const name = option(args, '--name', process.env.USER || process.env.USERNAME || '');
1747
- const passphrase = option(args, '--passphrase');
1947
+ const passphrase = resolvePassphrase(args);
1748
1948
  try {
1749
1949
  const identity = creatorApi.initIdentity(name, null, passphrase);
1750
1950
  console.log(`Creator identity initialized:`);
@@ -1837,7 +2037,11 @@ function cmdSourceClassify(args) {
1837
2037
  const target = project.distillation_target;
1838
2038
  if (!target) fail('Declare a distillation target first: kdna-studio target declare <project>');
1839
2039
 
1840
- const evidence = project.evidence || [];
2040
+ // Canonical evidence field is `evidence_materials` (set by cmdImport via
2041
+ // evidenceApi.addEvidence). The legacy `project.evidence` field never
2042
+ // receives writes, so reading it always produced an empty list and the
2043
+ // classifier reported "no evidence" even after `kdna-studio import`.
2044
+ const evidence = project.evidence_materials || project.evidence || [];
1841
2045
  const results = evidence.map(e => {
1842
2046
  const text = (e.content || e.title || '').toLowerCase();
1843
2047
  const domainWords = (target.include_areas || []).concat([target.domain_category, target.task_scope]);
@@ -1965,14 +2169,29 @@ function cmdCandidate(args) {
1965
2169
  if (accepted.length === 0) fail('No candidates to promote. Accept some candidates first.');
1966
2170
 
1967
2171
  for (const c of accepted) {
1968
- const card = cardApi.createCard(c.suggested_card_type, {
1969
- one_sentence: c.one_sentence,
1970
- full_statement: c.full_statement,
1971
- why: '',
1972
- applies_when: '',
1973
- does_not_apply_when: '',
1974
- failure_risk: '',
1975
- });
2172
+ // Promote candidates to draft cards. For axioms we must populate every
2173
+ // field the Human Lock gate and migrate path require — empty strings
2174
+ // cause migrate to fail ("critical fields missing from axioms"). When
2175
+ // a candidate lacks the field, fall back to the candidate's own
2176
+ // one_sentence/full_statement so the lock gate at least has substance
2177
+ // to verify. Authors are expected to enrich these fields before
2178
+ // locking.
2179
+ const fields = {};
2180
+ if (c.suggested_card_type === 'axiom') {
2181
+ fields.one_sentence = c.one_sentence || '';
2182
+ fields.full_statement = c.full_statement || c.one_sentence || '<TBD: full_statement>';
2183
+ fields.why = c.why || '<TBD: why>';
2184
+ // applies_when / does_not_apply_when are arrays, not strings.
2185
+ // Empty string is invalid; emit an empty array so checkRequiredFields
2186
+ // (which treats `length === 0` as missing) correctly reports them.
2187
+ fields.applies_when = Array.isArray(c.applies_when) ? c.applies_when : [];
2188
+ fields.does_not_apply_when = Array.isArray(c.does_not_apply_when) ? c.does_not_apply_when : [];
2189
+ fields.failure_risk = c.failure_risk || '<TBD: failure_risk>';
2190
+ } else {
2191
+ fields.one_sentence = c.one_sentence || '';
2192
+ fields.full_statement = c.full_statement || c.one_sentence || '';
2193
+ }
2194
+ const card = cardApi.createCard(c.suggested_card_type, fields);
1976
2195
  card.evidence_refs = c.supporting_evidence_ids;
1977
2196
  project.cards = project.cards || [];
1978
2197
  project.cards.push(card);
@@ -2012,7 +2231,12 @@ async function cmdFeynman(args) {
2012
2231
  if (cardIdx < 0) fail(`Card not found: ${cardId}`);
2013
2232
  const card = project.cards[cardIdx];
2014
2233
 
2015
- if (!card.feynman_text) fail(`Card ${cardId} has no Feynman restatement. Use card approve first with --statement to set it.`);
2234
+ // Canonical field name in the card schema is `feynman_restatement`, not
2235
+ // `feynman_text`. The old check was dead code (nothing writes the wrong
2236
+ // field) and made this command fail on every card.
2237
+ if (!card.feynman_restatement) {
2238
+ fail(`Card ${cardId} has no Feynman restatement. Generate one with the feynman authoring command, or set it via card update --field feynman_restatement='<json>'.`);
2239
+ }
2016
2240
 
2017
2241
  console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2018
2242
  const result = await ai.feynman.evaluate(llm.config(), card, {});
@@ -2043,12 +2267,41 @@ async function cmdTest(args) {
2043
2267
  const domainName = project.name || 'Untitled Project';
2044
2268
  const domainPrompt = `Domain: ${domainName}\n${project.description || ''}`;
2045
2269
 
2270
+ // Bug (#50): prior version hand-rolled the prompt for the 3 preset
2271
+ // names and bypassed ai.testlab.testPreset entirely. The exported
2272
+ // testPreset function was dead code, and the help text advertised
2273
+ // names (baseline / edge / contradiction) that did not match the
2274
+ // actual preset keys in testlab.js (baseline / edge_case /
2275
+ // contradiction). Result: --preset edge silently ran the
2276
+ // contradiction branch, and any caller that asked for "edge_case"
2277
+ // fell through to the contradiction branch too.
2278
+ //
2279
+ // The fix routes the call through testPreset when a known preset is
2280
+ // given, and accepts both help-text names (baseline / edge /
2281
+ // contradiction) and the canonical testlab names (baseline / edge_case
2282
+ // / contradiction).
2283
+ const PRESET_ALIASES = {
2284
+ baseline: 'baseline',
2285
+ edge: 'edge_case',
2286
+ edge_case: 'edge_case',
2287
+ contradiction: 'contradiction',
2288
+ };
2289
+ const canonical = PRESET_ALIASES[preset] || 'baseline';
2046
2290
  console.log(`Testing: ${domainName} [${preset}]`);
2047
- const prompt = preset === 'baseline' ? `Test the core judgment: ${input}`
2048
- : preset === 'edge' ? `Test a boundary case: ${input}`
2049
- : `Test for contradiction: ${input}`;
2050
- const result = await ai.testlab.compare(llm.config(), domainName, prompt, domainPrompt, {});
2051
- console.log(JSON.stringify(result, null, 2));
2291
+
2292
+ if (canonical === 'baseline') {
2293
+ // Baseline is a single comparison: keep the prior single-result
2294
+ // shape so existing parsers / consumers see the same output.
2295
+ const prompt = `Test the core judgment: ${input}`;
2296
+ const result = await ai.testlab.compare(llm.config(), domainName, prompt, domainPrompt, {});
2297
+ console.log(JSON.stringify(result, null, 2));
2298
+ return;
2299
+ }
2300
+ // For non-baseline presets, run the preset + the baseline so the user
2301
+ // can see both side by side. The preset prompt is the one in
2302
+ // ai/testlab.js — that's the code path that was dead before.
2303
+ const result = await ai.testlab.testPreset(llm.config(), domainName, input, domainPrompt, {});
2304
+ console.log(JSON.stringify(result[canonical] || result, null, 2));
2052
2305
  }
2053
2306
 
2054
2307
  const args = process.argv.slice(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Official KDNA Studio command-line authoring entry for creating, reviewing, compiling, and exporting .kdna judgment assets through the KDNA toolchain.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "node": ">=18"
40
40
  },
41
41
  "dependencies": {
42
- "@aikdna/kdna-studio-core": "^1.7.1",
42
+ "@aikdna/kdna-studio-core": "^1.7.3",
43
43
  "ajv": "^8.20.0",
44
44
  "ajv-formats": "^3.0.1",
45
45
  "cbor-x": "^1.6.4"
package/src/ai/feynman.js CHANGED
@@ -31,8 +31,20 @@ Return ONLY valid JSON:
31
31
  }`;
32
32
 
33
33
  async function evaluate(config, card, options = {}) {
34
- const axiomText = card.one_sentence || card.full_statement || '';
35
- const restatement = card.feynman_text || '';
34
+ // Bug: prior version read `card.feynman_text` (a field nothing in the
35
+ // codebase ever wrote) and flattened `card.one_sentence` /
36
+ // `card.full_statement` — the schema actually nests those under
37
+ // `card.fields`. The AI evaluator therefore always received an empty
38
+ // restatement and a blank axiom, no matter what was in the project.
39
+ // The fix reads the canonical schema field names and the nested
40
+ // `fields` object, with a fallback for callers that pass the flattened
41
+ // shape directly.
42
+ const fields = (card && card.fields) || card || {};
43
+ const axiomText = fields.one_sentence || fields.full_statement || fields.question || '';
44
+ const restatement = card.feynman_restatement
45
+ || fields.feynman_restatement
46
+ || fields.feynman_text
47
+ || '';
36
48
 
37
49
  if (!restatement) return { score: 0, criteria: {}, explanations: { notJustRepeat: 'No Feynman restatement provided.' }, suggestions: ['Write a Feynman restatement before evaluation.'] };
38
50