@aikdna/kdna-studio-cli 0.8.7 → 0.8.8

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>]
@@ -311,11 +343,40 @@ function importedCard(type, fields = {}, id = null) {
311
343
  }
312
344
  }
313
345
 
346
+ // Bug (#3 + #4 UX): payload.kdnab intentionally carries the same
347
+ // misunderstanding card in two places — `patterns[]` (the v1 schema's
348
+ // primary card list) and `reasoning.failure_modes[]` (the v1 schema's
349
+ // "what bad judgment looks like" view). Both share the same `id`.
350
+ // Prior version imported both, doubling every misunderstanding on
351
+ // re-import. The dedup is by id: when a card with the same id is
352
+ // pushed twice, the second push merges fields (the failure_modes
353
+ // entry carries a different field shape than patterns; merging
354
+ // keeps the richer failure_risk / applies_when / does_not_apply_when
355
+ // from the patterns entry).
356
+ const _importedIds = new Set();
314
357
  function pushImportedCard(cards, type, fields = {}, id = null) {
358
+ if (id && _importedIds.has(id)) {
359
+ // Already imported — merge richer fields into the existing card.
360
+ const existing = cards.find(c => c.id === id);
361
+ if (existing) {
362
+ existing.fields = existing.fields || {};
363
+ for (const [k, v] of Object.entries(fields || {})) {
364
+ if (v !== undefined && v !== null && v !== '' &&
365
+ (existing.fields[k] === undefined || existing.fields[k] === '' ||
366
+ (Array.isArray(existing.fields[k]) && existing.fields[k].length === 0))) {
367
+ existing.fields[k] = v;
368
+ }
369
+ }
370
+ }
371
+ return;
372
+ }
373
+ if (id) _importedIds.add(id);
315
374
  const card = importedCard(type, fields, id);
316
375
  if (card) cards.push(card);
317
376
  }
318
377
 
