@aikdna/kdna-studio-cli 0.8.5 → 0.8.7

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.
@@ -91,6 +91,27 @@ function fail(message, code = EXIT.INPUT_ERROR) {
91
91
  process.exit(code);
92
92
  }
93
93
 
94
+ // Bug (#65): `fail()` calls `process.exit`, which skips `finally`
95
+ // blocks in the same call stack. Any tmpDir that a `try { ... }
96
+ // finally { fs.rmSync(tmpDir) }` block is in the middle of running
97
+ // will leak. The fix tracks every temporary directory the process
98
+ // creates and removes it on process exit (including the exit path
99
+ // triggered by `fail()`).
100
+ const _tempDirs = new Set();
101
+ function trackTempDir(dir) {
102
+ if (dir) _tempDirs.add(dir);
103
+ return dir;
104
+ }
105
+ function cleanupTempDirs() {
106
+ for (const dir of _tempDirs) {
107
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
108
+ }
109
+ _tempDirs.clear();
110
+ }
111
+ process.on('exit', cleanupTempDirs);
112
+ process.on('SIGINT', () => { cleanupTempDirs(); process.exit(130); });
113
+ process.on('SIGTERM', () => { cleanupTempDirs(); process.exit(143); });
114
+
94
115
  function option(args, name, fallback = null) {
95
116
  const idx = args.indexOf(name);
96
117
  if (idx < 0) return fallback;
@@ -314,12 +335,12 @@ function decodePayload(bytes, manifest = {}) {
314
335
  // by collapsing one path into the other.
315
336
  function cardsFromV1Payload(payload) {
316
337
  const cards = [];
317
- for (const sourceCard of (payload.source_cards || [])) {
338
+ for (const sourceCard of (Array.isArray(payload.source_cards) ? payload.source_cards : [])) {
318
339
  pushImportedCard(cards, sourceCard.type, sourceCard.fields || {}, sourceCard.id || null);
319
340
  }
320
341
  if (cards.length > 0) return cards;
321
342
 
322
- for (const ax of (payload.core?.axioms || [])) {
343
+ for (const ax of (Array.isArray(payload.core?.axioms) ? payload.core?.axioms : [])) {
323
344
  pushImportedCard(cards, 'axiom', {
324
345
  one_sentence: ax.one_sentence || '',
325
346
  full_statement: ax.full_statement || '',
@@ -329,19 +350,19 @@ function cardsFromV1Payload(payload) {
329
350
  failure_risk: ax.failure_risk || '',
330
351
  }, ax.id || null);
331
352
  }
332
- for (const ont of (payload.core?.ontology || [])) {
353
+ for (const ont of (Array.isArray(payload.core?.ontology) ? payload.core?.ontology : [])) {
333
354
  pushImportedCard(cards, 'ontology', ont, ont.id || null);
334
355
  }
335
- for (const fw of (payload.core?.frameworks || [])) {
356
+ for (const fw of (Array.isArray(payload.core?.frameworks) ? payload.core?.frameworks : [])) {
336
357
  pushImportedCard(cards, 'framework', fw, fw.id || null);
337
358
  }
338
- for (const stance of (payload.core?.stances || [])) {
359
+ for (const stance of (Array.isArray(payload.core?.stances) ? payload.core?.stances : [])) {
339
360
  pushImportedCard(cards, 'stance', stance, stance.id || null);
340
361
  }
341
- for (const aesthetic of (payload.core?.aesthetics || [])) {
362
+ for (const aesthetic of (Array.isArray(payload.core?.aesthetics) ? payload.core?.aesthetics : [])) {
342
363
  pushImportedCard(cards, 'aesthetic', aesthetic, aesthetic.id || null);
343
364
  }
344
- for (const boundary of (payload.core?.boundaries || [])) {
365
+ for (const boundary of (Array.isArray(payload.core?.boundaries) ? payload.core?.boundaries : [])) {
345
366
  pushImportedCard(cards, 'boundary', boundary, boundary.id || null);
346
367
  }
347
368
  // risk_model may be either an array of risk items (legacy) or
@@ -352,15 +373,45 @@ function cardsFromV1Payload(payload) {
352
373
  for (const risk of riskItems) {
353
374
  pushImportedCard(cards, 'risk', risk, risk.id || null);
354
375
  }
355
- for (const pattern of (payload.patterns || [])) {
356
- if (!pattern.type) continue;
357
- const { type, ...fields } = pattern;
376
+ for (const pattern of (Array.isArray(payload.patterns) ? payload.patterns : [])) {
377
+ // Bug (#146): prior version dropped any payload.patterns entry
378
+ // that lacked an explicit `type` field. The v1 schema lists
379
+ // misunderstandings in patterns[] without a `type` discriminator
380
+ // (their shape is `{wrong, correct, key_distinction, why}`),
381
+ // and several exporters omit `type` to keep the on-the-wire
382
+ // shape compact. The fix infers a type from the field set when
383
+ // `type` is missing, and only falls through to "skip" when the
384
+ // entry truly cannot be classified.
385
+ let type = pattern.type;
386
+ let fields = pattern;
387
+ if (!type) {
388
+ // Heuristics, in priority order — most specific to most general.
389
+ if ('wrong' in pattern && 'correct' in pattern) type = 'misunderstanding';
390
+ else if ('term' in pattern && 'definition' in pattern) type = 'term';
391
+ else if ('term' in pattern && 'why' in pattern && 'replace_with' in pattern) type = 'banned_term';
392
+ else if ('what_it_looks_like' in pattern && 'how_to_fix' in pattern) type = 'pattern';
393
+ else if ('name' in pattern && ('description' in pattern || 'one_sentence' in pattern)) type = 'aesthetic';
394
+ else {
395
+ // Truly unclassifiable. Log a one-line warning and skip
396
+ // rather than crash, so the rest of the asset still imports.
397
+ console.warn(
398
+ `Skipping payload.patterns entry with no recognisable type ` +
399
+ `(id=${pattern.id || '?'}, keys=${Object.keys(pattern).join(',') || '<empty>'})`
400
+ );
401
+ continue;
402
+ }
403
+ fields = { ...pattern };
404
+ delete fields.type;
405
+ } else {
406
+ const { type: _stripped, ...rest } = pattern;
407
+ fields = rest;
408
+ }
358
409
  pushImportedCard(cards, type, fields, pattern.id || null);
359
410
  }
360
- for (const scenario of (payload.scenarios || [])) {
411
+ for (const scenario of (Array.isArray(payload.scenarios) ? payload.scenarios : [])) {
361
412
  pushImportedCard(cards, 'scenario', scenario, scenario.id || null);
362
413
  }
363
- for (const item of (payload.cases || [])) {
414
+ for (const item of (Array.isArray(payload.cases) ? payload.cases : [])) {
364
415
  pushImportedCard(cards, 'case', item, item.id || null);
365
416
  }
366
417
  // Accept both `self_check` (canonical) and `self_checks` (legacy schema
@@ -376,18 +427,27 @@ function cardsFromV1Payload(payload) {
376
427
  const fields = typeof selfCheck === 'string' ? { question: selfCheck } : selfCheck;
377
428
  pushImportedCard(cards, 'self_check', fields, fields.id || null);
378
429
  }
379
- for (const failureMode of (payload.reasoning?.failure_modes || [])) {
430
+ for (const failureMode of (Array.isArray(payload.reasoning?.failure_modes) ? payload.reasoning?.failure_modes : [])) {
431
+ // Bug (#145 follow-up): the prior version dropped three
432
+ // judgment fields (failure_risk, applies_when, does_not_apply_when)
433
+ // when reading failure_modes back out of a v1 asset. A Studio
434
+ // project that round-tripped through `migrate --format v1` lost
435
+ // these fields every time. The fix reads them through, matching
436
+ // the export-side map in kdna-studio-core's buildPayload.
380
437
  pushImportedCard(cards, 'misunderstanding', {
381
438
  wrong: failureMode.mode || failureMode.wrong || '',
382
439
  correct: failureMode.correct || '',
383
440
  key_distinction: failureMode.key_distinction || '',
384
441
  why: failureMode.why || '',
442
+ failure_risk: failureMode.failure_risk || '',
443
+ applies_when: Array.isArray(failureMode.applies_when) ? failureMode.applies_when : [],
444
+ does_not_apply_when: Array.isArray(failureMode.does_not_apply_when) ? failureMode.does_not_apply_when : [],
385
445
  }, failureMode.id || null);
386
446
  }
387
- for (const chain of (payload.reasoning?.reasoning_chains || [])) {
447
+ for (const chain of (Array.isArray(payload.reasoning?.reasoning_chains) ? payload.reasoning?.reasoning_chains : [])) {
388
448
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
389
449
  }
390
- for (const stage of (payload.evolution?.stages || [])) {
450
+ for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
391
451
  // Distinguish source-authored stages (preserved identity) from
392
452
  // audit-log-derived stages (synthesised). The compile path tags
393
453
  // source_authored: true; import those verbatim and skip the rest
@@ -406,7 +466,7 @@ function cardsFromLegacyPayload(payload) {
406
466
  const core = judgment.core || {};
407
467
  const patterns = judgment.patterns || {};
408
468
 
409
- for (const ax of (core.axioms || [])) {
469
+ for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
410
470
  pushImportedCard(cards, 'axiom', {
411
471
  one_sentence: ax.one_sentence || '',
412
472
  full_statement: ax.full_statement || '',
@@ -416,7 +476,7 @@ function cardsFromLegacyPayload(payload) {
416
476
  failure_risk: ax.failure_risk || '',
417
477
  }, ax.id || null);
418
478
  }
419
- for (const ont of (core.ontology || [])) {
479
+ for (const ont of (Array.isArray(core.ontology) ? core.ontology : [])) {
420
480
  pushImportedCard(cards, 'ontology', {
421
481
  one_sentence: ont.one_sentence || ont.essence || '',
422
482
  essence: ont.essence || '',
@@ -424,23 +484,32 @@ function cardsFromLegacyPayload(payload) {
424
484
  trigger_signal: ont.trigger_signal || '',
425
485
  }, ont.id || null);
426
486
  }
427
- for (const boundary of (core.boundaries || [])) {
487
+ for (const boundary of (Array.isArray(core.boundaries) ? core.boundaries : [])) {
428
488
  pushImportedCard(cards, 'boundary', {
429
489
  scope: boundary.scope || '',
430
490
  out_of_scope: boundary.out_of_scope || '',
431
491
  acceptable_exceptions: boundary.acceptable_exceptions || [],
432
492
  }, boundary.id || null);
433
493
  }
434
- for (const risk of (core.risks || [])) {
494
+ for (const risk of (Array.isArray(core.risks) ? core.risks : [])) {
435
495
  pushImportedCard(cards, 'risk', risk, risk.id || null);
436
496
  }
437
- for (const term of (patterns.terminology?.standard_terms || [])) {
497
+ // Bug (#63): prior version skipped `core.stances` and
498
+ // `core.frameworks`, so a legacy asset that authored either type
499
+ // round-tripped out as zero cards. The fix adds both loops.
500
+ for (const stance of (Array.isArray(core.stances) ? core.stances : [])) {
501
+ pushImportedCard(cards, 'stance', stance, stance.id || null);
502
+ }
503
+ for (const fw of (Array.isArray(core.frameworks) ? core.frameworks : [])) {
504
+ pushImportedCard(cards, 'framework', fw, fw.id || null);
505
+ }
506
+ for (const term of (Array.isArray(patterns.terminology?.standard_terms) ? patterns.terminology?.standard_terms : [])) {
438
507
  pushImportedCard(cards, 'term', term, term.id || null);
439
508
  }
440
- for (const banned of (patterns.terminology?.banned_terms || [])) {
509
+ for (const banned of (Array.isArray(patterns.terminology?.banned_terms) ? patterns.terminology?.banned_terms : [])) {
441
510
  pushImportedCard(cards, 'banned_term', banned, banned.id || null);
442
511
  }
443
- for (const ms of (patterns.misunderstandings || [])) {
512
+ for (const ms of (Array.isArray(patterns.misunderstandings) ? patterns.misunderstandings : [])) {
444
513
  pushImportedCard(cards, 'misunderstanding', {
445
514
  wrong: ms.wrong || '',
446
515
  correct: ms.correct || '',
@@ -455,19 +524,19 @@ function cardsFromLegacyPayload(payload) {
455
524
  const question = typeof sc === 'string' ? sc : sc.question || '';
456
525
  pushImportedCard(cards, 'self_check', { question }, sc.id || null);
457
526
  }
458
- for (const aesthetic of (patterns.aesthetics || [])) {
527
+ for (const aesthetic of (Array.isArray(patterns.aesthetics) ? patterns.aesthetics : [])) {
459
528
  pushImportedCard(cards, 'aesthetic', aesthetic, aesthetic.id || null);
460
529
  }
461
- for (const scene of (judgment.scenarios?.scenes || [])) {
530
+ for (const scene of (Array.isArray(judgment.scenarios?.scenes) ? judgment.scenarios?.scenes : [])) {
462
531
  pushImportedCard(cards, 'scenario', scene, scene.id || null);
463
532
  }
464
- for (const item of (judgment.cases?.cases || [])) {
533
+ for (const item of (Array.isArray(judgment.cases?.cases) ? judgment.cases?.cases : [])) {
465
534
  pushImportedCard(cards, 'case', item, item.id || null);
466
535
  }
467
- for (const chain of (judgment.reasoning?.reasoning_chains || [])) {
536
+ for (const chain of (Array.isArray(judgment.reasoning?.reasoning_chains) ? judgment.reasoning?.reasoning_chains : [])) {
468
537
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
469
538
  }
470
- for (const stage of (judgment.evolution?.stages || [])) {
539
+ for (const stage of (Array.isArray(judgment.evolution?.stages) ? judgment.evolution?.stages : [])) {
471
540
  pushImportedCard(cards, 'evolution_stage', stage, stage.id || null);
472
541
  }
473
542
  return cards;
@@ -678,35 +747,50 @@ function importFromKdna(kdnaPath) {
678
747
  fail(`Could not import cards from payload.kdnab: ${e.message}`);
679
748
  }
680
749
  } else if (entries.has('KDNA_Core.json') || entries.has('KDNA_Patterns.json')) {
681
- // Legacy fallback — only used when payload.kdnab is absent. This path
682
- // intentionally covers a strict subset of card types and is not the
683
- // recommended import path.
750
+ // Legacy fallback — used when the v1 asset has no payload.kdnab
751
+ // (i.e. the asset predates the 0.7 split). Bug (#67): prior
752
+ // version only handled axiom / ontology / boundary / misunderstanding
753
+ // / self_check — 5 of the 14 card types — so every other type
754
+ // (risk / stance / framework / term / banned_term / aesthetic /
755
+ // scenario / case / reasoning / evolution_stage) round-tripped
756
+ // out as zero cards. The fix threads every type this CLI can
757
+ // import through, and prefers the same field-shape rules as
758
+ // cardsFromLegacyPayload so the round-trip is symmetric.
684
759
  if (entries.has('KDNA_Core.json')) {
685
760
  const core = JSON.parse(entries.get('KDNA_Core.json').toString());
686
- for (const ax of (core.axioms || [])) {
761
+ for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
687
762
  const fields = {};
688
763
  for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk']) {
689
764
  if (k in ax) fields[k] = ax[k];
690
765
  }
691
766
  importedCards.push(cardApi.createCard('axiom', fields));
692
767
  }
693
- for (const ont of (core.ontology || [])) {
768
+ for (const ont of (Array.isArray(core.ontology) ? core.ontology : [])) {
694
769
  importedCards.push(cardApi.createCard('ontology', {
695
770
  one_sentence: ont.one_sentence || ont.essence || '',
696
771
  essence: ont.essence || '', boundary: ont.boundary || '',
697
772
  trigger_signal: ont.trigger_signal || '',
698
773
  }));
699
774
  }
700
- for (const b of (core.boundaries || [])) {
775
+ for (const b of (Array.isArray(core.boundaries) ? core.boundaries : [])) {
701
776
  importedCards.push(cardApi.createCard('boundary', {
702
777
  scope: b.scope || '', out_of_scope: b.out_of_scope || '',
703
778
  acceptable_exceptions: b.acceptable_exceptions || [],
704
779
  }));
705
780
  }
781
+ for (const risk of (core.risks || core.risk_model || [])) {
782
+ importedCards.push(cardApi.createCard('risk', risk.fields || risk));
783
+ }
784
+ for (const stance of (Array.isArray(core.stances) ? core.stances : [])) {
785
+ importedCards.push(cardApi.createCard('stance', stance.fields || stance));
786
+ }
787
+ for (const fw of (Array.isArray(core.frameworks) ? core.frameworks : [])) {
788
+ importedCards.push(cardApi.createCard('framework', fw.fields || fw));
789
+ }
706
790
  }
707
791
  if (entries.has('KDNA_Patterns.json')) {
708
792
  const pat = JSON.parse(entries.get('KDNA_Patterns.json').toString());
709
- for (const ms of (pat.misunderstandings || [])) {
793
+ for (const ms of (Array.isArray(pat.misunderstandings) ? pat.misunderstandings : [])) {
710
794
  importedCards.push(cardApi.createCard('misunderstanding', {
711
795
  wrong: ms.wrong || '', correct: ms.correct || '',
712
796
  key_distinction: ms.key_distinction || '', why: ms.why || '',
@@ -716,6 +800,39 @@ function importFromKdna(kdnaPath) {
716
800
  const q = typeof sc === 'string' ? sc : sc.question || '';
717
801
  importedCards.push(cardApi.createCard('self_check', { question: q }));
718
802
  }
803
+ for (const aesthetic of (Array.isArray(pat.aesthetics) ? pat.aesthetics : [])) {
804
+ importedCards.push(cardApi.createCard('aesthetic', aesthetic.fields || aesthetic));
805
+ }
806
+ for (const term of (Array.isArray(pat.terminology?.standard_terms) ? pat.terminology?.standard_terms : [])) {
807
+ importedCards.push(cardApi.createCard('term', term));
808
+ }
809
+ for (const banned of (Array.isArray(pat.terminology?.banned_terms) ? pat.terminology?.banned_terms : [])) {
810
+ importedCards.push(cardApi.createCard('banned_term', banned));
811
+ }
812
+ }
813
+ if (entries.has('KDNA_Scenarios.json')) {
814
+ const scen = JSON.parse(entries.get('KDNA_Scenarios.json').toString());
815
+ for (const s of (Array.isArray(scen.scenes) ? scen.scenes : [])) {
816
+ importedCards.push(cardApi.createCard('scenario', s));
817
+ }
818
+ }
819
+ if (entries.has('KDNA_Cases.json')) {
820
+ const cases = JSON.parse(entries.get('KDNA_Cases.json').toString());
821
+ for (const c of (Array.isArray(cases.cases) ? cases.cases : [])) {
822
+ importedCards.push(cardApi.createCard('case', c));
823
+ }
824
+ }
825
+ if (entries.has('KDNA_Reasoning.json')) {
826
+ const reasoning = JSON.parse(entries.get('KDNA_Reasoning.json').toString());
827
+ for (const chain of (Array.isArray(reasoning.reasoning_chains) ? reasoning.reasoning_chains : [])) {
828
+ importedCards.push(cardApi.createCard('reasoning', chain));
829
+ }
830
+ }
831
+ if (entries.has('KDNA_Evolution.json')) {
832
+ const evolution = JSON.parse(entries.get('KDNA_Evolution.json').toString());
833
+ for (const stage of (Array.isArray(evolution.stages) ? evolution.stages : [])) {
834
+ importedCards.push(cardApi.createCard('evolution_stage', stage));
835
+ }
719
836
  }
720
837
  }
721
838
 
@@ -818,7 +935,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
818
935
  }
819
936
 
820
937
  if (core) {
821
- for (const ax of (core.axioms || [])) {
938
+ for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
822
939
  const fields = {};
823
940
  for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk']) {
824
941
  if (key in ax) fields[key] = ax[key];
@@ -826,14 +943,14 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
826
943
  }
827
944
  importedCards.push(cardApi.createCard('axiom', fields));
828
945
  }
829
- for (const ont of (core.ontology || [])) {
946
+ for (const ont of (Array.isArray(core.ontology) ? core.ontology : [])) {
830
947
  importedCards.push(cardApi.createCard('ontology', {
831
948
  one_sentence: ont.one_sentence || ont.essence || '',
832
949
  essence: ont.essence || '', boundary: ont.boundary || '',
833
950
  trigger_signal: ont.trigger_signal || '',
834
951
  }));
835
952
  }
836
- for (const b of (core.boundaries || [])) {
953
+ for (const b of (Array.isArray(core.boundaries) ? core.boundaries : [])) {
837
954
  importedCards.push(cardApi.createCard('boundary', {
838
955
  scope: b.scope || '', out_of_scope: b.out_of_scope || '',
839
956
  acceptable_exceptions: b.acceptable_exceptions || [],
@@ -851,7 +968,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
851
968
  importedCards.push(cardApi.createCard('risk', r.fields || r));
852
969
  }
853
970
  // NEW: import stances
854
- for (const s of (core.stances || [])) {
971
+ for (const s of (Array.isArray(core.stances) ? core.stances : [])) {
855
972
  const text = typeof s === 'string' ? s : s.stance || '';
856
973
  importedCards.push(cardApi.createCard('stance', {
857
974
  statement: text,
@@ -860,7 +977,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
860
977
  }));
861
978
  }
862
979
  // NEW: import frameworks
863
- for (const fw of (core.frameworks || [])) {
980
+ for (const fw of (Array.isArray(core.frameworks) ? core.frameworks : [])) {
864
981
  importedCards.push(cardApi.createCard('framework', {
865
982
  name: fw.name || '', when_to_use: fw.when_to_use || '',
866
983
  steps: fw.steps || [],
@@ -871,18 +988,18 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
871
988
  if (patterns) {
872
989
  // NEW: import terminology
873
990
  if (patterns.terminology) {
874
- for (const t of (patterns.terminology.standard_terms || [])) {
991
+ for (const t of (Array.isArray(patterns.terminology.standard_terms) ? patterns.terminology.standard_terms : [])) {
875
992
  importedCards.push(cardApi.createCard('term', {
876
993
  term: t.term || '', definition: t.definition || '',
877
994
  }));
878
995
  }
879
- for (const bt of (patterns.terminology.banned_terms || [])) {
996
+ for (const bt of (Array.isArray(patterns.terminology.banned_terms) ? patterns.terminology.banned_terms : [])) {
880
997
  importedCards.push(cardApi.createCard('banned_term', {
881
998
  term: bt.term || '', why: bt.why || '', replace_with: bt.replace_with || '',
882
999
  }));
883
1000
  }
884
1001
  }
885
- for (const ms of (patterns.misunderstandings || [])) {
1002
+ for (const ms of (Array.isArray(patterns.misunderstandings) ? patterns.misunderstandings : [])) {
886
1003
  importedCards.push(cardApi.createCard('misunderstanding', {
887
1004
  wrong: ms.wrong || '', correct: ms.correct || '',
888
1005
  key_distinction: ms.key_distinction || '', why: ms.why || '',
@@ -892,6 +1009,15 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
892
1009
  const q = typeof sc === 'string' ? sc : sc.question || '';
893
1010
  importedCards.push(cardApi.createCard('self_check', { question: q }));
894
1011
  }
1012
+ // Bug (aesthetic round-trip): prior version dropped
1013
+ // `patterns.aesthetics` on import. `cardsFromV1Payload` and
1014
+ // `cardsFromLegacyPayload` both read the field, so a domain
1015
+ // that authored aesthetic cards in either format would lose
1016
+ // them when imported through `create --from-folder`. The fix
1017
+ // adds the missing loop here.
1018
+ for (const aesthetic of (Array.isArray(patterns.aesthetics) ? patterns.aesthetics : [])) {
1019
+ importedCards.push(cardApi.createCard('aesthetic', aesthetic.fields || aesthetic));
1020
+ }
895
1021
  }
896
1022
 
897
1023
  // NEW: import scenarios
@@ -1468,7 +1594,7 @@ function cmdMigrate(args) {
1468
1594
  projectPath = loaded.projectPath;
1469
1595
  if (!requestedName && project.name) name = project.name;
1470
1596
  } else {
1471
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-migrate-'));
1597
+ tmpDir = trackTempDir(fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-migrate-')));
1472
1598
  const imported = importFromFolder(sourceDir, tmpDir, name, creatorIdentity);
1473
1599
  project = imported.project;
1474
1600
  projectPath = path.join(tmpDir, 'studio.project.json');
@@ -1483,7 +1609,7 @@ function cmdMigrate(args) {
1483
1609
 
1484
1610
  // Reject if critical judgment fields are missing from axioms
1485
1611
  const criticalMissing = [];
1486
- for (const card of (project.cards || [])) {
1612
+ for (const card of (Array.isArray(project.cards) ? project.cards : [])) {
1487
1613
  if (card.type !== 'axiom') continue;
1488
1614
  for (const field of ['applies_when', 'does_not_apply_when', 'failure_risk']) {
1489
1615
  const val = card.fields && card.fields[field];
@@ -1616,7 +1742,7 @@ function exportProjectV1(project, name, outPath, opts = {}) {
1616
1742
  }
1617
1743
  }
1618
1744
  const lockedCards = (project.cards || []).filter(c => c.locked);
1619
- const v1Dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-v1-'));
1745
+ const v1Dir = trackTempDir(fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-v1-')));
1620
1746
  try {
1621
1747
  const manifestDefaults = buildV1Manifest(project, name);
1622
1748
  const runtimeAsset = exportRuntime.exportRuntimeAsset(project, {
@@ -2089,10 +2215,16 @@ function cmdSourceClassify(args) {
2089
2215
  const target = project.distillation_target;
2090
2216
  if (!target) fail('Declare a distillation target first: kdna-studio target declare <project>');
2091
2217
 
2092
- // Canonical evidence field is `evidence_materials` (set by cmdImport via
2093
- // evidenceApi.addEvidence). The legacy `project.evidence` field never
2094
- // receives writes, so reading it always produced an empty list and the
2095
- // classifier reported "no evidence" even after `kdna-studio import`.
2218
+ // Bug (#66 + #69): the previous version of this comment claimed
2219
+ // `evidence_materials` was canonical and `evidence` was the legacy
2220
+ // fallback. The reality was the opposite — `addEvidence` in
2221
+ // @aikdna/kdna-studio-core wrote only to `project.evidence`, so
2222
+ // every consumer that read `project.evidence_materials` (this
2223
+ // command, cmdFilter, cmdDistill) saw an empty list. The fix in
2224
+ // studio-core 1.7.5 makes `addEvidence` write to BOTH fields; the
2225
+ // canonical name is now the one this comment names, not the one
2226
+ // it used to name. The fallback `|| project.evidence` stays so
2227
+ // legacy projects (pre-1.7.5) still classify correctly.
2096
2228
  const evidence = project.evidence_materials || project.evidence || [];
2097
2229
  const results = evidence.map(e => {
2098
2230
  const text = (e.content || e.title || '').toLowerCase();
@@ -2286,8 +2418,31 @@ async function cmdFeynman(args) {
2286
2418
  // Canonical field name in the card schema is `feynman_restatement`, not
2287
2419
  // `feynman_text`. The old check was dead code (nothing writes the wrong
2288
2420
  // field) and made this command fail on every card.
2421
+ //
2422
+ // Bug (#62 + #68 follow-up): the prior `if (!card.feynman_restatement)
2423
+ // fail(...)` block rejected every card that had no human-written
2424
+ // restatement, even when the AI evaluator in feynman.js was
2425
+ // prepared to auto-synthesise one. A caller without a configured
2426
+ // LLM (no `KDNA_API_KEY`, no `kdna-studio llm config`) would
2427
+ // get a `fail` instead of a structured note. The fix removes the
2428
+ // guard: a missing restatement is now handed off to the evaluator
2429
+ // (which either asks the LLM to evaluate a synthesised one or,
2430
+ // without an LLM, returns a structured `synthesised_restatement`
2431
+ // for the caller to use as a starting point). The error message
2432
+ // is kept as a secondary path: only if the evaluator itself fails
2433
+ // does the command exit non-zero, and only then with a clear
2434
+ // pointer to the manual `card update` path.
2435
+ //
2436
+ // We do still need a restatement-shaped object to feed the
2437
+ // evaluator, so we synthesise one in-memory when the card has
2438
+ // none. This does NOT persist the synthesised restatement to the
2439
+ // project (so the user still has to opt in via `card update` to
2440
+ // keep one across runs), but it lets the no-LLM path return a
2441
+ // useful result instead of a hard fail.
2289
2442
  if (!card.feynman_restatement) {
2290
- fail(`Card ${cardId} has no Feynman restatement. Generate one with the feynman authoring command, or set it via card update --field feynman_restatement='<json>'.`);
2443
+ const synthesised = ai.feynman.synthFeynmanRestatement(card.fields || {});
2444
+ card.feynman_restatement = { text: synthesised, synthesised: true };
2445
+ console.warn(`No feynman_restatement on ${cardId}; using synthesised:\n "${synthesised}"`);
2291
2446
  }
2292
2447
 
2293
2448
  console.log(`Evaluating Feynman restatement for card: ${cardId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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.4",
42
+ "@aikdna/kdna-studio-core": "^1.7.6",
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
@@ -46,7 +46,35 @@ async function evaluate(config, card, options = {}) {
46
46
  || fields.feynman_text
47
47
  || '';
48
48
 
49
- if (!restatement) return { score: 0, criteria: {}, explanations: { notJustRepeat: 'No Feynman restatement provided.' }, suggestions: ['Write a Feynman restatement before evaluation.'] };
49
+ if (!restatement) {
50
+ // Bug (#62): prior version returned score 0 with the explanation
51
+ // "No Feynman restatement provided", but nothing in the CLI ever
52
+ // *writes* a feynman_restatement on a card. Every `kdna-studio
53
+ // feynman` call hit this path, every call returned 0, and the
54
+ // fynchmann command was effectively dead.
55
+ //
56
+ // The fix synthesises a first-cut restatement from the card's own
57
+ // text (so the LLM has *something* to evaluate), and returns a
58
+ // structured note in `suggestions` so the caller knows the score
59
+ // is on a machine-generated restatement, not their own. The
60
+ // caller can then `card update --field feynman_restatement='...'`
61
+ // to lock in a human-written version.
62
+ const synthesised = synthFeynmanRestatement(fields);
63
+ return {
64
+ score: 0,
65
+ criteria: {},
66
+ explanations: {
67
+ notJustRepeat: 'No human-written Feynman restatement — evaluated a synthesised one.',
68
+ notTooAbstract: 'Review the synthesised restatement before trusting the score.',
69
+ },
70
+ suggestions: [
71
+ 'No feynman_restatement found on this card. The score below is on an auto-generated restatement.',
72
+ `Set a human-written restatement: kdna-studio card update <project> ${card.id || '<card-id>'} --field feynman_restatement='{"text":"<your plain-language restatement>"}'`,
73
+ `Synthesised restatement: "${synthesised}"`,
74
+ ],
75
+ synthesised_restatement: synthesised,
76
+ };
77
+ }
50
78
 
51
79
  const userPrompt = [
52
80
  `## Original Axiom`,
@@ -66,4 +94,25 @@ async function evaluate(config, card, options = {}) {
66
94
  return result.data || { score: 0, criteria: {}, explanations: {}, suggestions: [] };
67
95
  }
68
96
 
69
- module.exports = { evaluate, CRITERIA, SYSTEM };
97
+ // Synthesise a first-cut restatement from the card's own fields. The
98
+ // output is intentionally plain — Feynman is "explain it like I'm
99
+ // five", so we strip axiom-speak and surface only the human-readable
100
+ // core.
101
+ function synthFeynmanRestatement(fields) {
102
+ const text = fields.one_sentence
103
+ || fields.full_statement
104
+ || fields.question
105
+ || fields.essence
106
+ || fields.scope
107
+ || '(no text to restate)';
108
+ // Strip the leading "always/never/must" markers that signal axiom
109
+ // language. The LLM evaluator will then score the result on its
110
+ // own merits, but the human reader can already see plain English.
111
+ return String(text)
112
+ .replace(/^(always|never|must|should|shall)\s+/i, '')
113
+ .replace(/\s+always\s+/gi, ' ')
114
+ .replace(/\s+never\s+/gi, ' ')
115
+ .trim();
116
+ }
117
+
118
+ module.exports = { evaluate, CRITERIA, SYSTEM, synthFeynmanRestatement };