@aikdna/kdna-studio-cli 0.8.6 → 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);
@@ -428,14 +490,29 @@ function cardsFromV1Payload(payload) {
428
490
  pushImportedCard(cards, 'self_check', fields, fields.id || null);
429
491
  }
430
492
  for (const failureMode of (Array.isArray(payload.reasoning?.failure_modes) ? payload.reasoning?.failure_modes : [])) {
493
+ // Bug (#145 follow-up): the prior version dropped three
494
+ // judgment fields (failure_risk, applies_when, does_not_apply_when)
495
+ // when reading failure_modes back out of a v1 asset. A Studio
496
+ // project that round-tripped through `migrate --format v1` lost
497
+ // these fields every time. The fix reads them through, matching
498
+ // the export-side map in kdna-studio-core's buildPayload.
431
499
  pushImportedCard(cards, 'misunderstanding', {
432
500
  wrong: failureMode.mode || failureMode.wrong || '',
433
501
  correct: failureMode.correct || '',
434
502
  key_distinction: failureMode.key_distinction || '',
435
503
  why: failureMode.why || '',
504
+ failure_risk: failureMode.failure_risk || '',
505
+ applies_when: Array.isArray(failureMode.applies_when) ? failureMode.applies_when : [],
506
+ does_not_apply_when: Array.isArray(failureMode.does_not_apply_when) ? failureMode.does_not_apply_when : [],
436
507
  }, failureMode.id || null);
437
508
  }
438
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;
439
516
  pushImportedCard(cards, 'reasoning', chain, chain.id || null);
440
517
  }
441
518
  for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
@@ -452,6 +529,7 @@ function cardsFromV1Payload(payload) {
452
529
  }
453
530
 
