@aikdna/kdna-studio-cli 0.10.2 → 0.11.0

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.
@@ -3,18 +3,109 @@ const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
+
6
7
  const zlib = require('zlib');
7
8
  const cbor = require('cbor-x');
8
9
  const { execFileSync } = require('child_process');
9
10
 
11
+ function deterministicSourceDigest(paths) {
12
+ const files = [];
13
+ function visit(target, root) {
14
+ const stat = fs.lstatSync(target);
15
+ if (stat.isSymbolicLink()) {
16
+ throw new Error('Studio Core source checkout contains a symbolic link.');
17
+ }
18
+ if (stat.isFile()) {
19
+ files.push({ target, relative: path.relative(root, target) });
20
+ return;
21
+ }
22
+ if (!stat.isDirectory()) return;
23
+ for (const entry of fs.readdirSync(target).sort()) {
24
+ visit(path.join(target, entry), root);
25
+ }
26
+ }
27
+ for (const target of paths) {
28
+ const absolute = path.resolve(target);
29
+ visit(absolute, path.dirname(absolute));
30
+ }
31
+ files.sort((left, right) => left.relative.localeCompare(right.relative));
32
+ const hash = crypto.createHash('sha256');
33
+ for (const file of files) {
34
+ hash.update(file.relative);
35
+ hash.update('\0');
36
+ hash.update(fs.readFileSync(file.target));
37
+ hash.update('\0');
38
+ }
39
+ return `sha256:${hash.digest('hex')}`;
40
+ }
41
+
10
42
  function loadStudioCore() {
11
43
  const publishedCore = require('@aikdna/kdna-studio-core');
12
- if (publishedCore.exportRuntime && publishedCore.protocolContract) return publishedCore;
44
+ const publishedVersion = require('@aikdna/kdna-studio-core/package.json').version;
45
+ if (
46
+ publishedCore.exportRuntime &&
47
+ publishedCore.protocolContract &&
48
+ publishedCore.creationEngine &&
49
+ publishedCore.creationEngine.SCHEMA_VERSION === '0.2.0' &&
50
+ typeof publishedCore.creationEngine.readManagedCandidate === 'function'
51
+ ) {
52
+ return {
53
+ ...publishedCore,
54
+ creationExportRuntime: publishedCore.exportRuntime,
55
+ creationStudioCoreCoordinate: {
56
+ package: '@aikdna/kdna-studio-core',
57
+ version: publishedVersion,
58
+ distribution: 'installed-package',
59
+ },
60
+ };
61
+ }
13
62
  try {
14
- return require('../../kdna-studio-core/src');
15
- } catch (_) {
16
- return publishedCore;
63
+ const sourcePaths = [
64
+ path.join(__dirname, '../../kdna-studio-core/src'),
65
+ path.join(
66
+ __dirname,
67
+ '../../kdna-studio-core/schemas/creation-workspace.schema.json',
68
+ ),
69
+ path.join(__dirname, '../../kdna-studio-core/package.json'),
70
+ path.join(__dirname, '../../kdna-studio-core/package-lock.json'),
71
+ ].filter((target) => fs.existsSync(target));
72
+ const sourceDigestBeforeLoad = deterministicSourceDigest(sourcePaths);
73
+ const sourceCore = require('../../kdna-studio-core/src');
74
+ const sourceDigestAfterLoad = deterministicSourceDigest(sourcePaths);
75
+ if (sourceDigestBeforeLoad !== sourceDigestAfterLoad) {
76
+ throw new Error('Studio Core source changed while it was being loaded.');
77
+ }
78
+ if (
79
+ sourceCore.exportRuntime &&
80
+ sourceCore.protocolContract &&
81
+ sourceCore.creationEngine
82
+ ) {
83
+ // Keep the installed package as the binding for established expert
84
+ // commands. A sibling source checkout supplies only the unreleased
85
+ // Creation Engine and its matching exporter during coordinated
86
+ // development; it must not silently replace expert APIs.
87
+ return {
88
+ ...publishedCore,
89
+ creationEngine: sourceCore.creationEngine,
90
+ creationExportRuntime: sourceCore.exportRuntime,
91
+ creationStudioCoreCoordinate: {
92
+ package: '@aikdna/kdna-studio-core',
93
+ version: require('../../kdna-studio-core/package.json').version,
94
+ distribution: 'source-checkout',
95
+ source_tree_digest: sourceDigestAfterLoad,
96
+ },
97
+ };
98
+ }
99
+ } catch (error) {
100
+ if (
101
+ error?.code !== 'MODULE_NOT_FOUND' &&
102
+ error?.code !== 'ENOENT'
103
+ ) {
104
+ throw error;
105
+ }
106
+ // Installed packages do not have a sibling source checkout.
17
107
  }
108
+ return publishedCore;
18
109
  }
19
110
 
