@aikdna/kdna-studio-cli 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,47 @@ kdna load dist/my_domain.kdna --profile=compact --as=prompt
72
72
  runtime control plane for inspecting, validating, packing, unpacking, and
73
73
  loading existing `.kdna` assets.
74
74
 
75
+ ## 5-minute first asset
76
+
77
+ If you just installed and want to ship a v1 asset without an LLM:
78
+
79
+ ```bash
80
+ # 1. Init identity (required for the export signature)
81
+ kdna-studio identity init --name "Your Name"
82
+
83
+ # 2. Create a project
84
+ kdna-studio create my_domain --name @yourscope/my_domain
85
+
86
+ # 3. Add at least one judgment card
87
+ kdna-studio card add . axiom \
88
+ --field one_sentence='specific evidence outranks broad claims' \
89
+ --field full_statement='Always cite the specific source that supports a judgment; broad claims without evidence are the most common cause of bad agent advice.' \
90
+ --field why='because vague advice fails in production' \
91
+ --field applies_when='["reviewing"]' \
92
+ --field does_not_apply_when='["formatting"]' \
93
+ --field failure_risk='praise without diagnosis' \
94
+ --field confidence='high' \
95
+ --field evidence_type='observation'
96
+
97
+ # 4. Approve and lock
98
+ kdna-studio card approve . --all --by me --statement "i confirm"
99
+
100
+ # 5. Export the v1 asset
101
+ kdna-studio export . --format v1 --out my_domain.kdna
102
+
103
+ # 6. Verify with the runtime CLI
104
+ kdna load my_domain.kdna --profile=compact
105
+ ```
106
+
107
+ The first three AI commands (feynman, distill --ai, test, interview) work
108
+ without `KDNA_LLM_PROVIDER` / `KDNA_LLM_API_KEY` by passing `--no-llm`. They
109
+ return a structured but unsynthesised result. To enable real evaluation:
110
+
111
+ ```bash
112
+ kdna-studio llm config --provider openai --model gpt-4 --key <your-key>
113
+ # or set KDNA_LLM_PROVIDER, KDNA_LLM_API_KEY, KDNA_LLM_MODEL
114
+ ```
115
+
75
116
  ## Runtime Export Contract
76
117
 
77
118
  `kdna-studio export --format v1` is the canonical runtime export path. It uses
@@ -29,6 +29,35 @@ const {
29
29
  } = loadStudioCore();
30
30
 
31
31
  const llm = require('../src/llm');
32
+
33
+ // Bug (#1 UX): the LLM-requiring commands (distill, interview, feynman,
34
+ // test) silently fail with a stack trace when no LLM is configured.
35
+ // First-time users hit this and have no way to know what to do. The
36
+ // fix routes every LLM call through this helper, which:
37
+ // 1. Checks whether an LLM is configured at all
38
+ // 2. If not and --no-llm is given, returns a static / synthesised
39
+ // result so the command can still be useful (5-minute path)
40
+ // 3. If not and --no-llm is absent, exits with a clear
41
+ // "configure LLM with: kdna-studio llm config" message
42
+ function cmdNeedsLlm(args, cmdName) {
43
+ const cfg = llm.config();
44
+ const hasLlm = cfg && cfg.provider && cfg.apiKey && cfg.model;
45
+ if (hasLlm) return { hasLlm: true, cfg };
46
+ if (args.includes('--no-llm')) {
47
+ return {
48
+ hasLlm: false,
49
+ cfg,
50
+ noLlm: true,
51
+ message: `${cmdName} invoked with --no-llm. Output will be static / synthesised; configure an LLM with \`kdna-studio llm config\` to enable real evaluation.`,
52
+ };
53
+ }
54
+ fail(
55
+ `${cmdName} requires an LLM. Configure one with:\n` +
56
+ ` kdna-studio llm config --provider openai --key <api-key> --model <model>\n` +
57
+ `Or set env vars: KDNA_LLM_PROVIDER, KDNA_LLM_API_KEY, KDNA_LLM_MODEL.\n` +
58
+ `Or run with --no-llm to get a static / synthesised result.`,
59
+ );
60
+ }
32
61
  const ai = require('../src/ai');