454
531
  function cardsFromLegacyPayload(payload) {
532
+ resetImportedIds();
455
533
  const cards = [];
456
534
  const judgment = payload.judgment || {};
457
535
  const core = judgment.core || {};
@@ -701,6 +779,7 @@ function cmdCreate(args) {
701
779
  * Cards are imported as draft — they do NOT inherit trust from the source.
702
780
  */
703
781
  function importFromKdna(kdnaPath) {
782
+ resetImportedIds();
704
783
  const absKdna = path.resolve(kdnaPath);
705
784
  if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
706
785
  if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
@@ -863,6 +942,7 @@ function importFromKdna(kdnaPath) {
863
942
  * Outputs a schema audit report.
864
943
  */
865
944
  function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
945
+ resetImportedIds();
866
946
  const absSource = path.resolve(sourceDir);
867
947
  if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
868
948
  if (!fs.statSync(absSource).isDirectory()) fail('--from-folder requires a directory');
@@ -1000,6 +1080,15 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity) {
1000
1080
  const q = typeof sc === 'string' ? sc : sc.question || '';
1001
1081
  importedCards.push(cardApi.createCard('self_check', { question: q }));
1002
1082
  }
1083
+ // Bug (aesthetic round-trip): prior version dropped
1084
+ // `patterns.aesthetics` on import. `cardsFromV1Payload` and
1085
+ // `cardsFromLegacyPayload` both read the field, so a domain
1086
+ // that authored aesthetic cards in either format would lose
1087
+ // them when imported through `create --from-folder`. The fix
1088
+ // adds the missing loop here.
1089
+ for (const aesthetic of (Array.isArray(patterns.aesthetics) ? patterns.aesthetics : [])) {
1090
+ importedCards.push(cardApi.createCard('aesthetic', aesthetic.fields || aesthetic));
1091
+ }
1003
1092
  }
1004
1093
 
1005
1094
  // NEW: import scenarios
@@ -1335,8 +1424,25 @@ function cmdCard(args) {
1335
1424
  const sub = args[0];
1336
1425
  if (sub === 'list') {
1337
1426
  const { project } = readProject(args[1]);
1338
- for (const card of project.cards || []) {
1339
- 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
+ }
1340
1446
  }
1341
1447
  return;
1342
1448
  }
@@ -2236,9 +2342,15 @@ async function cmdDistill(args) {
2236
2342
  const projectInput = args[0];
2237
2343
  const candidatesFile = option(args, '--candidates');
2238
2344
  const useAI = args.includes('--ai');
2239
- const provider = option(args, '--provider');
2240
- const model = option(args, '--model');
2241
- 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;
2242
2354
  if (!projectInput) fail('Usage: kdna-studio distill <project> [--ai] [--candidates <file.json>]');
2243
2355
  if (!candidatesFile && !useAI) fail('Either --ai or --candidates <file.json> required.');
2244
2356
 
@@ -2401,31 +2513,62 @@ async function cmdFeynman(args) {
2401
2513
  // `feynman_text`. The old check was dead code (nothing writes the wrong
2402
2514
  // field) and made this command fail on every card.
2403
2515
  //
2404
- // Bug (#62 + #68): the prior error message pointed at "the feynman
2405
- // authoring command", which does not exist in this CLI. The right
2406
- // path is either:
2407
- // - kdna-studio feynman <project> <card-id> (evaluate an existing
2408
- // restatement; what the caller is already running)
2409
- // - kdna-studio card update <project> <card-id> --field feynman_restatement='<json>'
2410
- // (set the restatement manually)
2411
- // The fix makes the second path explicit and gives a working
2412
- // example. Bug #62 (the field is never populated) is fixed in
2413
- // feynman.js: a no-restatement card now auto-generates one
2414
- // before the LLM call, so the bare `kdna-studio feynman` command
2415
- // works end-to-end.
2516
+ // Bug (#62 + #68 follow-up): the prior `if (!card.feynman_restatement)
2517
+ // fail(...)` block rejected every card that had no human-written
2518
+ // restatement, even when the AI evaluator in feynman.js was
2519
+ // prepared to auto-synthesise one. A caller without a configured
2520
+ // LLM (no `KDNA_API_KEY`, no `kdna-studio llm config`) would
2521
+ // get a `fail` instead of a structured note. The fix removes the
2522
+ // guard: a missing restatement is now handed off to the evaluator
2523
+ // (which either asks the LLM to evaluate a synthesised one or,
2524
+ // without an LLM, returns a structured `synthesised_restatement`
2525
+ // for the caller to use as a starting point). The error message
2526
+ // is kept as a secondary path: only if the evaluator itself fails
2527
+ // does the command exit non-zero, and only then with a clear
2528
+ // pointer to the manual `card update` path.
2529
+ //
2530
+ // We do still need a restatement-shaped object to feed the
2531
+ // evaluator, so we synthesise one in-memory when the card has
2532
+ // none. This does NOT persist the synthesised restatement to the
2533
+ // project (so the user still has to opt in via `card update` to
2534
+ // keep one across runs), but it lets the no-LLM path return a
2535
+ // useful result instead of a hard fail.
2416
2536
  if (!card.feynman_restatement) {
2417
- fail(
2418
- `Card ${cardId} has no Feynman restatement. Set it manually:\n` +
2419
- ` kdna-studio card update <project> ${cardId} --field feynman_restatement='{"text":"<your plain-language restatement>"}'`,
2420
- );
2537
+ const synthesised = ai.feynman.synthFeynmanRestatement(card.fields || {});
2538
+ card.feynman_restatement = { text: synthesised, synthesised: true };
2539
+ console.warn(`No feynman_restatement on ${cardId}; using synthesised:\n "${synthesised}"`);
2421
2540
  }
2422
2541
 
2423
2542
  console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2424
- 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
+ }
2425
2568
  const score = result.score || 0;
2426
2569
  const passed = score >= 4;
2427
2570
 
2428
- 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)' : ''}`);
2429
2572
  for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2430
2573
  const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2431
2574
  console.log(` ${r} ${criterion}`);
@@ -2471,18 +2614,35 @@ async function cmdTest(args) {
2471
2614
  const canonical = PRESET_ALIASES[preset] || 'baseline';
2472
2615
  console.log(`Testing: ${domainName} [${preset}]`);
2473
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
+
2474
2624
  if (canonical === 'baseline') {
2475
2625
  // Baseline is a single comparison: keep the prior single-result
2476
2626
  // shape so existing parsers / consumers see the same output.
2477
2627
  const prompt = `Test the core judgment: ${input}`;
2478
- 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, {});
2479
2635
  console.log(JSON.stringify(result, null, 2));
2480
2636
  return;
2481
2637
  }
2482
2638
  // For non-baseline presets, run the preset + the baseline so the user
2483
2639
  // can see both side by side. The preset prompt is the one in
2484
2640
  // ai/testlab.js — that's the code path that was dead before.
2485
- 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, {});
2486
2646
  console.log(JSON.stringify(result[canonical] || result, null, 2));
2487
2647
  }
2488
2648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-cli",
3
- "version": "0.8.6",
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": {
@@ -39,12 +39,12 @@
39
39
  "node": ">=18"
40
40
  },
41
41
  "dependencies": {
42
- "@aikdna/kdna-studio-core": "^1.7.5",
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"
46
46
  },
47
47
  "devDependencies": {
48
- "@aikdna/kdna-core": "^0.15.0"
48
+ "@aikdna/kdna-core": "^0.15.5"
49
49
  }
50
50
  }