20
111
  const {
@@ -22,68 +113,93 @@ const {
22
113
  cards: cardApi,
23
114
  evidence: evidenceApi,
24
115
  compile: compileApi,
25
- quality,
26
116
  creator: creatorApi,
27
117
  distillation: distillationApi,
28
118
  exportRuntime,
119
+ creationExportRuntime,
120
+ creationStudioCoreCoordinate,
29
121
  protocolContract,
122
+ creationEngine,
30
123
  } = loadStudioCore();
31
124
 
32
125
  const llm = require('../src/llm');
33
126
 
34
- // Bug (#1 UX): the LLM-requiring commands (distill, interview, feynman,
35
- // test) silently fail with a stack trace when no LLM is configured.
36
- // First-time users hit this and have no way to know what to do. The
37
- // fix routes every LLM call through this helper, which:
38
- // 1. Checks whether an LLM is configured at all
39
- // 2. If not and --no-llm is given, returns a static / synthesised
40
- // result so the command can still be useful (5-minute path)
41
- // 3. If not and --no-llm is absent, exits with a clear
42
- // "configure LLM with: kdna-studio llm config" message
43
- function cmdNeedsLlm(args, cmdName) {
44
- const cfg = llm.config();
45
- const hasLlm = cfg && cfg.provider && cfg.apiKey && cfg.model;
46
- if (hasLlm) return { hasLlm: true, cfg };
47
- if (args.includes('--no-llm')) {
48
- return {
49
- hasLlm: false,
50
- cfg,
51
- noLlm: true,
52
- message: `${cmdName} invoked with --no-llm. Output will be static / synthesised; configure an LLM with \`kdna-studio llm config\` to enable real evaluation.`,
53
- };
54
- }
55
- fail(
56
- `${cmdName} requires an LLM. Configure one with:\n` +
57
- ` kdna-studio llm config --provider openai --key <api-key> --model <model>\n` +
58
- `Or set env vars: KDNA_LLM_PROVIDER, KDNA_LLM_API_KEY, KDNA_LLM_MODEL.\n` +
59
- `Or run with --no-llm to get a static / synthesised result.`,
60
- );
61
- }
62
127
  const ai = require('../src/ai');
128
+ const {
129
+ CREATION_COMMANDS,
130
+ decodeSecretTransport,
131
+ executeCreationCommand,
132
+ } = require('../src/creation-cli');
63
133
 
64
134
  const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
135
+ const SECRET_STDIN_LIMIT_BYTES = 64 * 1024;
65
136
 
66
- function usage() {
67
- console.log(`kdna-studio — Studio-compatible KDNA authoring CLI
137
+ function readBoundedSecretStdin(label) {
138
+ const storage = Buffer.alloc(SECRET_STDIN_LIMIT_BYTES + 1);
139
+ let length = 0;
140
+ try {
141
+ while (length < storage.length) {
142
+ const count = fs.readSync(
143
+ 0,
144
+ storage,
145
+ length,
146
+ storage.length - length,
147
+ null,
148
+ );
149
+ if (count === 0) break;
150
+ length += count;
151
+ }
152
+ if (length > SECRET_STDIN_LIMIT_BYTES) {
153
+ throw new Error(`${label} exceeds the bounded secret input limit`);
154
+ }
155
+ return decodeSecretTransport(storage.subarray(0, length));
156
+ } finally {
157
+ storage.fill(0);
158
+ }
159
+ }
68
160
 
69
- LLM (AI-powered authoring; every AI command accepts --no-llm for a static result):
161
+ function usage() {
162
+ console.log(`kdna-studio — Agent-guided KDNA Creation Engine
163
+
164
+ Create without schema editing:
165
+ kdna-studio guide-agent --action create|inventory --json
166
+ kdna-studio guide-agent <workspace> --json
167
+ kdna-studio create-agent <workspace> [--input-file <json> | --input-stdin] [--material <path>] [--password-stdin] [--operation-id <id>] [--json]
168
+ kdna-studio inventory-agent <material-file-or-directory> [--material <path>] [--workspace <existing-creation-workspace>] [--json]
169
+ kdna-studio deliver-material <workspace> (--private-output-fd 3 | --private-output-file <mode-0600-system-temp-file>) [--input-file <json> | --input-stdin] [--material <reauthorized-path>] [--json]
170
+ kdna-studio resume <workspace> [--input-file <json> | --input-stdin] [--material <path>] [--password-stdin] [--operation-id <id>] [--json]
171
+ kdna-studio status <workspace> [--json]
172
+ kdna-studio answer <workspace> [--input-file <json> | --input-stdin] [--operation-id <id>] [--json]
173
+ kdna-studio review <workspace> [--input-file <json> | --input-stdin] [--operation-id <id>] [--json]
174
+ kdna-studio try <workspace> [--input-file <json> | --input-stdin] [--operation-id <id>] [--json]
175
+ kdna-studio verify-application-agent <workspace> [--password-stdin] [--json]
176
+ kdna-studio repair <workspace> [--input-file <json> | --input-stdin] [--operation-id <id>] [--json]
177
+ kdna-studio export-agent <workspace> [--password-stdin] [--operation-id <id>] [--json]
178
+ kdna-studio finalize-agent <workspace> --out <file.kdna> [--operation-id <id>] [--json]
179
+
180
+ The default Creation Engine speaks in purpose, material, judgment, boundary,
181
+ example, confirmation, and export terms. Natural-language answers belong in
182
+ stdin or an input file, never process arguments.
183
+
184
+ Expert authoring:
185
+
186
+ LLM configuration for expert AI-assisted authoring:
70
187
  kdna-studio llm config [--provider <name>] [--model <name>] [--key-pipe] [--url <base-url>]
71
188
  kdna-studio llm show
72
- (run 'kdna-studio llm config --provider openai --key <key> --model gpt-4' to enable
73
- feynman, distill --ai, interview, and test. With --no-llm these commands
74
- still produce a structured but unsynthesised result.)
189
+ (pipe the key to 'kdna-studio llm config --provider openai --key-pipe --model gpt-4'
190
+ to enable distill --ai and interview.)
75
191
 
76
192
  Identity:
77
- kdna-studio identity init [--name <display-name>]
193
+ kdna-studio identity init [--name <display-name>] [--passphrase-stdin]
78
194
  kdna-studio identity show
79
195
 
80
196
  Create (three entry paths):
81
- kdna-studio create <project-dir> --name <@scope/name> [--author <name>] # blank
197
+ kdna-studio create <project-dir> --name <@scope/name> [--author <name>] [--use-local-identity] # blank
82
198
  kdna-studio create <project-dir> --from-kdna <file.kdna> --name <@scope/name>
83
199
  kdna-studio create <project-dir> --from-folder <source-dir> --name <@scope/name>
84
200
 
85
201
  Migrate (dev source or Studio project → canonical .kdna in one command):
86
- kdna-studio migrate <source-dir|project> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--allow-incomplete] [--sign] [--passphrase <pw>|--passphrase-stdin]
202
+ kdna-studio migrate <source-dir|project> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--allow-incomplete] [--sign] [--use-local-identity] [--passphrase-stdin]
87
203
  kdna-studio migrate <source-dir> --check --name <@scope/name> # pre-flight: report which fields would block the export without writing the .kdna
88
204
  kdna-studio audit-locks <project> [--type axiom|risk|stance|...] [--json] # list cards with missing Human Lock fields (per-card-type, per-field)
89
205
 
@@ -95,10 +211,10 @@ Authoring:
95
211
  kdna-studio card add <project> <type> --field key=value [--field key=value] [--template <name>] [--no-strict]
96
212
  kdna-studio card update <project> <card-id> --field key=value
97
213
  kdna-studio card remove <project> <card-id>
98
- kdna-studio card approve <project> <card-id|--all> --by <id> --statement <text> [--sign] [--passphrase <pass>]
214
+ kdna-studio card approve <project> <card-id|--all> --by <id> --statement <text> [--sign] [--passphrase-stdin]
99
215
  kdna-studio card unlock <project> <card-id> --by <id> --statement <text>
100
216
  kdna-studio compile <project> --out <dir>
101
- kdna-studio export <project> --out <file.kdna> [--allow-incomplete] [--password <pw>|--password-stdin]
217
+ kdna-studio export <project> --out <file.kdna> [--allow-incomplete] [--password-stdin]
102
218
  kdna-studio export-route-card <domain-id> [--out=<path>] # Export route-card sidecar skeleton
103
219
  kdna-studio export-consumer-index [--entries=<domain-ids>] [--out=<path>] # Export consumer-index skeleton
104
220
 
@@ -106,8 +222,6 @@ AI Authoring (requires LLM config: kdna-studio llm config):
106
222
  kdna-studio distill <project> --ai # AI-driven candidate extraction from evidence
107
223
  kdna-studio distill <project> --candidates <file.json> # load pre-generated AI candidates
108
224
  kdna-studio interview <project> [--stage <name>] # 4-stage guided AI interview
109
- kdna-studio feynman <project> <card-id> # AI Feynman evaluation (5-dimension)
110
- kdna-studio test <project> --input "<text>" [--preset baseline|edge|contradiction]
111
225
 
112
226
  Distillation:
113
227
  kdna-studio target declare <project> # declare distillation target interactively
@@ -118,7 +232,6 @@ Distillation:
118
232
  kdna-studio candidate reject <project> <candidate-id>
119
233
  kdna-studio candidate override <project> <candidate-id> # override scope gate
120
234
  kdna-studio candidate promote <project> # promote accepted+scope_fit → cards
121
- kdna-studio report <project>
122
235
  kdna-studio audit-locks <project> # list all axioms/risk/stance missing Human Lock fields
123
236
 
124
237
  Project may be a directory containing studio.project.json or a project JSON file.`);
@@ -159,64 +272,58 @@ function option(args, name, fallback = null) {
159
272
  }
160
273
 
161
274
  /**
162
- * Resolve a passphrase from CLI flags, environment, or stdin.
163
- *
164
- * SECURITY: passing a passphrase as `--passphrase <value>` exposes it in
165
- * `ps aux` output and shell history. The recommended path is one of:
166
- * 1. --passphrase-stdin — read from stdin (pipe-friendly, no TTY hang)
167
- * 2. KDNA_PASSPHRASE env var (less secure but no process-list leak)
168
- * 3. --passphrase <pw> — fallback only; prints a warning
275
+ * Resolve a passphrase from stdin. Secrets in argv are rejected.
169
276
  *
170
277
  * Returns the passphrase string, or null if none was provided.
171
278
  */
172
279
  function resolvePassphrase(args) {
280
+ if (
281
+ args.includes('--passphrase') ||
282
+ args.some((arg) => arg.startsWith('--passphrase='))
283
+ ) {
284
+ fail(
285
+ '--passphrase is not supported because it exposes secrets in process arguments. ' +
286
+ 'Use --passphrase-stdin.'
287
+ );
288
+ }
173
289
  if (args.includes('--passphrase-stdin')) {
174
290
  if (process.stdin.isTTY) {
175
291
  fail(
176
292
  '--passphrase-stdin requires the passphrase to be piped in on stdin.\n' +
177
- 'Example: read -s PW && echo "$PW" | kdna-studio export ... --sign --passphrase-stdin'
293
+ 'Example: read -r -s PW && printf "%s\\n" "$PW" | kdna-studio export ... --sign --passphrase-stdin'
178
294
  );
179
295
  }
180
296
  try {
181
- return fs.readFileSync(0, 'utf8').trim();
297
+ return readBoundedSecretStdin('Passphrase');
182
298
  } catch (e) {
183
299
  fail(`Could not read passphrase from stdin: ${e.message}`);
184
300
  }
185
301
  }
186
- if (process.env.KDNA_PASSPHRASE) return process.env.KDNA_PASSPHRASE;
187
- const fromFlag = option(args, '--passphrase');
188
- if (fromFlag) {
189
- console.error('Warning: --passphrase <value> exposes the secret in `ps aux` and shell history. Prefer --passphrase-stdin or KDNA_PASSPHRASE env var.');
190
- return fromFlag;
191
- }
192
302
  return null;
193
303
  }
194
304
 
195
305
  function resolveApiKey(args) {
196
- // 1. KDNA_API_KEY environment variable (preferred)
197
- if (process.env.KDNA_API_KEY) return process.env.KDNA_API_KEY;
306
+ if (
307
+ args.includes('--key') ||
308
+ args.includes('-k') ||
309
+ args.some((arg) => arg.startsWith('--key='))
310
+ ) {
311
+ fail('API keys in process arguments are not supported. Use --key-pipe.');
312
+ }
198
313
 
199
- // 2. Read from stdin via --key-pipe flag
200
314
  if (args.includes('--key-pipe')) {
201
315
  if (process.stdin.isTTY) {
202
- console.error('Error: --key-pipe requires stdin to be piped. Example: echo $KDNA_API_KEY | kdna-studio llm config --key-pipe');
316
+ console.error('Error: --key-pipe requires the API key to be piped on stdin.');
203
317
  return null;
204
318
  }
205
319
  try {
206
- return fs.readFileSync(0, 'utf8').trim();
320
+ return readBoundedSecretStdin('API key');
207
321
  } catch (e) {
208
322
  console.error('Error reading API key from stdin:', e.message);
209
323
  return null;
210
324
  }
211
325
  }
212
326
 
213
- // 3. Deprecated --key / -k flag (backward compat)
214
- const key = option(args, '--key', null) || option(args, '-k', null);
215
- if (key) {
216
- console.error('Warning: --key is deprecated and exposes secrets in shell history and ps output. Use KDNA_API_KEY env var or pipe via stdin (--key-pipe).');
217
- return key;
218
- }
219
-
220
327
  return null;
221
328
  }
222
329
 
@@ -676,8 +783,19 @@ function cmdCreate(args) {
676
783
  let lineage = null;
677
784
  let creatorIdentity = null;
678
785
 
679
- // Load creator identity if available
680
- try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* no identity yet */ }
786
+ // A machine-local identity is never inherited implicitly. Creating for
787
+ // another person, an organization, or an interpretive source must not pick
788
+ // up whichever identity happens to be configured on this computer.
789
+ if (args.includes('--use-local-identity')) {
790
+ try {
791
+ creatorIdentity = creatorApi.loadIdentity();
792
+ } catch {
793
+ fail(
794
+ '--use-local-identity was requested, but no usable local identity is available.',
795
+ EXIT.TRUST_FAILED,
796
+ );
797
+ }
798
+ }
681
799
 
682
800
  if (fromKdna) {
683
801
  sourceMode = 'kdna_asset';
@@ -776,7 +894,8 @@ function importFromKdna(kdnaPath) {
776
894
  fail(`KDNA file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${absKdna}`);
777
895
  }
778
896
 
779
- // ── read ZIP before Core validation so the profile can branch ──
897
+ // Read the container before Core validation only to produce a stable
898
+ // profile error. Studio does not retain a second Runtime contract.
780
899
  const zipBuf = fs.readFileSync(absKdna);
781
900
  const entries = readZipEntries(zipBuf);
782
901
  validateZipContainer(entries);
@@ -787,22 +906,17 @@ function importFromKdna(kdnaPath) {
787
906
  const payload = decodePayload(entries.get('payload.kdnab'), manifest);
788
907
  const profile = payload?.profile;
789
908
 
790
- // ── branch: current profile requires Core validate; legacy uses a frozen contract ──
791
- let runtimeCore, isLegacy;
909
+ if (profile !== 'kdna.payload.judgment') {
910
+ fail(`Unsupported payload profile "${profile || '(none)'}". Only kdna.payload.judgment is accepted for --from-kdna.`);
911
+ }
912
+
913
+ let runtimeCore;
792
914
  try { runtimeCore = require('@aikdna/kdna-core'); } catch {
793
915
  fail('@aikdna/kdna-core is required to validate --from-kdna imports.');
794
916
  }
795
- if (profile === 'kdna.payload.judgment') {
796
- const validation = runtimeCore.validate(absKdna);
797
- if (!validation.overall_valid) {
798
- fail(`Not a current .kdna asset: ${(validation.problems || []).join('; ')}`);
799
- }
800
- isLegacy = false;
801
- } else if (profile === 'judgment-profile-v1') {
802
- validateLegacyImportContract(manifest, payload);
803
- isLegacy = true;
804
- } else {
805
- fail(`Unsupported payload profile "${profile || '(none)'}". Only kdna.payload.judgment and judgment-profile-v1 are accepted for --from-kdna.`);
917
+ const validation = runtimeCore.validate(absKdna);
918
+ if (!validation.overall_valid) {
919
+ fail(`Not a current .kdna asset: ${(validation.problems || []).join('; ')}`);
806
920
  }
807
921
 
808
922
  const lineage = {
@@ -813,28 +927,54 @@ function importFromKdna(kdnaPath) {
813
927
  parent_asset_digest: manifest.content_digest || manifest.asset_digest || null,
814
928
  };
815
929
 
816
- const importedCards = [];
817
- let judgmentCore = null;
930
+ let imported;
818
931
  try {
819
- if (isLegacy) {
820
- importedCards.push(...cardsFromLegacyPayload(payload));
821
- judgmentCore = judgmentCoreFromLegacyPayload(payload);
822
- } else {
823
- importedCards.push(...cardsFromPayload(payload));
824
- judgmentCore = judgmentCoreFromRuntimePayload(payload);
825
- }
932
+ imported = importFromRuntimeContent(manifest, payload);
826
933
  } catch (e) {
827
934
  fail(`Could not import cards from payload.kdnab: ${e.message}`);
828
935
  }
829
936
 
830
- if (importedCards.length === 0) {
937
+ if (imported.cards.length === 0) {
831
938
  fail(`No current judgment cards could be extracted from ${absKdna}.`);
832
939
  }
833
940
 
834
941
  return {
835
942
  lineage,
836
- cards: importedCards,
837
- judgment_core: judgmentCore,
943
+ ...imported,
944
+ };
945
+ }
946
+
947
+ function importFromRuntimeContent(manifest, payload) {
948
+ if (payload?.profile !== 'kdna.payload.judgment') {
949
+ throw new Error(
950
+ `unsupported payload profile "${payload?.profile || '(none)'}"`,
951
+ );
952
+ }
953
+ resetImportedIds();
954
+ const cards = cardsFromPayload(payload);
955
+ const runtimeBoundaries = Array.isArray(payload.core?.boundaries)
956
+ ? payload.core.boundaries
957
+ : [];
958
+ const taskScopes = Array.from(new Set(
959
+ runtimeBoundaries
960
+ .map((boundary) => boundary?.scope)
961
+ .filter((scope) => typeof scope === 'string' && scope.trim().length > 0),
962
+ ));
963
+ const excludedAreas = runtimeBoundaries
964
+ .map((boundary) => boundary?.out_of_scope)
965
+ .filter((area) => typeof area === 'string' && area.trim().length > 0);
966
+ return {
967
+ cards,
968
+ judgment_core: judgmentCoreFromRuntimePayload(payload),
969
+ distillation_target: {
970
+ task_scope: taskScopes.length === 1 ? taskScopes[0] : null,
971
+ include_areas: taskScopes,
972
+ exclude_areas: excludedAreas,
973
+ load_condition:
974
+ typeof payload.core?.load_condition === 'string'
975
+ ? payload.core.load_condition
976
+ : null,
977
+ },
838
978
  source_manifest: manifest,
839
979
  source_patterns: null,
840
980
  source_reasoning: payload.reasoning && typeof payload.reasoning === 'object'
@@ -849,9 +989,23 @@ function importFromKdna(kdnaPath) {
849
989
  };
850
990
  }
851
991
 
852
- // ── legacy judgment-profile-v1 import support ───────────────────────────
853
-
854
- const LEGACY_PROFILE = 'judgment-profile-v1';
992
+ function importFromRuntimeCapsule(capsule) {
993
+ if (capsule?.type !== 'kdna.runtime-capsule' || capsule.profile !== 'full') {
994
+ throw new Error('A full Runtime Capsule is required for Studio re-import.');
995
+ }
996
+ const manifest = capsule.context?.manifest;
997
+ const payload = capsule.context?.payload;
998
+ if (!manifest || !payload) {
999
+ throw new Error(
1000
+ 'The Runtime Capsule does not contain current manifest and payload semantics.',
1001
+ );
1002
+ }
1003
+ const imported = importFromRuntimeContent(manifest, payload);
1004
+ if (imported.cards.length === 0) {
1005
+ throw new Error('No current judgment cards could be re-imported.');
1006
+ }
1007
+ return imported;
1008
+ }
855
1009
 
856
1010
  const LEGACY_PATTERN_TYPE_MAP = {
857
1011
  term: 'term',
@@ -907,28 +1061,6 @@ function validateZipContainer(entries) {
907
1061
  }
908
1062
  }
909
1063
 
910
- function validateLegacyImportContract(manifest, payload) {
911
- // Frozen validation for judgment-profile-v1 assets.
912
- if (!manifest || typeof manifest !== 'object') fail('Legacy asset manifest is missing or invalid');
913
- if (manifest.kdna_version !== '1.0') fail(`Legacy asset must declare kdna_version "1.0", got "${manifest.kdna_version}"`);
914
- if (!manifest.asset_id || typeof manifest.asset_id !== 'string') fail('Legacy asset_id is missing');
915
- if (!manifest.compatibility || manifest.compatibility.profile !== LEGACY_PROFILE) {
916
- fail(`Legacy asset compatibility.profile must be "${LEGACY_PROFILE}"`);
917
- }
918
- if (!manifest.payload || manifest.payload.path !== 'payload.kdnab') {
919
- fail('Legacy asset payload.path must be "payload.kdnab"');
920
- }
921
- if (manifest.payload.encoding !== 'cbor') {
922
- fail('Legacy asset payload.encoding must be "cbor"');
923
- }
924
- if (manifest.payload.encrypted) fail('Legacy asset must not be encrypted');
925
- if (!payload || typeof payload !== 'object') fail('Legacy asset payload is missing or invalid');
926
- if (payload.profile !== LEGACY_PROFILE) fail('Legacy payload must declare profile "judgment-profile-v1"');
927
- if (!payload.core || typeof payload.core !== 'object') fail('Legacy payload must contain a "core" object');
928
- if (!Array.isArray(payload.core.axioms)) fail('Legacy payload must contain axioms');
929
- if (!payload.patterns || !Array.isArray(payload.patterns)) fail('Legacy payload must contain patterns');
930
- }
931
-
932
1064
  function judgmentCoreFromLegacyPayload(payload) {
933
1065
  const core = payload.core || {};
934
1066
  const result = {};
@@ -1791,7 +1923,7 @@ function cmdCard(args) {
1791
1923
  const passphrase = resolvePassphrase(args);
1792
1924
  try {
1793
1925
  if (identity.encrypted && !passphrase) {
1794
- errors.push(`${card.id}: Private key is encrypted — provide --passphrase-stdin or KDNA_PASSPHRASE`);
1926
+ errors.push(`${card.id}: Private key is encrypted — provide --passphrase-stdin`);
1795
1927
  return null;
1796
1928
  }
1797
1929
  lockPayload.signature = creatorApi.signHumanLock(
@@ -1912,7 +2044,7 @@ function cmdMigrate(args) {
1912
2044
  const requestedName = option(args, '--name');
1913
2045
  let name = requestedName || path.basename(path.resolve(sourceDir || '.'));
1914
2046
  if (!sourceDir || (!out && !checkOnly) || (!by && !checkOnly) || (!statement && !checkOnly)) {
1915
- fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--check] [--sign] [--passphrase <pw>|--passphrase-stdin]');
2047
+ fail('Usage: kdna-studio migrate <source-dir> --out <file.kdna> --name <@scope/name> --by <id> --statement <text> [--check] [--sign] [--passphrase-stdin]');
1916
2048
  }
1917
2049
 
1918
2050
  const sourcePath = path.resolve(sourceDir);
@@ -1932,7 +2064,16 @@ function cmdMigrate(args) {
1932
2064
  try {
1933
2065
  // Step 1: import dev source, or use an existing Studio project directly.
1934
2066
  let creatorIdentity = null;
1935
- try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* proceed without */ }
2067
+ if (args.includes('--use-local-identity')) {
2068
+ try {
2069
+ creatorIdentity = creatorApi.loadIdentity();
2070
+ } catch {
2071
+ fail(
2072
+ '--use-local-identity was requested, but no usable local identity is available.',
2073
+ EXIT.TRUST_FAILED,
2074
+ );
2075
+ }
2076
+ }
1936
2077
  if (isStudioProject) {
1937
2078
  const loaded = readProject(sourcePath);
1938
2079
  project = loaded.project;
@@ -2143,20 +2284,21 @@ function cmdExport(args) {
2143
2284
  fail('The --format option is not supported. KDNA has one current asset format.');
2144
2285
  }
2145
2286
  if (args.includes('--sign')) {
2146
- fail('Asset signing is a separate runtime step. Export first, then run: kdna sign <asset.kdna>');
2287
+ fail('Asset signatures are outside the current Preview contract.');
2147
2288
  }
2148
2289
  {
2149
2290
  const { project } = readProject(projectInput);
2150
- // BUG-11 (2026-06-27): previous logic required --password to be set
2151
- // before --password-stdin would take effect. That meant callers using
2152
- // `--password-stdin` alone (the recommended path) got `password = null`
2153
- // and the export ran unencrypted. Resolve the two flags independently:
2154
- // - --password-stdin → read password from stdin (preferred)
2155
- // - --password <pw> → take password from the flag (insecure)
2156
- // - --passphrase <pw> → legacy alias for --password
2157
- // If both are given, --password-stdin wins (it is the explicit intent).
2158
- // Use args.includes() for --password-stdin since it is a boolean flag
2159
- // (option() rejects it for "missing value").
2291
+ if (
2292
+ args.includes('--password') ||
2293
+ args.some((arg) => arg.startsWith('--password=')) ||
2294
+ args.includes('--passphrase') ||
2295
+ args.some((arg) => arg.startsWith('--passphrase='))
2296
+ ) {
2297
+ fail(
2298
+ 'Password and passphrase values in process arguments are not supported. ' +
2299
+ 'Use --password-stdin.'
2300
+ );
2301
+ }
2160
2302
  const useStdin = args.includes('--password-stdin');
2161
2303
  let password;
2162
2304
  if (useStdin) {
@@ -2166,25 +2308,15 @@ function cmdExport(args) {
2166
2308
  if (process.stdin.isTTY) {
2167
2309
  fail(
2168
2310
  '--password-stdin requires the password to be piped in on stdin.\n' +
2169
- 'Example: echo "$KDNA_PASSWORD" | kdna-studio export <project> --out <file.kdna> --password-stdin\n' +
2311
+ 'Example: printf "%s\\n" "$KDNA_PASSWORD" | kdna-studio export <project> --out <file.kdna> --password-stdin\n' +
2170
2312
  'If you are running interactively, omit --password-stdin and you will be prompted.'
2171
2313
  );
2172
2314
  }
2173
2315
  try {
2174
- password = fs.readFileSync(0, 'utf8').trim();
2316
+ password = readBoundedSecretStdin('Password');
2175
2317
  } catch (e) {
2176
2318
  fail(`Could not read password from stdin: ${e.message}`);
2177
2319
  }
2178
- } else {
2179
- // Bug (#55): prior version only consulted --password / --passphrase
2180
- // here, so a caller who set KDNA_PASSPHRASE for the signing path
2181
- // (works via resolvePassphrase) found that the runtime encryption
2182
- // password was silently null. Resolve from the same sources as
2183
- // the signing path so the two stay in sync.
2184
- password = process.env.KDNA_PASSPHRASE
2185
- || process.env.KDNA_PASSWORD
2186
- || option(args, '--password')
2187
- || option(args, '--passphrase');
2188
2320
  }
2189
2321
  exportProject(project, option(args, '--name') || project.name, path.resolve(out), {
2190
2322
  allowIncomplete: args.includes('--allow-incomplete'),
@@ -2200,7 +2332,9 @@ function cmdStudioInstall(args) {
2200
2332
  if (!target) fail('Usage: kdna-studio install <@scope/name|file.kdna>');
2201
2333
  try {
2202
2334
  const kdnaArgs = ['install', target, '--yes'];
2203
- if (args.includes('--trusted')) kdnaArgs.push('--trusted');
2335
+ if (args.includes('--trusted')) {
2336
+ fail('Asset signatures are outside the current Preview contract.');
2337
+ }
2204
2338
  const result = require('child_process').spawnSync('kdna', kdnaArgs, {
2205
2339
  stdio: 'inherit', encoding: 'utf8',
2206
2340
  });
@@ -2254,6 +2388,77 @@ function cmdLlm(args) {
2254
2388
  fail('Usage: kdna-studio llm <config|show>');
2255
2389
  }
2256
2390
 
2391
+ // initIdentity fails for several distinct reasons and each needs a different
2392
+ // user message. A catch-all "Identity already exists." would lie about path
2393
+ // (ENOTDIR/ENOENT), permission (EACCES/EPERM), write, and KDF failures and
2394
+ // would send the user to `identity show`, which then reports no identity.
2395
+ function failIdentityInit(err) {
2396
+ const code = err && err.code;
2397
+ // Identity state is classified only by Studio Core's stable machine codes
2398
+ // (or by stable OS error codes). Human-readable error text is diagnostic
2399
+ // material, never a state oracle.
2400
+ if (code === 'IDENTITY_ALREADY_EXISTS') {
2401
+ fail(
2402
+ `Identity already exists [${code}] and passed consistency verification. `
2403
+ + "Use 'kdna-studio identity show' to view it."
2404
+ );
2405
+ }
2406
+ if (code === 'IDENTITY_INCOMPLETE') {
2407
+ fail(
2408
+ `Identity is incomplete [${code}]: only part of the canonical three-file identity exists. `
2409
+ + 'No existing file was changed or removed. Preserve the files and recover the identity manually.'
2410
+ );
2411
+ }
2412
+ if (code === 'IDENTITY_CORRUPT') {
2413
+ fail(
2414
+ `Identity is corrupt [${code}]: all canonical files exist but failed consistency verification. `
2415
+ + 'Do not sign with or use this identity. Preserve the files and recover from a trusted backup.'
2416
+ );
2417
+ }
2418
+ if (code === 'IDENTITY_COMMITTED_DURABILITY_UNCONFIRMED'
2419
+ && err.committed === true && err.identityVerified === true) {
2420
+ fail(
2421
+ `Identity committed, durability unconfirmed [${code}]: your identity was created, `
2422
+ + 'but confirming it is fully durable on disk failed.\n'
2423
+ + 'Do NOT re-run `kdna-studio identity init`.\n'
2424
+ + ' 1. Verify the identity with: kdna-studio identity show\n'
2425
+ + ' 2. Back up your private key promptly.'
2426
+ );
2427
+ }
2428
+ if (code === 'IDENTITY_COMMITTED_INCONSISTENT') {
2429
+ fail(
2430
+ `Identity committed but inconsistent [${code}]: files reached the canonical identity directory, `
2431
+ + 'but they did not pass post-commit consistency verification.\n'
2432
+ + 'Do NOT sign with, show as valid, delete, or automatically retry this identity.\n'
2433
+ + 'Preserve and restrict access to the directory, then recover from a trusted backup or have an '
2434
+ + 'administrator inspect all three files.'
2435
+ );
2436
+ }
2437
+ if (code === 'IDENTITY_COMMITTED_DURABILITY_UNCONFIRMED') {
2438
+ fail(
2439
+ 'Identity init returned an invalid committed-state result '
2440
+ + `[${code}]: verified identity evidence is missing. Do not retry or use the identity; recover manually.`
2441
+ );
2442
+ }
2443
+ if (code === 'ENOTDIR' || code === 'ENOENT') {
2444
+ fail(`Identity init failed — the identity path is not usable [${code}].`);
2445
+ }
2446
+ if (code === 'EACCES' || code === 'EPERM') {
2447
+ fail(`Identity init failed — permission denied [${code}].`);
2448
+ }
2449
+ if (code === 'ENOSPC') {
2450
+ fail(`Identity init failed — storage is full [${code}]. No identity was committed.`);
2451
+ }
2452
+ if (code === 'EIO') {
2453
+ fail(`Identity init failed — storage I/O failed [${code}]. Do not assume an identity was created.`);
2454
+ }
2455
+ if (code === 'IDENTITY_KDF_FAILED') {
2456
+ fail(`Identity init failed — key derivation failed [${code}]. No identity was committed.`);
2457
+ }
2458
+ const displayCode = typeof code === 'string' && /^[A-Z0-9_]+$/.test(code) ? code : 'UNKNOWN';
2459
+ fail(`Identity init failed [${displayCode}]. No recognized identity state was reported.`);
2460
+ }
2461
+
2257
2462
  function cmdIdentity(args) {
2258
2463
  const sub = args[0];
2259
2464
  if (sub === 'init') {
@@ -2272,7 +2477,7 @@ function cmdIdentity(args) {
2272
2477
  console.log(` Private key is encrypted — keep your passphrase safe.`);
2273
2478
  }
2274
2479
  } catch (err) {
2275
- fail(`Identity already exists. Use 'kdna-studio identity show' to view. ${err.message}`);
2480
+ failIdentityInit(err);
2276
2481
  }
2277
2482
  return;
2278
2483
  }
@@ -2290,22 +2495,14 @@ function cmdIdentity(args) {
2290
2495
  fail('Usage: kdna-studio identity <init|show>');
2291
2496
  }
2292
2497
 
2293
- function cmdReport(args) {
2294
- const { project } = readProject(args[0]);
2295
- const readiness = quality.computeReadiness(project);
2296
- const gate = projectApi.checkHumanLockGate(project);
2297
- console.log(JSON.stringify({ readiness, human_lock_gate: gate }, null, 2));
2298
- process.exit(gate.blocked ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
2299
- }
2300
-
2301
2498
  // Audit-locks: list every card that would fail the current Human Lock
2302
2499
  // gate because of missing or empty fields. Used during the
2303
2500
  // pre-migration review of source-tree assets (e.g. pro-20) so an
2304
2501
  // author can fill in the missing confidence / evidence_type / risk
2305
2502
  // description / stance statement BEFORE running `migrate`.
2306
2503
  //
2307
- // Bug (UX pro-20 migration): prior version of `kdna-studio report`
2308
- // returned a flat human_lock_gate.issues list, which mixed card
2504
+ // The earlier readiness output returned a flat human_lock_gate.issues list,
2505
+ // which mixed card
2309
2506
  // types and field names. An author migrating 20 source-tree
2310
2507
  // assets with 99 axioms + 140 risk cards + 70 stance cards had no
2311
2508
  // way to see, for a single asset, "which axioms are missing
@@ -2567,6 +2764,16 @@ async function cmdDistill(args) {
2567
2764
  suggested_card_type: c.type || c.suggested_card_type || 'axiom',
2568
2765
  supporting_evidence_ids: (c.evidence_ids || c.evidenceIds || c.supporting_evidence_ids || []).map(String),
2569
2766
  confidence: c.confidence || 'medium',
2767
+ rationale: c.rationale || c.why || '',
2768
+ applies_when: Array.isArray(c.applies_when) ? c.applies_when.map(String) : [],
2769
+ does_not_apply_when: Array.isArray(c.does_not_apply_when)
2770
+ ? c.does_not_apply_when.map(String)
2771
+ : [],
2772
+ misuse_risk: c.misuse_risk || c.failure_risk || '',
2773
+ agent_inference: c.agent_inference === true,
2774
+ fields: c.fields && typeof c.fields === 'object' && !Array.isArray(c.fields)
2775
+ ? JSON.parse(JSON.stringify(c.fields))
2776
+ : {},
2570
2777
  candidate_status: 'proposed',
2571
2778
  scope_fit: c.scope_fit ?? c.scopeFit ?? true,
2572
2779
  };
@@ -2625,36 +2832,68 @@ function cmdCandidate(args) {
2625
2832
  }
2626
2833
 
2627
2834
  if (sub === 'promote') {
2628
- const accepted = candidates.filter(c => c.candidate_status === 'accepted' && c.scope_fit !== false);
2835
+ const accepted = candidates.filter(
2836
+ c =>
2837
+ c.candidate_status === 'accepted' &&
2838
+ c.scope_fit !== false &&
2839
+ !c.promoted_card_id,
2840
+ );
2629
2841
  if (accepted.length === 0) fail('No candidates to promote. Accept some candidates first.');
2630
2842
 
2843
+ const incomplete = [];
2844
+ for (const candidate of accepted) {
2845
+ const missing = [];
2846
+ if (!String(candidate.one_sentence || '').trim()) missing.push('one_sentence');
2847
+ if (!String(candidate.full_statement || '').trim()) missing.push('full_statement');
2848
+ if (!String(candidate.rationale || candidate.why || '').trim()) missing.push('rationale');
2849
+ if (!Array.isArray(candidate.applies_when) || candidate.applies_when.length === 0) {
2850
+ missing.push('applies_when');
2851
+ }
2852
+ if (
2853
+ !Array.isArray(candidate.does_not_apply_when) ||
2854
+ candidate.does_not_apply_when.length === 0
2855
+ ) {
2856
+ missing.push('does_not_apply_when');
2857
+ }
2858
+ if (!String(candidate.misuse_risk || candidate.failure_risk || '').trim()) {
2859
+ missing.push('misuse_risk');
2860
+ }
2861
+ if (missing.length > 0) incomplete.push(`${candidate.id}: ${missing.join(', ')}`);
2862
+ }
2863
+ if (incomplete.length > 0) {
2864
+ fail(
2865
+ `Accepted candidates are incomplete and were not promoted:\n - ${incomplete.join(
2866
+ '\n - ',
2867
+ )}\nRefine or re-distill them before promotion.`,
2868
+ );
2869
+ }
2870
+
2631
2871
  for (const c of accepted) {
2632
- // Promote candidates to draft cards. For axioms we must populate every
2633
- // field the Human Lock gate and migrate path require — empty strings
2634
- // cause migrate to fail ("critical fields missing from axioms"). When
2635
- // a candidate lacks the field, fall back to the candidate's own
2636
- // one_sentence/full_statement so the lock gate at least has substance
2637
- // to verify. Authors are expected to enrich these fields before
2638
- // locking.
2639
- const fields = {};
2872
+ // Candidate review is the place to resolve missing semantics. Promotion
2873
+ // preserves the reviewed fields and never fabricates placeholder text.
2874
+ const fields = {
2875
+ ...(c.fields && typeof c.fields === 'object' ? c.fields : {}),
2876
+ one_sentence: c.one_sentence,
2877
+ full_statement: c.full_statement,
2878
+ rationale: c.rationale || c.why,
2879
+ applies_when: c.applies_when,
2880
+ does_not_apply_when: c.does_not_apply_when,
2881
+ misuse_risk: c.misuse_risk || c.failure_risk,
2882
+ confidence: c.confidence || 'unknown',
2883
+ evidence_type: c.agent_inference === true ? 'analogy' : 'practice',
2884
+ agent_inference: c.agent_inference === true,
2885
+ };
2640
2886
  if (c.suggested_card_type === 'axiom') {
2641
- fields.one_sentence = c.one_sentence || '';
2642
- fields.full_statement = c.full_statement || c.one_sentence || '<TBD: full_statement>';
2643
- fields.why = c.why || '<TBD: why>';
2644
- // applies_when / does_not_apply_when are arrays, not strings.
2645
- // Empty string is invalid; emit an empty array so checkRequiredFields
2646
- // (which treats `length === 0` as missing) correctly reports them.
2647
- fields.applies_when = Array.isArray(c.applies_when) ? c.applies_when : [];
2648
- fields.does_not_apply_when = Array.isArray(c.does_not_apply_when) ? c.does_not_apply_when : [];
2649
- fields.failure_risk = c.failure_risk || '<TBD: failure_risk>';
2650
- } else {
2651
- fields.one_sentence = c.one_sentence || '';
2652
- fields.full_statement = c.full_statement || c.one_sentence || '';
2887
+ fields.why = c.rationale || c.why;
2888
+ fields.failure_risk = c.misuse_risk || c.failure_risk;
2653
2889
  }
2654
2890
  const card = cardApi.createCard(c.suggested_card_type, fields);
2655
- card.evidence_refs = c.supporting_evidence_ids;
2891
+ card.evidence_refs = Array.isArray(c.supporting_evidence_ids)
2892
+ ? c.supporting_evidence_ids
2893
+ : [];
2656
2894
  project.cards = project.cards || [];
2657
2895
  project.cards.push(card);
2896
+ c.promoted_card_id = card.id;
2658
2897
  }
2659
2898
  if (project.stages?.judgment_cards) {
2660
2899
  project.stages.judgment_cards.total = project.cards.length;
@@ -2672,175 +2911,46 @@ async function cmdInterview(args) {
2672
2911
  const projectInput = args[0];
2673
2912
  const stage = option(args, '--stage');
2674
2913
  if (!projectInput) fail('Usage: kdna-studio interview <project> [--stage distill|clarify|correct|replay]');
2675
- const { project } = readProject(projectInput);
2914
+ const { projectPath, project } = readProject(projectInput);
2915
+ let results;
2676
2916
 
2677
2917
  if (stage) {
2678
- const result = await ai.interview.runInterview(llm.config(), project, stage, '', {});
2918
+ const normalizedStage = ai.interview.normalizeInterviewStage(stage);
2919
+ const result = await ai.interview.runInterview(
2920
+ llm.config(),
2921
+ project,
2922
+ normalizedStage,
2923
+ '',
2924
+ {},
2925
+ );
2926
+ results = [result];
2679
2927
  console.log(`\n=== ${result.label} ===\n${result.content}`);
2680
2928
  } else {
2681
- await ai.interview.runInterviewInteractive(llm.config(), project, {});
2929
+ results = await ai.interview.runInterviewInteractive(llm.config(), project, {});
2682
2930
  }
2683
- }
2684
-
2685
- async function cmdFeynman(args) {
2686
- const projectInput = args[0];
2687
- const cardId = args[1];
2688
- if (!projectInput || !cardId) fail('Usage: kdna-studio feynman <project> <card-id>');
2689
- const useJson = args.includes('--json');
2690
- const { projectPath, project } = readProject(projectInput);
2691
- const cardIdx = (project.cards || []).findIndex(c => c.id === cardId);
2692
- if (cardIdx < 0) fail(`Card not found: ${cardId}`);
2693
- const card = project.cards[cardIdx];
2694
-
2695
- // Canonical field name in the card schema is `feynman_restatement`, not
2696
- // `feynman_text`. The old check was dead code (nothing writes the wrong
2697
- // field) and made this command fail on every card.
2698
- //
2699
- // Bug (#62 + #68 follow-up): the prior `if (!card.feynman_restatement)
2700
- // fail(...)` block rejected every card that had no human-written
2701
- // restatement, even when the AI evaluator in feynman.js was
2702
- // prepared to auto-synthesise one. A caller without a configured
2703
- // LLM (no `KDNA_API_KEY`, no `kdna-studio llm config`) would
2704
- // get a `fail` instead of a structured note. The fix removes the
2705
- // guard: a missing restatement is now handed off to the evaluator
2706
- // (which either asks the LLM to evaluate a synthesised one or,
2707
- // without an LLM, returns a structured `synthesised_restatement`
2708
- // for the caller to use as a starting point). The error message
2709
- // is kept as a secondary path: only if the evaluator itself fails
2710
- // does the command exit non-zero, and only then with a clear
2711
- // pointer to the manual `card update` path.
2712
- //
2713
- // We do still need a restatement-shaped object to feed the
2714
- // evaluator, so we synthesise one in-memory when the card has
2715
- // none. This does NOT persist the synthesised restatement to the
2716
- // project (so the user still has to opt in via `card update` to
2717
- // keep one across runs), but it lets the no-LLM path return a
2718
- // useful result instead of a hard fail.
2719
- if (!card.feynman_restatement) {
2720
- const synthesised = ai.feynman.synthFeynmanRestatement(card.fields || {});
2721
- card.feynman_restatement = { text: synthesised, synthesised: true };
2722
- console.warn(`No feynman_restatement on ${cardId}; using synthesised:\n "${synthesised}"`);
2723
- }
2724
-
2725
- if (!useJson) console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2726
-
2727
- // Bug (#1 UX follow-up): if no LLM is configured and the caller
2728
- // did not pass --no-llm, route through cmdNeedsLlm which either
2729
- // gives a clear "configure LLM" error or accepts the --no-llm
2730
- // path. Without this branch, the no-LLM caller would fall
2731
- // through to the LLM call below and get a stack trace.
2732
- const llmGate = cmdNeedsLlm(args, 'feynmann');
2733
-
2734
- let result;
2735
- if (llmGate.noLlm) {
2736
- // Static evaluation: every criterion is "unsure" (✗), score 0,
2737
- // but the command still produces a useful structured result and
2738
- // saves the synthesised restatement to the card.
2739
- result = {
2740
- score: 0,
2741
- criteria: Object.fromEntries(Object.keys(ai.feynman.CRITERIA).map(k => [k, false])),
2742
- explanations: { noLlm: llmGate.message },
2743
- suggestions: [
2744
- 'No LLM configured — every criterion shows ✗. Run `kdna-studio llm config` and re-evaluate for a real score.',
2745
- 'Or set the synthesised restatement via: kdna-studio card update <project> ' + cardId + ' --field feynman_restatement=\'{"text":"<your own restatement>"}\'',
2746
- ],
2747
- };
2748
- } else {
2749
- result = await ai.feynman.evaluate(llmGate.cfg, card, {});
2750
- }
2751
- const score = result.score || 0;
2752
- const passed = score >= 4;
2753
-
2754
- if (useJson) {
2755
- console.log(JSON.stringify({
2756
- card_id: cardId,
2757
- score,
2758
- passed,
2759
- no_llm: !!llmGate.noLlm,
2760
- criteria: result.criteria || {},
2761
- suggestions: result.suggestions || [],
2762
- feynman_restatement: card.feynman_restatement,
2763
- }, null, 2));
2764
- } else {
2765
- console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2766
- for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2767
- const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2768
- console.log(` ${r} ${criterion}`);
2769
- }
2770
- if (result.suggestions && result.suggestions.length > 0) {
2771
- console.log('\nSuggestions:');
2772
- result.suggestions.forEach(s => console.log(` - ${s}`));
2773
- }
2931
+ project.interview_sessions = Array.isArray(project.interview_sessions)
2932
+ ? project.interview_sessions
2933
+ : [];
2934
+ project.interview_sessions.push({
2935
+ recorded_at: new Date().toISOString(),
2936
+ results,
2937
+ });
2938
+ if (project.stages?.interview_room) {
2939
+ project.stages.interview_room.status = 'in_progress';
2940
+ project.stages.interview_room.questions_asked =
2941
+ Number(project.stages.interview_room.questions_asked || 0) +
2942
+ results.reduce(
2943
+ (total, result) =>
2944
+ total +
2945
+ (Array.isArray(result.conversation)
2946
+ ? result.conversation.filter(message => message.role === 'user').length
2947
+ : 1),
2948
+ 0,
2949
+ );
2774
2950
  }
2775
-
2776
- card.feynman_evaluation = { score, criteria: result.criteria, suggestions: result.suggestions, evaluated_at: new Date().toISOString() };
2777
- project.cards[cardIdx] = card;
2778
2951
  writeProject(projectPath, project);
2779
2952
  }