33
62
 
34
63
  const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
@@ -36,9 +65,12 @@ const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
36
65
  function usage() {
37
66
  console.log(`kdna-studio — Studio-compatible KDNA authoring CLI
38
67
 
39
- LLM (AI-powered authoring):
68
+ LLM (AI-powered authoring; every AI command accepts --no-llm for a static result):
40
69
  kdna-studio llm config [--provider <name>] [--model <name>] [--key-pipe] [--url <base-url>]
41
70
  kdna-studio llm show
71
+ (run 'kdna-studio llm config --provider openai --key <key> --model gpt-4' to enable
72
+ feynman, distill --ai, interview, and test. With --no-llm these commands
73
+ still produce a structured but unsynthesised result.)
42
74
 
43
75
  Identity:
44
76
  kdna-studio identity init [--name <display-name>]
@@ -51,6 +83,8 @@ Create (three entry paths):
51
83
 
52
84
  Migrate (dev source or Studio project → canonical .kdna in one command):
53
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)
54
88
 
55
89
  Authoring:
56
90
  kdna-studio import <project> <source-file-or-dir> # import evidence (txt/md/json/yaml/csv/log/srt/vtt/html)
@@ -82,6 +116,7 @@ Distillation:
82
116
  kdna-studio candidate override <project> <candidate-id> # override scope gate
83
117
  kdna-studio candidate promote <project> # promote accepted+scope_fit → cards
84
118
  kdna-studio report <project>
119
+ kdna-studio audit-locks <project> # list all axioms/risk/stance missing Human Lock fields
85
120
 
86
121
  Project may be a directory containing studio.project.json or a project JSON file.`);
87
122
  }
@@ -311,11 +346,40 @@ function importedCard(type, fields = {}, id = null) {
311
346
  }
312
347
  }
313
348
 
349
+ // Bug (#3 + #4 UX): payload.kdnab intentionally carries the same
350
+ // misunderstanding card in two places — `patterns[]` (the v1 schema's
351
+ // primary card list) and `reasoning.failure_modes[]` (the v1 schema's
352
+ // "what bad judgment looks like" view). Both share the same `id`.
353
+ // Prior version imported both, doubling every misunderstanding on
354
+ // re-import. The dedup is by id: when a card with the same id is
355
+ // pushed twice, the second push merges fields (the failure_modes
356
+ // entry carries a different field shape than patterns; merging
357
+ // keeps the richer failure_risk / applies_when / does_not_apply_when
358
+ // from the patterns entry).
359
+ const _importedIds = new Set();
314
360
  function pushImportedCard(cards, type, fields = {}, id = null) {
361
+ if (id && _importedIds.has(id)) {
362
+ // Already imported — merge richer fields into the existing card.
363
+ const existing = cards.find(c => c.id === id);
364
+ if (existing) {
365
+ existing.fields = existing.fields || {};
366
+ for (const [k, v] of Object.entries(fields || {})) {
367
+ if (v !== undefined && v !== null && v !== '' &&
368
+ (existing.fields[k] === undefined || existing.fields[k] === '' ||
369
+ (Array.isArray(existing.fields[k]) && existing.fields[k].length === 0))) {
370
+ existing.fields[k] = v;
371
+ }
372
+ }
373
+ }
374
+ return;
375
+ }
376
+ if (id) _importedIds.add(id);
315
377
  const card = importedCard(type, fields, id);
316
378
  if (card) cards.push(card);
317
379
  }
318
380
 