378
+ function resetImportedIds() { _importedIds.clear(); }
379
+
319
380
  function decodePayload(bytes, manifest = {}) {
320
381
  const raw = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
321
382
  const encoding = manifest.payload?.encoding || manifest.container?.payload_encoding || null;
@@ -334,6 +395,7 @@ function decodePayload(bytes, manifest = {}) {
334
395
  // intentional. Documented here so a future reader does not "fix" it
335
396
  // by collapsing one path into the other.
336
397
  function cardsFromV1Payload(payload) {
398
+ resetImportedIds();
337
399
  const cards = [];
338
400
  for (const sourceCard of (Array.isArray(payload.source_cards) ? payload.source_cards : [])) {
339
401
  pushImportedCard(cards, sourceCard.type, sourceCard.fields || {}, sourceCard.id || null);
@@ -445,6 +507,12 @@ function cardsFromV1Payload(payload) {
445
507
  }, failureMode.id || null);
446
508
  }
447
509
  for (const chain of (Array.isArray(payload.reasoning?.reasoning_chains) ? payload.reasoning?.reasoning_chains : [])) {
510
+ // Bug (#4 UX): compile/index.js synthesises one reasoning chain
511
+ // per locked axiom (the legacy 1.0.0 path) and tags it
512
+ // `source_authored: false`. Importing those synthesised chains
513
+ // doubled the card count on round-trip. The fix skips chains
514
+ // that the producer explicitly marked as synthesised.
515
+ if (chain.source_authored === false) continue;
448
516
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
449
517
  }
450
518
  for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
@@ -461,6 +529,7 @@ function cardsFromV1Payload(payload) {
461
529
  }
462
530
 
463
531
  function cardsFromLegacyPayload(payload) {
532
+ resetImportedIds();
464
533
  const cards = [];
465
534
  const judgment = payload.judgment || {};
466
535
  const core = judgment.core || {};
@@ -710,6 +779,7 @@ function cmdCreate(args) {
710
779
  * Cards are imported as draft — they do NOT inherit trust from the source.
711
780
  */
712
781
  function importFromKdna(kdnaPath) {
782
+ resetImportedIds();
713
783
  const absKdna = path.resolve(kdnaPath);
714
784
  if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
715
785
  if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
@@ -872,6 +942,7 @@ function importFromKdna(kdnaPath) {
872
942
  * Outputs a schema audit report.
873
943
  */
874
944
  function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
945
+ resetImportedIds();
875
946
  const absSource = path.resolve(sourceDir);
876
947
  if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
877
948
  if (!fs.statSync(absSource).isDirectory()) fail('--from-folder requires a directory');
@@ -1353,8 +1424,25 @@ function cmdCard(args) {
1353
1424
  const sub = args[0];
1354
1425
  if (sub === 'list') {
1355
1426
  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'}`);
1427
+ // Bug (#2 UX): the --json flag was accepted but ignored — the
1428
+ // command printed the same TSV either way. A script consumer
1429
+ // that wanted to pipe the result into `jq` or a downstream
1430
+ // tool got garbage. The fix respects --json and emits a real
1431
+ // JSON document.
1432
+ const useJson = args.includes('--json');
1433
+ const rows = (project.cards || []).map(card => ({
1434
+ id: card.id,
1435
+ type: card.type,
1436
+ status: card.status,
1437
+ locked: !!card.locked,
1438
+ fields: card.fields || {},
1439
+ }));
1440
+ if (useJson) {
1441
+ console.log(JSON.stringify({ count: rows.length, cards: rows }, null, 2));
1442
+ } else {
1443
+ for (const r of rows) {
1444
+ console.log(`${r.id}\t${r.type}\t${r.status}\t${r.locked ? 'locked' : 'unlocked'}`);
1445
+ }
1358
1446
  }
1359
1447
  return;
1360
1448
  }
@@ -2254,9 +2342,15 @@ async function cmdDistill(args) {
2254
2342
  const projectInput = args[0];
2255
2343
  const candidatesFile = option(args, '--candidates');
2256
2344
  const useAI = args.includes('--ai');
2257
- const provider = option(args, '--provider');
2258
- const model = option(args, '--model');
2259
- const apiKey = resolveApiKey(args);
2345
+ // Bug (#1 UX): prior version called resolveApiKey(args)
2346
+ // unconditionally, which is fine when --candidates is set (the
2347
+ // call is a no-op) but it also surfaced the env-var resolution
2348
+ // on every run, so a caller that never asked for the LLM was
2349
+ // still at the mercy of `resolveApiKey` warnings. Defer the
2350
+ // LLM-key resolution to the --ai branch.
2351
+ const provider = useAI ? option(args, '--provider') : null;
2352
+ const model = useAI ? option(args, '--model') : null;
2353
+ const apiKey = useAI ? resolveApiKey(args) : null;
2260
2354
  if (!projectInput) fail('Usage: kdna-studio distill <project> [--ai] [--candidates <file.json>]');
2261
2355
  if (!candidatesFile && !useAI) fail('Either --ai or --candidates <file.json> required.');
2262
2356
 
@@ -2446,11 +2540,35 @@ async function cmdFeynman(args) {
2446
2540
  }
2447
2541
 
2448
2542
  console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2449
- const result = await ai.feynman.evaluate(llm.config(), card, {});
2543
+
2544
+ // Bug (#1 UX follow-up): if no LLM is configured and the caller
2545
+ // did not pass --no-llm, route through cmdNeedsLlm which either
2546
+ // gives a clear "configure LLM" error or accepts the --no-llm
2547
+ // path. Without this branch, the no-LLM caller would fall
2548
+ // through to the LLM call below and get a stack trace.
2549
+ const llmGate = cmdNeedsLlm(args, 'feynmann');
2550
+
2551
+ let result;
2552
+ if (llmGate.noLlm) {
2553
+ // Static evaluation: every criterion is "unsure" (✗), score 0,
2554
+ // but the command still produces a useful structured result and
2555
+ // saves the synthesised restatement to the card.
2556
+ result = {
2557
+ score: 0,
2558
+ criteria: Object.fromEntries(Object.keys(ai.feynman.CRITERIA).map(k => [k, false])),
2559
+ explanations: { noLlm: llmGate.message },
2560
+ suggestions: [
2561
+ 'No LLM configured — every criterion shows ✗. Run `kdna-studio llm config` and re-evaluate for a real score.',
2562
+ 'Or set the synthesised restatement via: kdna-studio card update <project> ' + cardId + ' --field feynman_restatement=\'{"text":"<your own restatement>"}\'',
2563
+ ],
2564
+ };
2565
+ } else {
2566
+ result = await ai.feynman.evaluate(llmGate.cfg, card, {});
2567
+ }
2450
2568
  const score = result.score || 0;
2451
2569
  const passed = score >= 4;
2452
2570
 
2453
- console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}`);
2571
+ console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2454
2572
  for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2455
2573
  const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2456
2574
  console.log(` ${r} ${criterion}`);
@@ -2496,18 +2614,35 @@ async function cmdTest(args) {
2496
2614
  const canonical = PRESET_ALIASES[preset] || 'baseline';
2497
2615
  console.log(`Testing: ${domainName} [${preset}]`);
2498
2616
 
2617
+ // Bug (#1 UX): gate LLM-requiring paths through cmdNeedsLlm.
2618
+ // The test command always needs an LLM (it compares without/with
2619
+ // the loaded domain). --no-llm gives a static "no-op" comparison
2620
+ // that lists the cards that *would* be applied.
2621
+ const llmGate = cmdNeedsLlm(args, 'test');
2622
+ const cfg = llmGate.noLlm ? null : llmGate.cfg;
2623
+
2499
2624
  if (canonical === 'baseline') {
2500
2625
  // Baseline is a single comparison: keep the prior single-result
2501
2626
  // shape so existing parsers / consumers see the same output.
2502
2627
  const prompt = `Test the core judgment: ${input}`;
2503
- const result = await ai.testlab.compare(llm.config(), domainName, prompt, domainPrompt, {});
2628
+ if (cfg === null) {
2629
+ // No-LLM static result
2630
+ 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 || '' }));
2631
+ console.log(JSON.stringify({ no_llm: true, message: llmGate.message, would_apply: cards }, null, 2));
2632
+ return;
2633
+ }
2634
+ const result = await ai.testlab.compare(cfg, domainName, prompt, domainPrompt, {});
2504
2635
  console.log(JSON.stringify(result, null, 2));
2505
2636
  return;
2506
2637
  }
2507
2638
  // For non-baseline presets, run the preset + the baseline so the user
2508
2639
  // can see both side by side. The preset prompt is the one in
2509
2640
  // ai/testlab.js — that's the code path that was dead before.
2510
- const result = await ai.testlab.testPreset(llm.config(), domainName, input, domainPrompt, {});
2641
+ if (cfg === null) {
2642
+ console.log(JSON.stringify({ no_llm: true, message: llmGate.message }, null, 2));
2643
+ return;
2644
+ }
2645
+ const result = await ai.testlab.testPreset(cfg, domainName, input, domainPrompt, {});
2511
2646
  console.log(JSON.stringify(result[canonical] || result, null, 2));
2512
2647
  }
2513
2648
 
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.8",
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
  }