2780
2953
 
2781
- async function cmdTest(args) {
2782
- const projectInput = args[0];
2783
- const input = option(args, '--input');
2784
- const preset = option(args, '--preset', 'baseline');
2785
- if (!projectInput || !input) fail('Usage: kdna-studio test <project> --input "<text>" [--preset baseline|edge|contradiction]');
2786
- const { project } = readProject(projectInput);
2787
- const domainName = project.name || 'Untitled Project';
2788
- const domainPrompt = `Domain: ${domainName}\n${project.description || ''}`;
2789
-
2790
- // Bug (#50): prior version hand-rolled the prompt for the 3 preset
2791
- // names and bypassed ai.testlab.testPreset entirely. The exported
2792
- // testPreset function was dead code, and the help text advertised
2793
- // names (baseline / edge / contradiction) that did not match the
2794
- // actual preset keys in testlab.js (baseline / edge_case /
2795
- // contradiction). Result: --preset edge silently ran the
2796
- // contradiction branch, and any caller that asked for "edge_case"
2797
- // fell through to the contradiction branch too.
2798
- //
2799
- // The fix routes the call through testPreset when a known preset is
2800
- // given, and accepts both help-text names (baseline / edge /
2801
- // contradiction) and the canonical testlab names (baseline / edge_case
2802
- // / contradiction).
2803
- const PRESET_ALIASES = {
2804
- baseline: 'baseline',
2805
- edge: 'edge_case',
2806
- edge_case: 'edge_case',
2807
- contradiction: 'contradiction',
2808
- };
2809
- const canonical = PRESET_ALIASES[preset] || 'baseline';
2810
- console.log(`Testing: ${domainName} [${preset}]`);
2811
-
2812
- // Bug (#1 UX): gate LLM-requiring paths through cmdNeedsLlm.
2813
- // The test command always needs an LLM (it compares without/with
2814
- // the loaded domain). --no-llm gives a static "no-op" comparison
2815
- // that lists the cards that *would* be applied.
2816
- const llmGate = cmdNeedsLlm(args, 'test');
2817
- const cfg = llmGate.noLlm ? null : llmGate.cfg;
2818
-
2819
- if (canonical === 'baseline') {
2820
- // Baseline is a single comparison: keep the prior single-result
2821
- // shape so existing parsers / consumers see the same output.
2822
- const prompt = `Test the core judgment: ${input}`;
2823
- if (cfg === null) {
2824
- // No-LLM static result
2825
- 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 || '' }));
2826
- console.log(JSON.stringify({ no_llm: true, message: llmGate.message, would_apply: cards }, null, 2));
2827
- return;
2828
- }
2829
- const result = await ai.testlab.compare(cfg, domainName, prompt, domainPrompt, {});
2830
- console.log(JSON.stringify(result, null, 2));
2831
- return;
2832
- }
2833
- // For non-baseline presets, run the preset + the baseline so the user
2834
- // can see both side by side. The preset prompt is the one in
2835
- // ai/testlab.js — that's the code path that was dead before.
2836
- if (cfg === null) {
2837
- console.log(JSON.stringify({ no_llm: true, message: llmGate.message }, null, 2));
2838
- return;
2839
- }
2840
- const result = await ai.testlab.testPreset(cfg, domainName, input, domainPrompt, {});
2841
- console.log(JSON.stringify(result[canonical] || result, null, 2));
2842
- }
2843
-
2844
2954
  const args = process.argv.slice(2);