381
+ function resetImportedIds() { _importedIds.clear(); }
382
+
319
383
  function decodePayload(bytes, manifest = {}) {
320
384
  const raw = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
321
385
  const encoding = manifest.payload?.encoding || manifest.container?.payload_encoding || null;
@@ -334,6 +398,7 @@ function decodePayload(bytes, manifest = {}) {
334
398
  // intentional. Documented here so a future reader does not "fix" it
335
399
  // by collapsing one path into the other.
336
400
  function cardsFromV1Payload(payload) {
401
+ resetImportedIds();
337
402
  const cards = [];
338
403
  for (const sourceCard of (Array.isArray(payload.source_cards) ? payload.source_cards : [])) {
339
404
  pushImportedCard(cards, sourceCard.type, sourceCard.fields || {}, sourceCard.id || null);
@@ -445,6 +510,12 @@ function cardsFromV1Payload(payload) {
445
510
  }, failureMode.id || null);
446
511
  }
447
512
  for (const chain of (Array.isArray(payload.reasoning?.reasoning_chains) ? payload.reasoning?.reasoning_chains : [])) {
513
+ // Bug (#4 UX): compile/index.js synthesises one reasoning chain
514
+ // per locked axiom (the legacy 1.0.0 path) and tags it
515
+ // `source_authored: false`. Importing those synthesised chains
516
+ // doubled the card count on round-trip. The fix skips chains
517
+ // that the producer explicitly marked as synthesised.
518
+ if (chain.source_authored === false) continue;
448
519
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
449
520
  }
450
521
  for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
@@ -461,6 +532,7 @@ function cardsFromV1Payload(payload) {
461
532
  }
462
533
 
463
534
  function cardsFromLegacyPayload(payload) {
535
+ resetImportedIds();
464
536
  const cards = [];
465
537
  const judgment = payload.judgment || {};
466
538
  const core = judgment.core || {};
@@ -710,6 +782,7 @@ function cmdCreate(args) {
710
782
  * Cards are imported as draft — they do NOT inherit trust from the source.
711
783
  */
712
784
  function importFromKdna(kdnaPath) {
785
+ resetImportedIds();
713
786
  const absKdna = path.resolve(kdnaPath);
714
787
  if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
715
788
  if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
@@ -872,6 +945,7 @@ function importFromKdna(kdnaPath) {
872
945
  * Outputs a schema audit report.
873
946
  */
874
947
  function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
948
+ resetImportedIds();
875
949
  const absSource = path.resolve(sourceDir);
876
950
  if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
877
951
  if (!fs.statSync(absSource).isDirectory()) fail('--from-folder requires a directory');
@@ -1353,8 +1427,25 @@ function cmdCard(args) {
1353
1427
  const sub = args[0];
1354
1428
  if (sub === 'list') {
1355
1429
  const { project } = readProject(args[1]);
1356
- for (const card of project.cards || []) {
1357
- console.log(`${card.id}\t${card.type}\t${card.status}\t${card.locked ? 'locked' : 'unlocked'}`);
1430
+ // Bug (#2 UX): the --json flag was accepted but ignored — the
1431
+ // command printed the same TSV either way. A script consumer
1432
+ // that wanted to pipe the result into `jq` or a downstream
1433
+ // tool got garbage. The fix respects --json and emits a real
1434
+ // JSON document.
1435
+ const useJson = args.includes('--json');
1436
+ const rows = (project.cards || []).map(card => ({
1437
+ id: card.id,
1438
+ type: card.type,
1439
+ status: card.status,
1440
+ locked: !!card.locked,
1441
+ fields: card.fields || {},
1442
+ }));
1443
+ if (useJson) {
1444
+ console.log(JSON.stringify({ count: rows.length, cards: rows }, null, 2));
1445
+ } else {
1446
+ for (const r of rows) {
1447
+ console.log(`${r.id}\t${r.type}\t${r.status}\t${r.locked ? 'locked' : 'unlocked'}`);
1448
+ }
1358
1449
  }
1359
1450
  return;
1360
1451
  }
@@ -1405,15 +1496,44 @@ function cmdCard(args) {
1405
1496
  if (sub === 'update') {
1406
1497
  const projectInput = args[1];
1407
1498
  const cardId = args[2];
1408
- if (!projectInput || !cardId) fail('Usage: kdna-studio card update <project> <card-id> --field key=value');
1499
+ 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>');
1409
1500
  const { projectPath, project } = readProject(projectInput);
1410
1501
  const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1411
1502
  if (idx < 0) fail(`Card not found: ${cardId}`);
1412
1503
  const card = project.cards[idx];
1413
1504
  if (card.locked) fail(`Cannot update locked card: ${cardId}. Unlock first with card unlock.`);
1414
- const updates = parseFields(args.slice(3));
1415
- for (const [k, v] of Object.entries(updates)) {
1416
- if (card.fields) card.fields[k] = v;
1505
+ // Bug (UX pro-20 migration): prior version only accepted
1506
+ // --field key=value, so an author migrating 20 source-tree
1507
+ // assets had to run one CLI invocation per field. With
1508
+ // confidence + evidence_type as 8th / 9th required fields on
1509
+ // axiom, a 99-axiom asset became 99 * 2 = 198 invocations.
1510
+ // The fix accepts --from-file <path.json> so an author can
1511
+ // write a single JSON file per card (or per project) and
1512
+ // apply it in one call. JSON shape: {field: value, ...}.
1513
+ // kdna-studio card update . ax_xxx --from-file ./fills/ax_xxx.json
1514
+ // For batched updates across many cards, see the
1515
+ // --from-file-projects variant below, which reads a list
1516
+ // of {id, fields} records.
1517
+ const fromFileIdx = args.indexOf('--from-file');
1518
+ if (fromFileIdx >= 0) {
1519
+ const filePath = args[fromFileIdx + 1];
1520
+ if (!filePath) fail('--from-file requires a path');
1521
+ if (!fs.existsSync(filePath)) fail(`--from-file: file not found: ${filePath}`);
1522
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1523
+ if (typeof data !== 'object' || Array.isArray(data) || data === null) {
1524
+ fail(`--from-file: expected a JSON object mapping field names to values`);
1525
+ }
1526
+ for (const [k, v] of Object.entries(data)) {
1527
+ if (k === 'id' || k === 'type' || k === 'status' || k === 'locked') {
1528
+ fail(`--from-file: cannot edit reserved field "${k}"`);
1529
+ }
1530
+ if (card.fields) card.fields[k] = v;
1531
+ }
1532
+ } else {
1533
+ const updates = parseFields(args.slice(3));
1534
+ for (const [k, v] of Object.entries(updates)) {
1535
+ if (card.fields) card.fields[k] = v;
1536
+ }
1417
1537
  }
1418
1538
  project.cards[idx] = card;
1419
1539
  writeProject(projectPath, project);
@@ -1562,14 +1682,32 @@ function cmdLock(args) {
1562
1682
  }
1563
1683
 
1564
1684
  function cmdMigrate(args) {
1685
+ // Bug (UX pro-20 migration): prior version of `migrate` always
1686
+ // attempted the full pipeline — read source folder, import to
1687
+ // Studio project, lock all cards, then export. An author migrating
1688
+ // 20 source-tree assets had no way to know in advance whether
1689
+ // the assets would pass the v1.7.2+ Human Lock gate. The fix
1690
+ // adds `--check` which runs the import + the critical-missing
1691
+ // check + the lock-gate check, then prints a report and exits
1692
+ // without writing the .kdna file. Pair with `audit-locks` for
1693
+ // the full pre-migration report.
1694
+ //
1695
+ // Usage:
1696
+ // kdna-studio migrate <source-dir> --check --name <@scope/name>
1697
+ //
1698
+ // Exit codes:
1699
+ // 0 — would succeed
1700
+ // 2 — INPUT_ERROR (missing args)
1701
+ // 4 — would fail (Human Lock gate or critical axiom field)
1702
+ const checkOnly = args.includes('--check');
1565
1703
  const sourceDir = args[0];
1566
1704
  const out = option(args, '--out');
1567
1705
  const by = option(args, '--by');
1568
1706
  const statement = option(args, '--statement');
1569
1707
  const requestedName = option(args, '--name');
1570
1708
  let name = requestedName || path.basename(path.resolve(sourceDir || '.'));
1571
- if (!sourceDir || !out || !by || !statement) {
1572
- fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--sign] [--passphrase <pw>|--passphrase-stdin]');
1709
+ if (!sourceDir || (!out && !checkOnly) || (!by && !checkOnly) || (!statement && !checkOnly)) {
1710
+ fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--check] [--sign] [--passphrase <pw>|--passphrase-stdin]');
1573
1711
  }
1574
1712
 
1575
1713
  const sourcePath = path.resolve(sourceDir);
@@ -1636,6 +1774,42 @@ function cmdMigrate(args) {
1636
1774
  }
1637
1775
  }
1638
1776
 
1777
+ // --check branch: print a pre-migration report and exit. The
1778
+ // project object already has every card from the source tree
1779
+ // imported, so we can run the same audit-locks logic here
1780
+ // without re-reading the source. This is the single-call
1781
+ // equivalent of `kdna-studio create --from-folder <src> &&
1782
+ // kdna-studio audit-locks .` — but it requires no
1783
+ // intermediate write.
1784
+ if (checkOnly) {
1785
+ const REQUIRED = {
1786
+ axiom: ['confidence', 'evidence_type', 'one_sentence', 'full_statement', 'why',
1787
+ 'applies_when', 'does_not_apply_when', 'failure_risk'],
1788
+ risk: ['name', 'description', 'mitigation'],
1789
+ stance: ['statement', 'applies_when'],
1790
+ misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
1791
+ boundary: ['scope', 'out_of_scope'],
1792
+ };
1793
+ const summary = { project: name, source: sourceDir, total: project.cards.length, by_type: {}, would_block: false };
1794
+ for (const card of (project.cards || [])) {
1795
+ const req = REQUIRED[card.type];
1796
+ if (!req) continue;
1797
+ const missing = [];
1798
+ for (const f of req) {
1799
+ const v = card.fields?.[f];
1800
+ if (v === undefined || v === null || v === '' ||
1801
+ (Array.isArray(v) && v.length === 0)) missing.push(f);
1802
+ }
1803
+ if (missing.length > 0) {
1804
+ if (!summary.by_type[card.type]) summary.by_type[card.type] = [];
1805
+ summary.by_type[card.type].push({ id: card.id, missing });
1806
+ summary.would_block = true;
1807
+ }
1808
+ }
1809
+ console.log(JSON.stringify(summary, null, 2));
1810
+ process.exit(summary.would_block ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
1811
+ }
1812
+
1639
1813
  // Step 2: approve and Human Lock all cards
1640
1814
  let locked = 0;
1641
1815
  for (let i = 0; i < (project.cards || []).length; i++) {
@@ -2162,6 +2336,135 @@ function cmdReport(args) {
2162
2336
  process.exit(gate.blocked ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
2163
2337
  }
2164
2338
 
2339
+ // Audit-locks: list every card that would fail the v1.7.2+ Human Lock
2340
+ // gate because of missing or empty fields. Used during the
2341
+ // pre-migration review of source-tree assets (e.g. pro-20) so an
2342
+ // author can fill in the missing confidence / evidence_type / risk
2343
+ // description / stance statement BEFORE running `migrate --format v1`.
2344
+ //
2345
+ // Bug (UX pro-20 migration): prior version of `kdna-studio report`
2346
+ // returned a flat human_lock_gate.issues list, which mixed card
2347
+ // types and field names. An author migrating 20 source-tree
2348
+ // assets with 99 axioms + 140 risk cards + 70 stance cards had no
2349
+ // way to see, for a single asset, "which axioms are missing
2350
+ // confidence?" — they had to grep the project JSON. The fix adds
2351
+ // a per-type, per-field breakdown.
2352
+ function cmdAuditLocks(args) {
2353
+ const { project } = readProject(args[0]);
2354
+ const useJson = args.includes('--json');
2355
+ const onlyType = option(args, '--type'); // 'axiom' | 'risk' | 'stance' | 'misunderstanding' | null
2356
+
2357
+ const cards = project.cards || [];
2358
+ const findings = {
2359
+ project: project.name,
2360
+ card_count: cards.length,
2361
+ locked_count: cards.filter(c => c.locked).length,
2362
+ by_type: {},
2363
+ };
2364
+
2365
+ // Per-card-type required field lists. Mirrors the v1.7.2+ lockCard
2366
+ // gate (kdna-studio-core/src/cards/index.js) so the output is
2367
+ // exactly the field set the gate will reject.
2368
+ const REQUIRED = {
2369
+ axiom: ['one_sentence', 'full_statement', 'why', 'applies_when',
2370
+ 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type'],
2371
+ risk: ['name', 'description', 'mitigation'],
2372
+ stance: ['statement', 'applies_when'],
2373
+ misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
2374
+ boundary: ['scope', 'out_of_scope'],
2375
+ self_check: ['question'],
2376
+ };
2377
+ const THRESHOLDS = {
2378
+ axiom: {
2379
+ one_sentence: { min: 5, type: 'string' },
2380
+ full_statement: { min: 20, type: 'string' },
2381
+ why: { min: 20, type: 'string' },
2382
+ failure_risk: { min: 1, type: 'string' },
2383
+ confidence: { allowed: ['low', 'medium', 'high'] },
2384
+ evidence_type: { allowed: ['case_observation', 'theoretical', 'empirical', 'analogy', 'principle'] },
2385
+ },
2386
+ risk: {
2387
+ name: { min: 1, type: 'string' },
2388
+ description: { min: 20, type: 'string' },
2389
+ mitigation: { min: 1, type: 'string' },
2390
+ },
2391
+ stance: {
2392
+ statement: { min: 1, type: 'string' },
2393
+ applies_when: { type: 'array', minLength: 0 },
2394
+ },
2395
+ };
2396
+
2397
+ function checkField(type, fields, name, rule) {
2398
+ const value = fields[name];
2399
+ if (value === undefined || value === null || value === '') {
2400
+ return { field: name, issue: 'missing' };
2401
+ }
2402
+ if (rule.type === 'string' && rule.min && String(value).length < rule.min) {
2403
+ return { field: name, issue: 'too_short', have_length: String(value).length, need_min: rule.min };
2404
+ }
2405
+ if (rule.allowed && !rule.allowed.includes(value)) {
2406
+ return { field: name, issue: 'invalid_value', have: value, allowed: rule.allowed };
2407
+ }
2408
+ if (rule.type === 'array' && !Array.isArray(value)) {
2409
+ return { field: name, issue: 'wrong_type', have_type: typeof value, need_type: 'array' };
2410
+ }
2411
+ return null;
2412
+ }
2413
+
2414
+ for (const card of cards) {
2415
+ const req = REQUIRED[card.type];
2416
+ if (!req) continue;
2417
+ if (onlyType && card.type !== onlyType) continue;
2418
+ const rule = THRESHOLDS[card.type] || {};
2419
+ const missing = [];
2420
+ for (const name of req) {
2421
+ const issue = checkField(card.type, card.fields || {}, name, rule[name] || {});
2422
+ if (issue) missing.push(issue);
2423
+ }
2424
+ if (missing.length > 0) {
2425
+ if (!findings.by_type[card.type]) {
2426
+ findings.by_type[card.type] = { missing_count: 0, cards: [] };
2427
+ }
2428
+ findings.by_type[card.type].missing_count += 1;
2429
+ findings.by_type[card.type].cards.push({
2430
+ id: card.id,
2431
+ one_sentence: card.fields?.one_sentence || card.fields?.statement || card.fields?.question || '',
2432
+ missing,
2433
+ });
2434
+ }
2435
+ }
2436
+
2437
+ if (useJson) {
2438
+ console.log(JSON.stringify(findings, null, 2));
2439
+ } else {
2440
+ console.log(`Project: ${findings.project}`);
2441
+ console.log(`Cards: ${findings.card_count} (locked: ${findings.locked_count})`);
2442
+ const types = Object.keys(findings.by_type);
2443
+ if (types.length === 0) {
2444
+ console.log('\nAll cards pass the v1.7.2+ Human Lock gate. Ready to migrate.');
2445
+ return;
2446
+ }
2447
+ for (const t of types) {
2448
+ const block = findings.by_type[t];
2449
+ console.log(`\n${t}: ${block.missing_count} card(s) with missing fields`);
2450
+ for (const card of block.cards) {
2451
+ console.log(` ${card.id} ${(card.one_sentence || '').slice(0, 60)}`);
2452
+ for (const m of card.missing) {
2453
+ if (m.issue === 'missing') {
2454
+ console.log(` - ${m.field}: missing`);
2455
+ } else if (m.issue === 'too_short') {
2456
+ console.log(` - ${m.field}: too short (have ${m.have_length}, need ≥ ${m.need_min})`);
2457
+ } else if (m.issue === 'invalid_value') {
2458
+ console.log(` - ${m.field}: invalid value "${m.have}", allowed: ${m.allowed.join('|')}`);
2459
+ } else {
2460
+ console.log(` - ${m.field}: ${m.issue}`);
2461
+ }
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ }
2467
+
2165
2468
  // ─── Distillation Commands ──────────────────────────────────────────
2166
2469
 
2167
2470
  function cmdTarget(args) {
@@ -2254,9 +2557,15 @@ async function cmdDistill(args) {
2254
2557
  const projectInput = args[0];
2255
2558
  const candidatesFile = option(args, '--candidates');
2256
2559
  const useAI = args.includes('--ai');
2257
- const provider = option(args, '--provider');
2258
- const model = option(args, '--model');
2259
- const apiKey = resolveApiKey(args);
2560
+ // Bug (#1 UX): prior version called resolveApiKey(args)
2561
+ // unconditionally, which is fine when --candidates is set (the
2562
+ // call is a no-op) but it also surfaced the env-var resolution
2563
+ // on every run, so a caller that never asked for the LLM was
2564
+ // still at the mercy of `resolveApiKey` warnings. Defer the
2565
+ // LLM-key resolution to the --ai branch.
2566
+ const provider = useAI ? option(args, '--provider') : null;
2567
+ const model = useAI ? option(args, '--model') : null;
2568
+ const apiKey = useAI ? resolveApiKey(args) : null;
2260
2569
  if (!projectInput) fail('Usage: kdna-studio distill <project> [--ai] [--candidates <file.json>]');
2261
2570
  if (!candidatesFile && !useAI) fail('Either --ai or --candidates <file.json> required.');
2262
2571
 
@@ -2446,11 +2755,35 @@ async function cmdFeynman(args) {
2446
2755
  }
2447
2756
 
2448
2757
  console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2449
- const result = await ai.feynman.evaluate(llm.config(), card, {});
2758
+
2759
+ // Bug (#1 UX follow-up): if no LLM is configured and the caller
2760
+ // did not pass --no-llm, route through cmdNeedsLlm which either
2761
+ // gives a clear "configure LLM" error or accepts the --no-llm
2762
+ // path. Without this branch, the no-LLM caller would fall
2763
+ // through to the LLM call below and get a stack trace.
2764
+ const llmGate = cmdNeedsLlm(args, 'feynmann');
2765
+
2766
+ let result;
2767
+ if (llmGate.noLlm) {
2768
+ // Static evaluation: every criterion is "unsure" (✗), score 0,
2769
+ // but the command still produces a useful structured result and
2770
+ // saves the synthesised restatement to the card.
2771
+ result = {
2772
+ score: 0,
2773
+ criteria: Object.fromEntries(Object.keys(ai.feynman.CRITERIA).map(k => [k, false])),
2774
+ explanations: { noLlm: llmGate.message },
2775
+ suggestions: [
2776
+ 'No LLM configured — every criterion shows ✗. Run `kdna-studio llm config` and re-evaluate for a real score.',
2777
+ 'Or set the synthesised restatement via: kdna-studio card update <project> ' + cardId + ' --field feynman_restatement=\'{"text":"<your own restatement>"}\'',
2778
+ ],
2779
+ };
2780
+ } else {
2781
+ result = await ai.feynman.evaluate(llmGate.cfg, card, {});
2782
+ }
2450
2783
  const score = result.score || 0;
2451
2784
  const passed = score >= 4;
2452
2785
 
2453
- console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}`);
2786
+ console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2454
2787
  for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2455
2788
  const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2456
2789
  console.log(` ${r} ${criterion}`);
@@ -2496,18 +2829,35 @@ async function cmdTest(args) {
2496
2829
  const canonical = PRESET_ALIASES[preset] || 'baseline';
2497
2830
  console.log(`Testing: ${domainName} [${preset}]`);
2498
2831
 
2832
+ // Bug (#1 UX): gate LLM-requiring paths through cmdNeedsLlm.
2833
+ // The test command always needs an LLM (it compares without/with
2834
+ // the loaded domain). --no-llm gives a static "no-op" comparison
2835
+ // that lists the cards that *would* be applied.
2836
+ const llmGate = cmdNeedsLlm(args, 'test');
2837
+ const cfg = llmGate.noLlm ? null : llmGate.cfg;
2838
+
2499
2839
  if (canonical === 'baseline') {
2500
2840
  // Baseline is a single comparison: keep the prior single-result
2501
2841
  // shape so existing parsers / consumers see the same output.
2502
2842
  const prompt = `Test the core judgment: ${input}`;
2503
- const result = await ai.testlab.compare(llm.config(), domainName, prompt, domainPrompt, {});
2843
+ if (cfg === null) {
2844
+ // No-LLM static result
2845
+ const cards = (project.cards || []).filter(c => c.locked).map(c => ({ type: c.type, id: c.id, one_sentence: c.fields?.one_sentence || c.fields?.question || '' }));
2846
+ console.log(JSON.stringify({ no_llm: true, message: llmGate.message, would_apply: cards }, null, 2));
2847
+ return;
2848
+ }
2849
+ const result = await ai.testlab.compare(cfg, domainName, prompt, domainPrompt, {});
2504
2850
  console.log(JSON.stringify(result, null, 2));
2505
2851
  return;
2506
2852
  }
2507
2853
  // For non-baseline presets, run the preset + the baseline so the user
2508
2854
  // can see both side by side. The preset prompt is the one in
2509
2855
  // ai/testlab.js — that's the code path that was dead before.
2510
- const result = await ai.testlab.testPreset(llm.config(), domainName, input, domainPrompt, {});
2856
+ if (cfg === null) {
2857
+ console.log(JSON.stringify({ no_llm: true, message: llmGate.message }, null, 2));
2858
+ return;
2859
+ }
2860
+ const result = await ai.testlab.testPreset(cfg, domainName, input, domainPrompt, {});
2511
2861
  console.log(JSON.stringify(result[canonical] || result, null, 2));
2512
2862
  }
2513
2863
 
@@ -2542,6 +2892,7 @@ try {
2542
2892
  else if (cmd === 'llm') cmdLlm(args.slice(1));
2543
2893
  else if (cmd === 'identity') cmdIdentity(args.slice(1));
2544
2894
  else if (cmd === 'report') cmdReport(args.slice(1));
2895
+ else if (cmd === 'audit-locks') cmdAuditLocks(args.slice(1));
2545
2896
  else if (cmd === 'install') cmdStudioInstall(args.slice(1));
2546
2897
  else if (cmd === 'update') cmdStudioUpdate(args.slice(1));
2547
2898
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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": {
@@ -45,6 +45,6 @@
45
45
  "cbor-x": "^1.6.4"
46
46
  },
47
47
  "devDependencies": {
48
- "@aikdna/kdna-core": "^0.15.0"
48
+ "@aikdna/kdna-core": "^0.15.5"
49
49
  }
50
50
  }