@aikdna/kdna-studio-cli 0.8.8 → 0.8.10

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.
@@ -83,6 +83,8 @@ Create (three entry paths):
83
83
 
84
84
  Migrate (dev source or Studio project → canonical .kdna in one command):
85
85
  kdna-studio migrate <source-dir|project> --format v1 --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--allow-incomplete] [--sign] [--passphrase <pw>|--passphrase-stdin]
86
+ kdna-studio migrate <source-dir> --check --name <@scope/name> # pre-flight: report which fields would block the export without writing the .kdna
87
+ kdna-studio audit-locks <project> [--type axiom|risk|stance|...] [--json] # list cards with missing Human Lock fields (per-card-type, per-field)
86
88
 
87
89
  Authoring:
88
90
  kdna-studio import <project> <source-file-or-dir> # import evidence (txt/md/json/yaml/csv/log/srt/vtt/html)
@@ -114,6 +116,7 @@ Distillation:
114
116
  kdna-studio candidate override <project> <candidate-id> # override scope gate
115
117
  kdna-studio candidate promote <project> # promote accepted+scope_fit → cards
116
118
  kdna-studio report <project>
119
+ kdna-studio audit-locks <project> # list all axioms/risk/stance missing Human Lock fields
117
120
 
118
121
  Project may be a directory containing studio.project.json or a project JSON file.`);
119
122
  }
@@ -255,23 +258,41 @@ function writeFiles(outDir, files) {
255
258
  }
256
259
  }
257
260
 
261
+ const ARRAY_FIELD_NAMES = new Set([
262
+ 'applies_when',
263
+ 'does_not_apply_when',
264
+ 'acceptable_exceptions',
265
+ ]);
266
+
258
267
  function parseFields(args) {
259
268
  const fields = {};
260
269
  for (const pair of optionsAll(args, '--field')) {
261
270
  const eq = pair.indexOf('=');
262
271
  if (eq < 1) fail(`Invalid --field "${pair}". Use key=value.`);
263
- const key = pair.slice(0, eq);
272
+ let key = pair.slice(0, eq);
273
+ const appendArray = key.endsWith('[]');
274
+ if (appendArray) key = key.slice(0, -2);
264
275
  const raw = pair.slice(eq + 1);
276
+ let value;
265
277
  if (raw.startsWith('[') || raw.startsWith('{')) {
266
278
  try {
267
- fields[key] = JSON.parse(raw);
279
+ value = JSON.parse(raw);
268
280
  } catch (_) {
269
- fields[key] = raw;
281
+ value = raw;
270
282
  }
271
283
  } else if (raw.includes('|')) {
272
- fields[key] = raw.split('|').map((s) => s.trim()).filter(Boolean);
284
+ value = raw.split('|').map((s) => s.trim()).filter(Boolean);
285
+ } else {
286
+ value = raw;
287
+ }
288
+
289
+ if (appendArray) {
290
+ const values = Array.isArray(value) ? value : [value];
291
+ fields[key] = [...(Array.isArray(fields[key]) ? fields[key] : []), ...values];
292
+ } else if (ARRAY_FIELD_NAMES.has(key) && typeof value === 'string') {
293
+ fields[key] = value.trim() ? [value.trim()] : [];
273
294
  } else {
274
- fields[key] = raw;
295
+ fields[key] = value;
275
296
  }
276
297
  }
277
298
  return fields;
@@ -410,6 +431,8 @@ function cardsFromV1Payload(payload) {
410
431
  applies_when: ax.applies_when || [],
411
432
  does_not_apply_when: ax.does_not_apply_when || [],
412
433
  failure_risk: ax.failure_risk || '',
434
+ confidence: ax.confidence || '',
435
+ evidence_type: ax.evidence_type || '',
413
436
  }, ax.id || null);
414
437
  }
415
438
  for (const ont of (Array.isArray(payload.core?.ontology) ? payload.core?.ontology : [])) {
@@ -448,7 +471,8 @@ function cardsFromV1Payload(payload) {
448
471
  let fields = pattern;
449
472
  if (!type) {
450
473
  // Heuristics, in priority order — most specific to most general.
451
- if ('wrong' in pattern && 'correct' in pattern) type = 'misunderstanding';
474
+ if ('logic' in pattern && ('so_what' in pattern || 'conclusion' in pattern)) type = 'reasoning';
475
+ else if ('wrong' in pattern && 'correct' in pattern) type = 'misunderstanding';
452
476
  else if ('term' in pattern && 'definition' in pattern) type = 'term';
453
477
  else if ('term' in pattern && 'why' in pattern && 'replace_with' in pattern) type = 'banned_term';
454
478
  else if ('what_it_looks_like' in pattern && 'how_to_fix' in pattern) type = 'pattern';
@@ -830,7 +854,7 @@ function importFromKdna(kdnaPath) {
830
854
  const core = JSON.parse(entries.get('KDNA_Core.json').toString());
831
855
  for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
832
856
  const fields = {};
833
- for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk']) {
857
+ for (const k of ['one_sentence','full_statement','why','applies_when','does_not_apply_when','failure_risk','confidence','evidence_type']) {
834
858
  if (k in ax) fields[k] = ax[k];
835
859
  }
836
860
  importedCards.push(cardApi.createCard('axiom', fields));
@@ -848,7 +872,16 @@ function importFromKdna(kdnaPath) {
848
872
  acceptable_exceptions: b.acceptable_exceptions || [],
849
873
  }));
850
874
  }
851
- for (const risk of (core.risks || core.risk_model || [])) {
875
+ // Accept risks as a top-level array (`risks`) or wrapped in `risk_model`
876
+ // (either as `{risks: [...]}` or as an array itself). Uses the same
877
+ // Array.isArray ladder pattern as the safe path at lines 1033-1040.
878
+ let risksSource = core.risks;
879
+ if (!Array.isArray(risksSource)) {
880
+ if (Array.isArray(core.risk_model)) risksSource = core.risk_model;
881
+ else if (Array.isArray(core.risk_model?.risks)) risksSource = core.risk_model.risks;
882
+ else risksSource = [];
883
+ }
884
+ for (const risk of risksSource) {
852
885
  importedCards.push(cardApi.createCard('risk', risk.fields || risk));
853
886
  }
854
887
  for (const stance of (Array.isArray(core.stances) ? core.stances : [])) {
@@ -941,7 +974,7 @@ function importFromKdna(kdnaPath) {
941
974
  * Reads KDNA_*.json files and converts entries to draft judgment cards.
942
975
  * Outputs a schema audit report.
943
976
  */
944
- function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
977
+ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, opts = {}) {
945
978
  resetImportedIds();
946
979
  const absSource = path.resolve(sourceDir);
947
980
  if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
@@ -1008,7 +1041,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1008
1041
  if (core) {
1009
1042
  for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
1010
1043
  const fields = {};
1011
- for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk']) {
1044
+ for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type']) {
1012
1045
  if (key in ax) fields[key] = ax[key];
1013
1046
  else audit.missingFields.push(`axiom.${ax.id || '?'}.${key}`);
1014
1047
  }
@@ -1040,7 +1073,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1040
1073
  }
1041
1074
  // NEW: import stances
1042
1075
  for (const s of (Array.isArray(core.stances) ? core.stances : [])) {
1043
- const text = typeof s === 'string' ? s : s.stance || '';
1076
+ const text = typeof s === 'string' ? s : s.statement || s.stance || '';
1044
1077
  importedCards.push(cardApi.createCard('stance', {
1045
1078
  statement: text,
1046
1079
  applies_when: s.applies_when || [],
@@ -1092,7 +1125,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1092
1125
  }
1093
1126
 
1094
1127
  // NEW: import scenarios
1095
- if (scenarios && scenarios.scenes) {
1128
+ if (scenarios && Array.isArray(scenarios.scenes)) {
1096
1129
  for (const scene of scenarios.scenes) {
1097
1130
  importedCards.push(cardApi.createCard('scenario', {
1098
1131
  name: scene.name || scene.id || '',
@@ -1102,7 +1135,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1102
1135
  }
1103
1136
 
1104
1137
  // NEW: import cases
1105
- if (cases && cases.cases) {
1138
+ if (cases && Array.isArray(cases.cases)) {
1106
1139
  for (const c of cases.cases) {
1107
1140
  importedCards.push(cardApi.createCard('case', {
1108
1141
  title: c.title || c.id || '',
@@ -1118,7 +1151,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1118
1151
  // so the cards were silently rewritten as axiom-derived fallback chains
1119
1152
  // — looking identical to misunderstanding cards and losing the source's
1120
1153
  // identity.
1121
- if (reasoning && reasoning.reasoning_chains) {
1154
+ if (reasoning && Array.isArray(reasoning.reasoning_chains)) {
1122
1155
  for (const rc of reasoning.reasoning_chains) {
1123
1156
  importedCards.push(cardApi.createCard('reasoning', {
1124
1157
  axiom: rc.axiom || '',
@@ -1133,7 +1166,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1133
1166
  }
1134
1167
 
1135
1168
  // NEW: import evolution stages
1136
- if (evolution && evolution.stages) {
1169
+ if (evolution && Array.isArray(evolution.stages)) {
1137
1170
  for (const stage of evolution.stages) {
1138
1171
  importedCards.push(cardApi.createCard('evolution_stage', {
1139
1172
  name: stage.name || stage.id || '',
@@ -1177,7 +1210,9 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1177
1210
 
1178
1211
  fs.mkdirSync(projectDir, { recursive: true });
1179
1212
  writeProject(path.join(projectDir, 'studio.project.json'), project);
1180
- console.log(JSON.stringify({ audit, imported: importedCards.length, source_mode: 'source_folder' }, null, 2));
1213
+ if (!opts.silent) {
1214
+ console.log(JSON.stringify({ audit, imported: importedCards.length, source_mode: 'source_folder' }, null, 2));
1215
+ }
1181
1216
  return { project, projectPath: path.join(projectDir, 'studio.project.json'), audit };
1182
1217
  }
1183
1218
 
@@ -1392,18 +1427,74 @@ const CARD_REQUIRED_FIELDS = {
1392
1427
  pattern: ['type', 'name', 'one_sentence', 'what_it_looks_like', 'how_to_fix', 'failure_risk'],
1393
1428
  };
1394
1429
 
1430
+ const CARD_FIELD_RULES = {
1431
+ axiom: {
1432
+ applies_when: { type: 'array' },
1433
+ does_not_apply_when: { type: 'array' },
1434
+ confidence: { allowed: ['low', 'medium', 'high'] },
1435
+ evidence_type: { allowed: ['case_observation', 'theoretical', 'empirical', 'analogy', 'principle', 'practice'] },
1436
+ },
1437
+ boundary: {
1438
+ acceptable_exceptions: { type: 'array', optional: true },
1439
+ },
1440
+ stance: {
1441
+ applies_when: { type: 'array', optional: true },
1442
+ },
1443
+ misunderstanding: {
1444
+ applies_when: { type: 'array', optional: true },
1445
+ does_not_apply_when: { type: 'array', optional: true },
1446
+ },
1447
+ };
1448
+
1449
+ function fieldIssue(fields, name, rule = {}) {
1450
+ const val = fields[name];
1451
+ if (val === undefined || val === null || val === '' ||
1452
+ (Array.isArray(val) && val.length === 0)) {
1453
+ return rule.optional ? null : { field: name, issue: 'missing' };
1454
+ }
1455
+ if (rule.type === 'array' && !Array.isArray(val)) {
1456
+ return { field: name, issue: 'wrong_type', have_type: typeof val, need_type: 'array' };
1457
+ }
1458
+ if (rule.allowed && !rule.allowed.includes(val)) {
1459
+ return { field: name, issue: 'invalid_value', have: val, allowed: rule.allowed };
1460
+ }
1461
+ return null;
1462
+ }
1463
+
1464
+ function formatFieldIssue(issue) {
1465
+ if (issue.issue === 'missing') return issue.field;
1466
+ if (issue.issue === 'wrong_type') return `${issue.field} (must be ${issue.need_type})`;
1467
+ if (issue.issue === 'invalid_value') return `${issue.field} (must be one of: ${issue.allowed.join('|')})`;
1468
+ return `${issue.field} (${issue.issue})`;
1469
+ }
1470
+
1395
1471
  function checkRequiredFields(type, fields) {
1396
1472
  const required = CARD_REQUIRED_FIELDS[type];
1397
1473
  if (!required) return [];
1398
- const missing = [];
1474
+ const issues = [];
1399
1475
  for (const f of required) {
1400
- const val = fields[f];
1401
- if (val === undefined || val === null || val === '' ||
1402
- (Array.isArray(val) && val.length === 0)) {
1403
- missing.push(f);
1476
+ const issue = fieldIssue(fields, f, CARD_FIELD_RULES[type]?.[f] || {});
1477
+ if (issue) issues.push(formatFieldIssue(issue));
1478
+ }
1479
+ for (const [name, rule] of Object.entries(CARD_FIELD_RULES[type] || {})) {
1480
+ if (required.includes(name)) continue;
1481
+ const issue = fieldIssue(fields, name, rule);
1482
+ if (issue) issues.push(formatFieldIssue(issue));
1483
+ }
1484
+ return issues;
1485
+ }
1486
+
1487
+ function criticalAxiomIssues(project) {
1488
+ const issues = [];
1489
+ for (const card of (Array.isArray(project.cards) ? project.cards : [])) {
1490
+ if (card.type !== 'axiom') continue;
1491
+ const fields = card.fields || {};
1492
+ for (const field of ['applies_when', 'does_not_apply_when', 'failure_risk']) {
1493
+ const issue = fieldIssue(fields, field, CARD_FIELD_RULES.axiom[field] || {});
1494
+ if (issue) issues.push(`${card.id}: ${formatFieldIssue(issue)}`);
1404
1495
  }
1405
1496
  }
1406
- return missing;
1497
+ return issues;
1407
1498
  }
1408
1499
 
1409
1500
  function cardStrictTemplate(type, fields) {
@@ -1487,21 +1578,54 @@ function cmdCard(args) {
1487
1578
  project.stages.judgment_cards.status = 'in_progress';
1488
1579
  }
1489
1580
  writeProject(projectPath, project);
1490
- console.log(`Added card: ${card.id}`);
1581
+ if (args.includes('--json')) {
1582
+ console.log(JSON.stringify({ added: true, card }, null, 2));
1583
+ } else {
1584
+ console.log(`Added card: ${card.id}`);
1585
+ }
1491
1586
  return;
1492
1587
  }
1493
1588
  if (sub === 'update') {
1494
1589
  const projectInput = args[1];
1495
1590
  const cardId = args[2];
1496
- if (!projectInput || !cardId) fail('Usage: kdna-studio card update <project> <card-id> --field key=value');
1591
+ if (!projectInput || !cardId) fail('Usage: kdna-studio card update <project> <card-id> --field key=value OR kdna-studio card update <project> <card-id> --from-file <path.json>');
1497
1592
  const { projectPath, project } = readProject(projectInput);
1498
1593
  const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1499
1594
  if (idx < 0) fail(`Card not found: ${cardId}`);
1500
1595
  const card = project.cards[idx];
1501
1596
  if (card.locked) fail(`Cannot update locked card: ${cardId}. Unlock first with card unlock.`);
1502
- const updates = parseFields(args.slice(3));
1503
- for (const [k, v] of Object.entries(updates)) {
1504
- if (card.fields) card.fields[k] = v;
1597
+ // Bug (UX pro-20 migration): prior version only accepted
1598
+ // --field key=value, so an author migrating 20 source-tree
1599
+ // assets had to run one CLI invocation per field. With
1600
+ // confidence + evidence_type as 8th / 9th required fields on
1601
+ // axiom, a 99-axiom asset became 99 * 2 = 198 invocations.
1602
+ // The fix accepts --from-file <path.json> so an author can
1603
+ // write a single JSON file per card (or per project) and
1604
+ // apply it in one call. JSON shape: {field: value, ...}.
1605
+ // kdna-studio card update . ax_xxx --from-file ./fills/ax_xxx.json
1606
+ // For batched updates across many cards, see the
1607
+ // --from-file-projects variant below, which reads a list
1608
+ // of {id, fields} records.
1609
+ const fromFileIdx = args.indexOf('--from-file');
1610
+ if (fromFileIdx >= 0) {
1611
+ const filePath = args[fromFileIdx + 1];
1612
+ if (!filePath) fail('--from-file requires a path');
1613
+ if (!fs.existsSync(filePath)) fail(`--from-file: file not found: ${filePath}`);
1614
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1615
+ if (typeof data !== 'object' || Array.isArray(data) || data === null) {
1616
+ fail(`--from-file: expected a JSON object mapping field names to values`);
1617
+ }
1618
+ for (const [k, v] of Object.entries(data)) {
1619
+ if (k === 'id' || k === 'type' || k === 'status' || k === 'locked') {
1620
+ fail(`--from-file: cannot edit reserved field "${k}"`);
1621
+ }
1622
+ if (card.fields) card.fields[k] = v;
1623
+ }
1624
+ } else {
1625
+ const updates = parseFields(args.slice(3));
1626
+ for (const [k, v] of Object.entries(updates)) {
1627
+ if (card.fields) card.fields[k] = v;
1628
+ }
1505
1629
  }
1506
1630
  project.cards[idx] = card;
1507
1631
  writeProject(projectPath, project);
@@ -1650,14 +1774,32 @@ function cmdLock(args) {
1650
1774
  }
1651
1775
 
1652
1776
  function cmdMigrate(args) {
1777
+ // Bug (UX pro-20 migration): prior version of `migrate` always
1778
+ // attempted the full pipeline — read source folder, import to
1779
+ // Studio project, lock all cards, then export. An author migrating
1780
+ // 20 source-tree assets had no way to know in advance whether
1781
+ // the assets would pass the v1.7.2+ Human Lock gate. The fix
1782
+ // adds `--check` which runs the import + the critical-missing
1783
+ // check + the lock-gate check, then prints a report and exits
1784
+ // without writing the .kdna file. Pair with `audit-locks` for
1785
+ // the full pre-migration report.
1786
+ //
1787
+ // Usage:
1788
+ // kdna-studio migrate <source-dir> --check --name <@scope/name>
1789
+ //
1790
+ // Exit codes:
1791
+ // 0 — would succeed
1792
+ // 2 — INPUT_ERROR (missing args)
1793
+ // 4 — would fail (Human Lock gate or critical axiom field)
1794
+ const checkOnly = args.includes('--check');
1653
1795
  const sourceDir = args[0];
1654
1796
  const out = option(args, '--out');
1655
1797
  const by = option(args, '--by');
1656
1798
  const statement = option(args, '--statement');
1657
1799
  const requestedName = option(args, '--name');
1658
1800
  let name = requestedName || path.basename(path.resolve(sourceDir || '.'));
1659
- if (!sourceDir || !out || !by || !statement) {
1660
- fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--sign] [--passphrase <pw>|--passphrase-stdin]');
1801
+ if (!sourceDir || (!out && !checkOnly) || (!by && !checkOnly) || (!statement && !checkOnly)) {
1802
+ fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--check] [--sign] [--passphrase <pw>|--passphrase-stdin]');
1661
1803
  }
1662
1804
 
1663
1805
  const sourcePath = path.resolve(sourceDir);
@@ -1683,7 +1825,7 @@ function cmdMigrate(args) {
1683
1825
  if (!requestedName && project.name) name = project.name;
1684
1826
  } else {
1685
1827
  tmpDir = trackTempDir(fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-migrate-')));
1686
- const imported = importFromFolder(sourceDir, tmpDir, name, creatorIdentity);
1828
+ const imported = importFromFolder(sourceDir, tmpDir, name, creatorIdentity, { silent: true });
1687
1829
  project = imported.project;
1688
1830
  projectPath = path.join(tmpDir, 'studio.project.json');
1689
1831
  }
@@ -1695,17 +1837,8 @@ function cmdMigrate(args) {
1695
1837
  // always failing.
1696
1838
  const allowIncompleteEarly = args.includes('--allow-incomplete');
1697
1839
 
1698
- // Reject if critical judgment fields are missing from axioms
1699
- const criticalMissing = [];
1700
- for (const card of (Array.isArray(project.cards) ? project.cards : [])) {
1701
- if (card.type !== 'axiom') continue;
1702
- for (const field of ['applies_when', 'does_not_apply_when', 'failure_risk']) {
1703
- const val = card.fields && card.fields[field];
1704
- if (!val || (Array.isArray(val) && val.length === 0) || val === '') {
1705
- criticalMissing.push(`${card.id}: missing ${field}`);
1706
- }
1707
- }
1708
- }
1840
+ // Reject if critical judgment fields are missing or malformed on axioms.
1841
+ const criticalMissing = criticalAxiomIssues(project);
1709
1842
  if (criticalMissing.length > 0) {
1710
1843
  if (allowIncompleteEarly) {
1711
1844
  console.warn(
@@ -1724,6 +1857,41 @@ function cmdMigrate(args) {
1724
1857
  }
1725
1858
  }
1726
1859
 
1860
+ // --check branch: print a pre-migration report and exit. The
1861
+ // project object already has every card from the source tree
1862
+ // imported, so we can run the same audit-locks logic here
1863
+ // without re-reading the source. This is the single-call
1864
+ // equivalent of `kdna-studio create --from-folder <src> &&
1865
+ // kdna-studio audit-locks .` — but it requires no
1866
+ // intermediate write.
1867
+ if (checkOnly) {
1868
+ const REQUIRED = {
1869
+ axiom: ['confidence', 'evidence_type', 'one_sentence', 'full_statement', 'why',
1870
+ 'applies_when', 'does_not_apply_when', 'failure_risk'],
1871
+ risk: ['name', 'description', 'mitigation'],
1872
+ stance: ['statement', 'applies_when'],
1873
+ misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
1874
+ boundary: ['scope', 'out_of_scope'],
1875
+ };
1876
+ const summary = { project: name, source: sourceDir, total: project.cards.length, by_type: {}, would_block: false };
1877
+ for (const card of (project.cards || [])) {
1878
+ const req = REQUIRED[card.type];
1879
+ if (!req) continue;
1880
+ const missing = [];
1881
+ for (const f of req) {
1882
+ const issue = fieldIssue(card.fields || {}, f, CARD_FIELD_RULES[card.type]?.[f] || {});
1883
+ if (issue) missing.push(formatFieldIssue(issue));
1884
+ }
1885
+ if (missing.length > 0) {
1886
+ if (!summary.by_type[card.type]) summary.by_type[card.type] = [];
1887
+ summary.by_type[card.type].push({ id: card.id, missing });
1888
+ summary.would_block = true;
1889
+ }
1890
+ }
1891
+ console.log(JSON.stringify(summary, null, 2));
1892
+ process.exit(summary.would_block ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
1893
+ }
1894
+
1727
1895
  // Step 2: approve and Human Lock all cards
1728
1896
  let locked = 0;
1729
1897
  for (let i = 0; i < (project.cards || []).length; i++) {
@@ -1819,6 +1987,25 @@ function exportProjectV1(project, name, outPath, opts = {}) {
1819
1987
  if (!exportRuntime || typeof exportRuntime.exportRuntimeAsset !== 'function') {
1820
1988
  fail('@aikdna/kdna-studio-core with exportRuntime.exportRuntimeAsset is required for v1 export.', 2);
1821
1989
  }
1990
+ const criticalIssues = criticalAxiomIssues(project);
1991
+ if (criticalIssues.length > 0) {
1992
+ if (opts.allowIncomplete) {
1993
+ console.warn(
1994
+ `--allow-incomplete: bypassing ${criticalIssues.length} critical axiom-field check(s). ` +
1995
+ `The exported .kdna will load but kdna-loader may not match this domain on those signals.`,
1996
+ );
1997
+ } else {
1998
+ fail(
1999
+ `Cannot export v1: ${criticalIssues.length} critical axiom field issue(s).\n` +
2000
+ ` These fields are required for domain routing (kdna-loader uses them to\n` +
2001
+ ` decide when to load this domain). Fix them first, or pass\n` +
2002
+ ` --allow-incomplete to bypass.\n` +
2003
+ ` Issues:\n ` + criticalIssues.slice(0, 10).join('\n ') +
2004
+ (criticalIssues.length > 10 ? `\n ... and ${criticalIssues.length - 10} more` : ''),
2005
+ EXIT.HUMAN_LOCK_REQUIRED,
2006
+ );
2007
+ }
2008
+ }
1822
2009
  const gate = projectApi.checkHumanLockGate(project);
1823
2010
  if (gate.blocked) {
1824
2011
  if (opts.allowIncomplete) {
@@ -1996,7 +2183,7 @@ function manifestForSigning(manifest) {
1996
2183
 
1997
2184
  function canonicalPayload(files) {
1998
2185
  return ['mimetype', ...Object.keys(files).filter((k) => k !== 'mimetype').sort()]
1999
- .filter((name) => name !== 'signature.json' && name !== '.DS_Store')
2186
+ .filter((name) => name !== 'signature.json' && name !== '.DS_Store' && name !== 'build-receipt.json' && !name.startsWith('reports/'))
2000
2187
  .map((name) => {
2001
2188
  let content = name === 'mimetype' ? 'application/vnd.aikdna.kdna+zip' : files[name];
2002
2189
  if (name.endsWith('.json')) {
@@ -2093,6 +2280,25 @@ function cmdExport(args) {
2093
2280
  return;
2094
2281
  }
2095
2282
  const { project, result } = compileProject(projectInput);
2283
+ const criticalIssues = criticalAxiomIssues(project);
2284
+ if (criticalIssues.length > 0) {
2285
+ if (args.includes('--allow-incomplete')) {
2286
+ console.warn(
2287
+ `--allow-incomplete: bypassing ${criticalIssues.length} critical axiom-field check(s). ` +
2288
+ `The exported .kdna will load but kdna-loader may not match this domain on those signals.`,
2289
+ );
2290
+ } else {
2291
+ fail(
2292
+ `Cannot export: ${criticalIssues.length} critical axiom field issue(s).\n` +
2293
+ ` These fields are required for domain routing (kdna-loader uses them to\n` +
2294
+ ` decide when to load this domain). Fix them first, or pass\n` +
2295
+ ` --allow-incomplete to bypass.\n` +
2296
+ ` Issues:\n ` + criticalIssues.slice(0, 10).join('\n ') +
2297
+ (criticalIssues.length > 10 ? `\n ... and ${criticalIssues.length - 10} more` : ''),
2298
+ EXIT.HUMAN_LOCK_REQUIRED,
2299
+ );
2300
+ }
2301
+ }
2096
2302
  const files = { ...result.files };
2097
2303
  files['README.md'] = compileApi.generateReadme(project);
2098
2304
  files.LICENSE = project.license?.type || 'UNSPECIFIED';
@@ -2167,15 +2373,16 @@ function cmdStudioInstall(args) {
2167
2373
  function cmdStudioUpdate(args) {
2168
2374
  const target = args[0];
2169
2375
  if (!target) fail('Usage: kdna-studio update <@scope/name>');
2170
- try {
2171
- const result = require('child_process').spawnSync('kdna', ['update', target], {
2172
- stdio: 'inherit', encoding: 'utf8',
2173
- });
2174
- if (result.status !== 0) process.exit(result.status);
2175
- } catch (e) {
2176
- console.error('Error spawning kdna CLI:', e.message);
2177
- fail('kdna CLI not found. Install with: npm install -g @aikdna/kdna-cli');
2178
- }
2376
+ // kdna update does not exist in kdna-cli v0.28.x. The correct
2377
+ // path is to re-install via npm or use kdna install to pull
2378
+ // the latest published version of the asset.
2379
+ console.log(`To update ${target}, run:`);
2380
+ console.log(` npm install -g @aikdna/kdna-cli@latest`);
2381
+ console.log(` kdna install ${target}`);
2382
+ console.log(``);
2383
+ console.log(`Or for a project-local install:`);
2384
+ console.log(` cd <project-dir> && npm install @aikdna/kdna-cli@latest`);
2385
+ process.exit(0);
2179
2386
  }
2180
2387
 
2181
2388
  function cmdLlm(args) {
@@ -2250,6 +2457,140 @@ function cmdReport(args) {
2250
2457
  process.exit(gate.blocked ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
2251
2458
  }
2252
2459
 
2460
+ // Audit-locks: list every card that would fail the v1.7.2+ Human Lock
2461
+ // gate because of missing or empty fields. Used during the
2462
+ // pre-migration review of source-tree assets (e.g. pro-20) so an
2463
+ // author can fill in the missing confidence / evidence_type / risk
2464
+ // description / stance statement BEFORE running `migrate --format v1`.
2465
+ //
2466
+ // Bug (UX pro-20 migration): prior version of `kdna-studio report`
2467
+ // returned a flat human_lock_gate.issues list, which mixed card
2468
+ // types and field names. An author migrating 20 source-tree
2469
+ // assets with 99 axioms + 140 risk cards + 70 stance cards had no
2470
+ // way to see, for a single asset, "which axioms are missing
2471
+ // confidence?" — they had to grep the project JSON. The fix adds
2472
+ // a per-type, per-field breakdown.
2473
+ function cmdAuditLocks(args) {
2474
+ const { project } = readProject(args[0]);
2475
+ const useJson = args.includes('--json');
2476
+ const onlyType = option(args, '--type'); // 'axiom' | 'risk' | 'stance' | 'misunderstanding' | null
2477
+
2478
+ const cards = project.cards || [];
2479
+ const findings = {
2480
+ project: project.name,
2481
+ card_count: cards.length,
2482
+ locked_count: cards.filter(c => c.locked).length,
2483
+ by_type: {},
2484
+ };
2485
+
2486
+ // Per-card-type required field lists. Mirrors the v1.7.2+ lockCard
2487
+ // gate (kdna-studio-core/src/cards/index.js) so the output is
2488
+ // exactly the field set the gate will reject.
2489
+ const REQUIRED = {
2490
+ axiom: ['one_sentence', 'full_statement', 'why', 'applies_when',
2491
+ 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type'],
2492
+ risk: ['name', 'description', 'mitigation'],
2493
+ stance: ['statement', 'applies_when'],
2494
+ misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
2495
+ boundary: ['scope', 'out_of_scope'],
2496
+ self_check: ['question'],
2497
+ };
2498
+ const THRESHOLDS = {
2499
+ axiom: {
2500
+ one_sentence: { min: 5, type: 'string' },
2501
+ full_statement: { min: 20, type: 'string' },
2502
+ why: { min: 20, type: 'string' },
2503
+ applies_when: { type: 'array', minLength: 1 },
2504
+ does_not_apply_when: { type: 'array', minLength: 1 },
2505
+ failure_risk: { min: 1, type: 'string' },
2506
+ confidence: { allowed: ['low', 'medium', 'high'] },
2507
+ evidence_type: { allowed: ['case_observation', 'theoretical', 'empirical', 'analogy', 'principle', 'practice'] },
2508
+ },
2509
+ risk: {
2510
+ name: { min: 1, type: 'string' },
2511
+ description: { min: 20, type: 'string' },
2512
+ mitigation: { min: 1, type: 'string' },
2513
+ },
2514
+ stance: {
2515
+ statement: { min: 1, type: 'string' },
2516
+ applies_when: { type: 'array', minLength: 0 },
2517
+ },
2518
+ };
2519
+
2520
+ function checkField(type, fields, name, rule) {
2521
+ const value = fields[name];
2522
+ if (value === undefined || value === null || value === '') {
2523
+ return { field: name, issue: 'missing' };
2524
+ }
2525
+ if (rule.type === 'string' && rule.min && String(value).length < rule.min) {
2526
+ return { field: name, issue: 'too_short', have_length: String(value).length, need_min: rule.min };
2527
+ }
2528
+ if (rule.allowed && !rule.allowed.includes(value)) {
2529
+ return { field: name, issue: 'invalid_value', have: value, allowed: rule.allowed };
2530
+ }
2531
+ if (rule.type === 'array' && !Array.isArray(value)) {
2532
+ return { field: name, issue: 'wrong_type', have_type: typeof value, need_type: 'array' };
2533
+ }
2534
+ if (rule.type === 'array' && rule.minLength && value.length < rule.minLength) {
2535
+ return { field: name, issue: 'too_short', have_length: value.length, need_min: rule.minLength };
2536
+ }
2537
+ return null;
2538
+ }
2539
+
2540
+ for (const card of cards) {
2541
+ const req = REQUIRED[card.type];
2542
+ if (!req) continue;
2543
+ if (onlyType && card.type !== onlyType) continue;
2544
+ const rule = THRESHOLDS[card.type] || {};
2545
+ const missing = [];
2546
+ for (const name of req) {
2547
+ const issue = checkField(card.type, card.fields || {}, name, rule[name] || {});
2548
+ if (issue) missing.push(issue);
2549
+ }
2550
+ if (missing.length > 0) {
2551
+ if (!findings.by_type[card.type]) {
2552
+ findings.by_type[card.type] = { missing_count: 0, cards: [] };
2553
+ }
2554
+ findings.by_type[card.type].missing_count += 1;
2555
+ findings.by_type[card.type].cards.push({
2556
+ id: card.id,
2557
+ one_sentence: card.fields?.one_sentence || card.fields?.statement || card.fields?.question || '',
2558
+ missing,
2559
+ });
2560
+ }
2561
+ }
2562
+
2563
+ if (useJson) {
2564
+ console.log(JSON.stringify(findings, null, 2));
2565
+ } else {
2566
+ console.log(`Project: ${findings.project}`);
2567
+ console.log(`Cards: ${findings.card_count} (locked: ${findings.locked_count})`);
2568
+ const types = Object.keys(findings.by_type);
2569
+ if (types.length === 0) {
2570
+ console.log('\nAll cards pass the v1.7.2+ Human Lock gate. Ready to migrate.');
2571
+ return;
2572
+ }
2573
+ for (const t of types) {
2574
+ const block = findings.by_type[t];
2575
+ console.log(`\n${t}: ${block.missing_count} card(s) with missing fields`);
2576
+ for (const card of block.cards) {
2577
+ console.log(` ${card.id} ${(card.one_sentence || '').slice(0, 60)}`);
2578
+ for (const m of card.missing) {
2579
+ if (m.issue === 'missing') {
2580
+ console.log(` - ${m.field}: missing`);
2581
+ } else if (m.issue === 'too_short') {
2582
+ console.log(` - ${m.field}: too short (have ${m.have_length}, need ≥ ${m.need_min})`);
2583
+ } else if (m.issue === 'invalid_value') {
2584
+ console.log(` - ${m.field}: invalid value "${m.have}", allowed: ${m.allowed.join('|')}`);
2585
+ } else {
2586
+ console.log(` - ${m.field}: ${m.issue}`);
2587
+ }
2588
+ }
2589
+ }
2590
+ }
2591
+ }
2592
+ }
2593
+
2253
2594
  // ─── Distillation Commands ──────────────────────────────────────────
2254
2595
 
2255
2596
  function cmdTarget(args) {
@@ -2504,6 +2845,7 @@ async function cmdFeynman(args) {
2504
2845
  const projectInput = args[0];
2505
2846
  const cardId = args[1];
2506
2847
  if (!projectInput || !cardId) fail('Usage: kdna-studio feynman <project> <card-id>');
2848
+ const useJson = args.includes('--json');
2507
2849
  const { projectPath, project } = readProject(projectInput);
2508
2850
  const cardIdx = (project.cards || []).findIndex(c => c.id === cardId);
2509
2851
  if (cardIdx < 0) fail(`Card not found: ${cardId}`);
@@ -2539,7 +2881,7 @@ async function cmdFeynman(args) {
2539
2881
  console.warn(`No feynman_restatement on ${cardId}; using synthesised:\n "${synthesised}"`);
2540
2882
  }
2541
2883
 
2542
- console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2884
+ if (!useJson) console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2543
2885
 
2544
2886
  // Bug (#1 UX follow-up): if no LLM is configured and the caller
2545
2887
  // did not pass --no-llm, route through cmdNeedsLlm which either
@@ -2568,14 +2910,26 @@ async function cmdFeynman(args) {
2568
2910
  const score = result.score || 0;
2569
2911
  const passed = score >= 4;
2570
2912
 
2571
- console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2572
- for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2573
- const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2574
- console.log(` ${r} ${criterion}`);
2575
- }
2576
- if (result.suggestions && result.suggestions.length > 0) {
2577
- console.log('\nSuggestions:');
2578
- result.suggestions.forEach(s => console.log(` - ${s}`));
2913
+ if (useJson) {
2914
+ console.log(JSON.stringify({
2915
+ card_id: cardId,
2916
+ score,
2917
+ passed,
2918
+ no_llm: !!llmGate.noLlm,
2919
+ criteria: result.criteria || {},
2920
+ suggestions: result.suggestions || [],
2921
+ feynman_restatement: card.feynman_restatement,
2922
+ }, null, 2));
2923
+ } else {
2924
+ console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2925
+ for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2926
+ const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2927
+ console.log(` ${r} ${criterion}`);
2928
+ }
2929
+ if (result.suggestions && result.suggestions.length > 0) {
2930
+ console.log('\nSuggestions:');
2931
+ result.suggestions.forEach(s => console.log(` - ${s}`));
2932
+ }
2579
2933
  }
2580
2934
 
2581
2935
  card.feynman_evaluation = { score, criteria: result.criteria, suggestions: result.suggestions, evaluated_at: new Date().toISOString() };
@@ -2677,6 +3031,7 @@ try {
2677
3031
  else if (cmd === 'llm') cmdLlm(args.slice(1));
2678
3032
  else if (cmd === 'identity') cmdIdentity(args.slice(1));
2679
3033
  else if (cmd === 'report') cmdReport(args.slice(1));
3034
+ else if (cmd === 'audit-locks') cmdAuditLocks(args.slice(1));
2680
3035
  else if (cmd === 'install') cmdStudioInstall(args.slice(1));
2681
3036
  else if (cmd === 'update') cmdStudioUpdate(args.slice(1));
2682
3037
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
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,12 +39,12 @@
39
39
  "node": ">=18"
40
40
  },
41
41
  "dependencies": {
42
- "@aikdna/kdna-studio-core": "^1.7.6",
42
+ "@aikdna/kdna-studio-core": "^1.7.7",
43
43
  "ajv": "^8.20.0",
44
44
  "ajv-formats": "^3.0.1",
45
45
  "cbor-x": "^1.6.4"
46
46
  },
47
47
  "devDependencies": {
48
- "@aikdna/kdna-core": "^0.15.5"
48
+ "@aikdna/kdna-core": "^0.15.9"
49
49
  }
50
50
  }
package/src/ai/feynman.js CHANGED
@@ -43,7 +43,9 @@ async function evaluate(config, card, options = {}) {
43
43
  const axiomText = fields.one_sentence || fields.full_statement || fields.question || '';
44
44
  const restatement = card.feynman_restatement
45
45
  || fields.feynman_restatement
46
- || fields.feynman_text
46
+ // NOTE: fields.feynman_text was previously in this fallback chain
47
+ // but is intentionally excluded — no code path in @aikdna writes
48
+ // feynman_text. The canonical field is feynman_restatement.
47
49
  || '';
48
50
 
49
51
  if (!restatement) {