2845
2955
  const cmd = args[0];
2846
2956
  if (!cmd || cmd === '--help' || cmd === '-h') {
@@ -2854,7 +2964,23 @@ if (cmd === '--version' || cmd === '-v') {
2854
2964
 
2855
2965
  (async () => {
2856
2966
  try {
2857
- if (cmd === 'create') cmdCreate(args.slice(1));
2967
+ if (CREATION_COMMANDS.has(cmd)) {
2968
+ const runtimeCore = require('@aikdna/kdna-core');
2969
+ await executeCreationCommand(cmd, args.slice(1), {
2970
+ creationEngine,
2971
+ runtimeCore,
2972
+ exportRuntime: creationExportRuntime || exportRuntime,
2973
+ reimportCapsule: importFromRuntimeCapsule,
2974
+ cliVersion:
2975
+ `@aikdna/kdna-studio-cli@${require('../package.json').version}`,
2976
+ studioCoreVersion:
2977
+ creationStudioCoreCoordinate ||
2978
+ `@aikdna/kdna-studio-core@${require('@aikdna/kdna-studio-core/package.json').version}`,
2979
+ runtimeCoreVersion:
2980
+ `@aikdna/kdna-core@${require('@aikdna/kdna-core/package.json').version}`,
2981
+ });
2982
+ }
2983
+ else if (cmd === 'create') cmdCreate(args.slice(1));
2858
2984
  else if (cmd === 'migrate') cmdMigrate(args.slice(1));
2859
2985
  else if (cmd === 'import') cmdImport(args.slice(1));
2860
2986
  else if (cmd === 'filter') cmdFilter(args.slice(1));
@@ -2863,15 +2989,12 @@ try {
2863
2989
  else if (cmd === 'distill') await cmdDistill(args.slice(1));
2864
2990
  else if (cmd === 'candidate') cmdCandidate(args.slice(1));
2865
2991
  else if (cmd === 'interview') await cmdInterview(args.slice(1));
2866
- else if (cmd === 'feynman') await cmdFeynman(args.slice(1));
2867
- else if (cmd === 'test') await cmdTest(args.slice(1));
2868
2992
  else if (cmd === 'card') cmdCard(args.slice(1));
2869
2993
  else if (cmd === 'lock') cmdLock(args.slice(1));
2870
2994
  else if (cmd === 'compile') cmdCompile(args.slice(1));
2871
2995
  else if (cmd === 'export') cmdExport(args.slice(1));
2872
2996
  else if (cmd === 'llm') cmdLlm(args.slice(1));
2873
2997
  else if (cmd === 'identity') cmdIdentity(args.slice(1));
2874
- else if (cmd === 'report') cmdReport(args.slice(1));
2875
2998
  else if (cmd === 'audit-locks') cmdAuditLocks(args.slice(1));
2876
2999
  else if (cmd === 'install') cmdStudioInstall(args.slice(1));
2877
3000
  else if (cmd === 'update') cmdStudioUpdate(args.slice(1));