@aikdna/kdna-studio-cli 0.10.1 → 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
 
@@ -366,12 +473,23 @@ function cleanFields(fields = {}) {
366
473
  return cleaned;
367
474
  }
368
475
 
476
+ function importedFields(entry = {}, options = {}) {
477
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return {};
478
+ const fields = JSON.parse(JSON.stringify(entry));
479
+ for (const key of ['id', 'status', 'locked', 'human_lock', 'audit_log', 'source_authored']) {
480
+ delete fields[key];
481
+ }
482
+ if (options.keepType !== true) delete fields.type;
483
+ return cleanFields(fields);
484
+ }
485
+
369
486
  function importedCard(type, fields = {}, id = null) {
370
- try {
371
- return cardApi.createCard(type, cleanFields(fields), id || null);
372
- } catch (_) {
373
- return null;
487
+ const card = cardApi.createCard(type, cleanFields(fields), id || null);
488
+ if (card === null) {
489
+ throw new Error(`failed to create card type="${type}" id="${id || '(auto)'}" — ` +
490
+ `the card type may require additional required fields or the fields may be malformed`);
374
491
  }
492
+ return card;
375
493
  }
376
494
 
377
495
  // Bug (#3 + #4 UX): payload.kdnab intentionally carries the same
@@ -401,8 +519,10 @@ function pushImportedCard(cards, type, fields = {}, id = null) {
401
519
  }
402
520
  return;
403
521
  }
404
- if (id) _importedIds.add(id);
405
522
  const card = importedCard(type, fields, id);
523
+ // Register the id only after card creation succeeds; a failed creation
524
+ // must not consume the id and prevent retry.
525
+ if (card && id) _importedIds.add(id);
406
526
  if (card) cards.push(card);
407
527
  }
408
528
 
@@ -438,31 +558,22 @@ function cardsFromRuntimePayload(payload) {
438
558
  if (cards.length > 0) return cards;
439
559
 
440
560
  for (const ax of (Array.isArray(payload.core?.axioms) ? payload.core?.axioms : [])) {
441
- pushImportedCard(cards, 'axiom', {
442
- one_sentence: ax.one_sentence || '',
443
- full_statement: ax.full_statement || '',
444
- why: ax.why || '',
445
- applies_when: ax.applies_when || [],
446
- does_not_apply_when: ax.does_not_apply_when || [],
447
- failure_risk: ax.failure_risk || '',
448
- confidence: ax.confidence || '',
449
- evidence_type: ax.evidence_type || '',
450
- }, ax.id || null);
561
+ pushImportedCard(cards, 'axiom', importedFields(ax), ax.id || null);
451
562
  }
452
563
  for (const ont of (Array.isArray(payload.core?.ontology) ? payload.core?.ontology : [])) {
453
- pushImportedCard(cards, 'ontology', ont, ont.id || null);
564
+ pushImportedCard(cards, 'ontology', importedFields(ont), ont.id || null);
454
565
  }
455
566
  for (const fw of (Array.isArray(payload.core?.frameworks) ? payload.core?.frameworks : [])) {
456
- pushImportedCard(cards, 'framework', fw, fw.id || null);
567
+ pushImportedCard(cards, 'framework', importedFields(fw), fw.id || null);
457
568
  }
458
569
  for (const stance of (Array.isArray(payload.core?.stances) ? payload.core?.stances : [])) {
459
- pushImportedCard(cards, 'stance', stance, stance.id || null);
570
+ pushImportedCard(cards, 'stance', importedFields(stance), stance.id || null);
460
571
  }
461
572
  for (const aesthetic of (Array.isArray(payload.core?.aesthetics) ? payload.core?.aesthetics : [])) {
462
- pushImportedCard(cards, 'aesthetic', aesthetic, aesthetic.id || null);
573
+ pushImportedCard(cards, 'aesthetic', importedFields(aesthetic), aesthetic.id || null);
463
574
  }
464
575
  for (const boundary of (Array.isArray(payload.core?.boundaries) ? payload.core?.boundaries : [])) {
465
- pushImportedCard(cards, 'boundary', boundary, boundary.id || null);
576
+ pushImportedCard(cards, 'boundary', importedFields(boundary), boundary.id || null);
466
577
  }
467
578
  // risk_model may be either an array of risk items (legacy) or
468
579
  // `{risks: [...]}` (current). Treat both shapes; ignore everything else
@@ -470,7 +581,7 @@ function cardsFromRuntimePayload(payload) {
470
581
  const riskModel = payload.core?.risk_model;
471
582
  const riskItems = Array.isArray(riskModel) ? riskModel : (riskModel?.risks || []);
472
583
  for (const risk of riskItems) {
473
- pushImportedCard(cards, 'risk', risk, risk.id || null);
584
+ pushImportedCard(cards, 'risk', importedFields(risk), risk.id || null);
474
585
  }
475
586
  for (const pattern of (Array.isArray(payload.patterns) ? payload.patterns : [])) {
476
587
  // Bug (#146): prior version dropped any payload.patterns entry
@@ -500,19 +611,25 @@ function cardsFromRuntimePayload(payload) {
500
611
  );
501
612
  continue;
502
613
  }
503
- fields = { ...pattern };
504
- delete fields.type;
614
+ fields = importedFields(pattern);
505
615
  } else {
506
- const { type: _stripped, ...rest } = pattern;
507
- fields = rest;
616
+ fields = importedFields(pattern, { keepType: type === 'pattern' || LEGACY_PATTERN_SUBTYPES.has(type) });
617
+ }
618
+ if (LEGACY_PATTERN_SUBTYPES.has(type)) {
619
+ fields = {
620
+ ...fields,
621
+ type,
622
+ legacy_subtype: fields.legacy_subtype || type,
623
+ };
624
+ type = 'pattern';
508
625
  }
509
626
  pushImportedCard(cards, type, fields, pattern.id || null);
510
627
  }
511
628
  for (const scenario of (Array.isArray(payload.scenarios) ? payload.scenarios : [])) {
512
- pushImportedCard(cards, 'scenario', scenario, scenario.id || null);
629
+ pushImportedCard(cards, 'scenario', importedFields(scenario), scenario.id || null);
513
630
  }
514
631
  for (const item of (Array.isArray(payload.cases) ? payload.cases : [])) {
515
- pushImportedCard(cards, 'case', item, item.id || null);
632
+ pushImportedCard(cards, 'case', importedFields(item), item.id || null);
516
633
  }
517
634
  // The current payload contract has one self-check field. Refuse to infer a
518
635
  // parallel field name: Core validation owns format migration, while Studio
@@ -520,8 +637,8 @@ function cardsFromRuntimePayload(payload) {
520
637
  const rawSelfCheck = payload.reasoning?.self_check;
521
638
  const selfCheckArr = Array.isArray(rawSelfCheck) ? rawSelfCheck : [];
522
639
  for (const selfCheck of selfCheckArr) {
523
- const fields = typeof selfCheck === 'string' ? { question: selfCheck } : selfCheck;
524
- pushImportedCard(cards, 'self_check', fields, fields.id || null);
640
+ const fields = typeof selfCheck === 'string' ? { question: selfCheck } : importedFields(selfCheck);
641
+ pushImportedCard(cards, 'self_check', fields, typeof selfCheck === 'object' ? selfCheck.id || null : null);
525
642
  }
526
643
  for (const failureMode of (Array.isArray(payload.reasoning?.failure_modes) ? payload.reasoning?.failure_modes : [])) {
527
644
  // Bug (#145 follow-up): the prior version dropped three
@@ -530,7 +647,8 @@ function cardsFromRuntimePayload(payload) {
530
647
  // project that round-tripped through `migrate` lost
531
648
  // these fields every time. The fix reads them through, matching
532
649
  // the export-side map in kdna-studio-core's buildPayload.
533
- pushImportedCard(cards, 'misunderstanding', {
650
+ const fields = {
651
+ ...importedFields(failureMode),
534
652
  wrong: failureMode.mode || failureMode.wrong || '',
535
653
  correct: failureMode.correct || '',
536
654
  key_distinction: failureMode.key_distinction || '',
@@ -538,7 +656,9 @@ function cardsFromRuntimePayload(payload) {
538
656
  failure_risk: failureMode.failure_risk || '',
539
657
  applies_when: Array.isArray(failureMode.applies_when) ? failureMode.applies_when : [],
540
658
  does_not_apply_when: Array.isArray(failureMode.does_not_apply_when) ? failureMode.does_not_apply_when : [],
541
- }, failureMode.id || null);
659
+ };
660
+ delete fields.mode;
661
+ pushImportedCard(cards, 'misunderstanding', fields, failureMode.id || null);
542
662
  }
543
663
  for (const chain of (Array.isArray(payload.reasoning?.reasoning_chains) ? payload.reasoning?.reasoning_chains : [])) {
544
664
  // Bug (#4 UX): compile/index.js synthesises one reasoning chain
@@ -547,7 +667,7 @@ function cardsFromRuntimePayload(payload) {
547
667
  // doubled the card count on round-trip. The fix skips chains
548
668
  // that the producer explicitly marked as synthesised.
549
669
  if (chain.source_authored === false) continue;
550
- pushImportedCard(cards, 'reasoning', chain, chain.id || null);
670
+ pushImportedCard(cards, 'reasoning', importedFields(chain), chain.id || null);
551
671
  }
552
672
  for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
553
673
  // Distinguish source-authored stages (preserved identity) from
@@ -556,7 +676,7 @@ function cardsFromRuntimePayload(payload) {
556
676
  // so the importer doesn't double-count what was already in the
557
677
  // locked judgment cards.
558
678
  if (stage.source_authored === true) {
559
- pushImportedCard(cards, 'evolution_stage', stage, stage.id || null);
679
+ pushImportedCard(cards, 'evolution_stage', importedFields(stage), stage.id || null);
560
680
  }
561
681
  }
562
682
  return cards;
@@ -608,7 +728,7 @@ function buildManifest(project, name) {
608
728
  asset_uid: project.asset_uid || source.asset_uid || 'urn:uuid:' + crypto.randomUUID(),
609
729
  asset_type: project.type === 'cluster' ? 'cluster' : 'domain',
610
730
  source_asset_type: source.asset_type || null,
611
- title: titleFromName(source.name || name || project.name, assetId),
731
+ title: source.title || project.title || titleFromName(source.name || name || project.name, assetId),
612
732
  version: semverValue(project.release?.version || source.version, '1.0.0'),
613
733
  judgment_version: semverValue(project.release?.judgment_version || source.judgment_version || source.version, '1.0.0'),
614
734
  created_at: isoDateTime(source.created_at || source.created || project.created),
@@ -663,8 +783,19 @@ function cmdCreate(args) {
663
783
  let lineage = null;
664
784
  let creatorIdentity = null;
665
785
 
666
- // Load creator identity if available
667
- 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
+ }
668
799
 
669
800
  if (fromKdna) {
670
801
  sourceMode = 'kdna_asset';
@@ -691,7 +822,13 @@ function cmdCreate(args) {
691
822
  if (kdnaData.source_patterns) project.source_patterns = kdnaData.source_patterns;
692
823
  if (kdnaData.source_reasoning) project.source_reasoning = kdnaData.source_reasoning;
693
824
  if (kdnaData.source_evolution) project.source_evolution = kdnaData.source_evolution;
825
+ if (kdnaData.source_core_structure) project.source_core_structure = kdnaData.source_core_structure;
694
826
  if (kdnaData.source_manifest) project.source_manifest = publicManifestMetadata(kdnaData.source_manifest);
827
+ if (kdnaData.source_manifest?.asset_uid) project.asset_uid = kdnaData.source_manifest.asset_uid;
828
+ if (kdnaData.source_manifest?.title) project.title = kdnaData.source_manifest.title;
829
+ if (kdnaData.source_manifest?.license) project.license = kdnaData.source_manifest.license;
830
+ if (kdnaData.source_manifest?.languages) project.languages = kdnaData.source_manifest.languages;
831
+ if (kdnaData.source_manifest?.language) project.default_language = kdnaData.source_manifest.language;
695
832
  if (kdnaData.source_manifest?.version) {
696
833
  if (!project.release) project.release = {};
697
834
  project.release.version = kdnaData.source_manifest.version;
@@ -700,9 +837,9 @@ function cmdCreate(args) {
700
837
  if (!project.release) project.release = {};
701
838
  project.release.judgment_version = kdnaData.source_manifest.judgment_version;
702
839
  }
703
- if (kdnaData.source_manifest?.description) {
840
+ if (kdnaData.source_manifest?.description || kdnaData.source_manifest?.summary) {
704
841
  if (!project.release) project.release = {};
705
- project.release.description = kdnaData.source_manifest.description;
842
+ project.release.description = kdnaData.source_manifest.description || kdnaData.source_manifest.summary;
706
843
  }
707
844
  if (project.stages?.judgment_cards) {
708
845
  project.stages.judgment_cards.total = kdnaData.cards.length;
@@ -756,6 +893,23 @@ function importFromKdna(kdnaPath) {
756
893
  if (stats.size > 50 * 1024 * 1024) {
757
894
  fail(`KDNA file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${absKdna}`);
758
895
  }
896
+
897
+ // Read the container before Core validation only to produce a stable
898
+ // profile error. Studio does not retain a second Runtime contract.
899
+ const zipBuf = fs.readFileSync(absKdna);
900
+ const entries = readZipEntries(zipBuf);
901
+ validateZipContainer(entries);
902
+ if (!entries.has('kdna.json')) fail('Not a valid .kdna asset: missing kdna.json');
903
+ if (!entries.has('payload.kdnab')) fail('Not a valid .kdna asset: missing payload.kdnab. Use --from-folder for legacy JSON source migration.');
904
+
905
+ const manifest = JSON.parse(entries.get('kdna.json').toString());
906
+ const payload = decodePayload(entries.get('payload.kdnab'), manifest);
907
+ const profile = payload?.profile;
908
+
909
+ if (profile !== 'kdna.payload.judgment') {
910
+ fail(`Unsupported payload profile "${profile || '(none)'}". Only kdna.payload.judgment is accepted for --from-kdna.`);
911
+ }
912
+
759
913
  let runtimeCore;
760
914
  try { runtimeCore = require('@aikdna/kdna-core'); } catch {
761
915
  fail('@aikdna/kdna-core is required to validate --from-kdna imports.');
@@ -764,11 +918,7 @@ function importFromKdna(kdnaPath) {
764
918
  if (!validation.overall_valid) {
765
919
  fail(`Not a current .kdna asset: ${(validation.problems || []).join('; ')}`);
766
920
  }
767
- const zipBuf = fs.readFileSync(absKdna);
768
- const entries = readZipEntries(zipBuf);
769
- if (!entries.has('kdna.json')) fail('Not a valid .kdna asset: missing kdna.json');
770
921
 
771
- const manifest = JSON.parse(entries.get('kdna.json').toString());
772
922
  const lineage = {
773
923
  type: 'fork',
774
924
  parent_name: packageNameFromManifest(manifest),
@@ -777,42 +927,275 @@ function importFromKdna(kdnaPath) {
777
927
  parent_asset_digest: manifest.content_digest || manifest.asset_digest || null,
778
928
  };
779
929
 
780
- // Current assets carry strict-CBOR `payload.kdnab`. Legacy source folders
781
- // are migrated explicitly with --from-folder; they are not runtime assets.
782
- const importedCards = [];
783
- let judgmentCore = null;
784
- if (!entries.has('payload.kdnab')) {
785
- fail('Not a current .kdna asset: missing payload.kdnab. Use --from-folder for legacy JSON source migration.');
786
- }
930
+ let imported;
787
931
  try {
788
- const payload = decodePayload(entries.get('payload.kdnab'), manifest);
789
- importedCards.push(...cardsFromPayload(payload));
790
- judgmentCore = judgmentCoreFromRuntimePayload(payload);
932
+ imported = importFromRuntimeContent(manifest, payload);
791
933
  } catch (e) {
792
934
  fail(`Could not import cards from payload.kdnab: ${e.message}`);
793
935
  }
794
936
 
795
- if (importedCards.length === 0) {
937
+ if (imported.cards.length === 0) {
796
938
  fail(`No current judgment cards could be extracted from ${absKdna}.`);
797
939
  }
798
940
 
799
- // Bug #15 / #28 follow-up: surface the source's KDNA_Patterns /
800
- // KDNA_Reasoning / KDNA_Evolution entries so cmdCreate can store
801
- // them on the project. The next export then forwards them to
802
- // compileDomain via exportRuntimeAsset, which preserves
803
- // changelog / version_notes / reasoning_chains / banned_terms
804
- // through the round-trip.
805
941
  return {
806
942
  lineage,
807
- cards: importedCards,
808
- 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
+ },
809
978
  source_manifest: manifest,
810
979
  source_patterns: null,
811
- source_reasoning: null,
812
- source_evolution: null,
980
+ source_reasoning: payload.reasoning && typeof payload.reasoning === 'object'
981
+ ? JSON.parse(JSON.stringify(payload.reasoning))
982
+ : null,
983
+ source_evolution: payload.evolution && typeof payload.evolution === 'object'
984
+ ? JSON.parse(JSON.stringify(payload.evolution))
985
+ : null,
986
+ source_core_structure: Array.isArray(payload.core?.core_structure)
987
+ ? JSON.parse(JSON.stringify(payload.core.core_structure))
988
+ : null,
813
989
  };
814
990
  }
815
991
 
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
+ }
1009
+
1010
+ const LEGACY_PATTERN_TYPE_MAP = {
1011
+ term: 'term',
1012
+ banned_term: 'banned_term',
1013
+ misunderstanding: 'misunderstanding',
1014
+ self_check: 'self_check',
1015
+ scenario: 'scenario',
1016
+ case: 'case',
1017
+ reasoning: 'reasoning',
1018
+ risk: 'risk',
1019
+ axiom: 'axiom',
1020
+ ontology: 'ontology',
1021
+ framework: 'framework',
1022
+ stance: 'stance',
1023
+ aesthetic: 'aesthetic',
1024
+ boundary: 'boundary',
1025
+ evolution_stage: 'evolution_stage',
1026
+ };
1027
+
1028
+ // Pattern subtypes that map to the generic 'pattern' card; the original
1029
+ // subtype is preserved in fields.legacy_subtype for audit and re-export.
1030
+ const LEGACY_PATTERN_SUBTYPES = new Set([
1031
+ 'failure_pattern', 'design_pattern', 'response_pattern',
1032
+ 'stopping_pattern', 'completion_pattern', 'decision_pattern',
1033
+ 'reasoning_pattern', 'action_pattern', 'recovery_pattern',
1034
+ 'responsibility_pattern',
1035
+ ]);
1036
+
1037
+ const MAX_ZIP_ENTRIES = 100;
1038
+ const MAX_SINGLE_ENTRY_UNCOMPRESSED = 10 * 1024 * 1024;
1039
+ const MAX_TOTAL_UNCOMPRESSED = 50 * 1024 * 1024;
1040
+ const REQUIRED_ZIP_ENTRIES = ['kdna.json', 'payload.kdnab', 'checksums.json', 'mimetype'];
1041
+
1042
+ function validateZipContainer(entries) {
1043
+ if (entries.size > MAX_ZIP_ENTRIES) {
1044
+ fail(`ZIP container has too many entries (${entries.size}, max ${MAX_ZIP_ENTRIES})`);
1045
+ }
1046
+ for (const name of REQUIRED_ZIP_ENTRIES) {
1047
+ if (!entries.has(name)) fail(`Not a valid .kdna container: missing required entry "${name}"`);
1048
+ }
1049
+ let totalSize = 0;
1050
+ for (const [name, buf] of entries) {
1051
+ if (path.isAbsolute(name) || name.includes('..') || name.includes('\\')) {
1052
+ fail(`ZIP entry "${name}" has an unsafe path`);
1053
+ }
1054
+ if (Buffer.isBuffer(buf) && buf.length > MAX_SINGLE_ENTRY_UNCOMPRESSED) {
1055
+ fail(`ZIP entry "${name}" is too large (${buf.length} bytes)`);
1056
+ }
1057
+ totalSize += Buffer.isBuffer(buf) ? buf.length : 0;
1058
+ }
1059
+ if (totalSize > MAX_TOTAL_UNCOMPRESSED) {
1060
+ fail(`ZIP container total uncompressed size exceeds limit (${totalSize} bytes)`);
1061
+ }
1062
+ }
1063
+
1064
+ function judgmentCoreFromLegacyPayload(payload) {
1065
+ const core = payload.core || {};
1066
+ const result = {};
1067
+ if (typeof core.highest_question === 'string' && core.highest_question) result.highest_question = core.highest_question;
1068
+ if (Array.isArray(core.worldview) && core.worldview.length) result.worldview = core.worldview;
1069
+ if (Array.isArray(core.value_order) && core.value_order.length) result.value_order = core.value_order;
1070
+ if (core.judgment_role && typeof core.judgment_role === 'object' && Object.keys(core.judgment_role).length) result.judgment_role = core.judgment_role;
1071
+ return Object.keys(result).length ? result : null;
1072
+ }
1073
+
1074
+ function legacyStableId(type, fields, prefix) {
1075
+ // Deterministic ID for legacy items that lack a declared id.
1076
+ // Prefix is the parent scope (e.g. the asset name or domain id).
1077
+ const sorted = JSON.stringify(Object.entries(fields).sort());
1078
+ const h = require('crypto').createHash('sha256').update(`${type}:${sorted}`).digest('hex');
1079
+ return `${prefix}_${h.slice(0, 12)}`;
1080
+ }
1081
+
1082
+ const _legacyDedupIds = new Set();
1083
+
1084
+ function _legacyDedupPush(cards, type, fields, id) {
1085
+ // Deduplicate by id IF an id is declared. Items without ids are unique
1086
+ // (deterministic by content). A second push with the same id merges.
1087
+ if (!id) { pushImportedCard(cards, type, fields, null); return; }
1088
+ if (_legacyDedupIds.has(id)) { pushImportedCard(cards, type, fields, id); return; }
1089
+ _legacyDedupIds.add(id);
1090
+ pushImportedCard(cards, type, fields, id);
1091
+ }
1092
+
1093
+ function cardsFromLegacyPayload(payload) {
1094
+ _legacyDedupIds.clear();
1095
+ const cards = [];
1096
+ const core = payload.core || {};
1097
+
1098
+ // axioms
1099
+ for (const ax of (core.axioms || [])) {
1100
+ _legacyDedupPush(cards, 'axiom', importedFields(ax), ax.id || null);
1101
+ }
1102
+
1103
+ // ontology
1104
+ for (const ont of (core.ontology || [])) {
1105
+ pushImportedCard(cards, 'ontology', importedFields(ont), ont.id || null);
1106
+ }
1107
+
1108
+ // frameworks
1109
+ for (const fw of (core.frameworks || [])) {
1110
+ pushImportedCard(cards, 'framework', importedFields(fw), fw.id || null);
1111
+ }
1112
+
1113
+ // stances
1114
+ for (const st of (core.stances || [])) {
1115
+ const fields = typeof st === 'string' ? { statement: st } : importedFields(st);
1116
+ pushImportedCard(cards, 'stance', fields, typeof st === 'object' ? st.id || null : null);
1117
+ }
1118
+
1119
+ // boundaries
1120
+ for (const bd of (core.boundaries || [])) {
1121
+ pushImportedCard(cards, 'boundary', importedFields(bd), bd.id || null);
1122
+ }
1123
+
1124
+ // risks
1125
+ const legacyRisks = Array.isArray(core.risk_model)
1126
+ ? core.risk_model
1127
+ : (Array.isArray(core.risk_model?.risks) ? core.risk_model.risks : []);
1128
+ for (const risk of legacyRisks) {
1129
+ pushImportedCard(cards, 'risk', importedFields(risk.fields || risk), risk.id || null);
1130
+ }
1131
+
1132
+ // patterns with explicit types (including legacy subtypes)
1133
+ const patterns = payload.patterns || [];
1134
+ for (const p of patterns) {
1135
+ if (!p || typeof p !== 'object') continue;
1136
+ const rawType = p.type;
1137
+ if (!rawType) continue; // type-less items handled separately (misunderstandings below)
1138
+
1139
+ if (LEGACY_PATTERN_SUBTYPES.has(rawType)) {
1140
+ pushImportedCard(cards, 'pattern', {
1141
+ ...importedFields(p, { keepType: true }),
1142
+ legacy_subtype: rawType,
1143
+ }, p.id || null);
1144
+ } else if (rawType === 'term') {
1145
+ pushImportedCard(cards, 'term', importedFields(p), p.id || null);
1146
+ } else if (rawType === 'banned_term') {
1147
+ pushImportedCard(cards, 'banned_term', importedFields(p), p.id || null);
1148
+ } else if (LEGACY_PATTERN_TYPE_MAP.hasOwnProperty(rawType)) {
1149
+ pushImportedCard(cards, LEGACY_PATTERN_TYPE_MAP[rawType], importedFields(p), p.id || null);
1150
+ } else {
1151
+ fail(`Unknown legacy pattern type "${rawType}" for card "${p.id || '(no id)'}"`);
1152
+ }
1153
+ }
1154
+
1155
+ // misunderstandings: appear in patterns[] with no type and in
1156
+ // reasoning.failure_modes[] with slightly different field names.
1157
+ // Import from patterns[] (richer fields). The pushImportedCard
1158
+ // dedup handles the failure_modes view via id matching.
1159
+ for (const p of patterns) {
1160
+ if (!p || typeof p !== 'object') continue;
1161
+ if (p.type) continue; // already handled above
1162
+ if (!p.wrong && !p.correct) continue;
1163
+ pushImportedCard(cards, 'misunderstanding', importedFields(p), p.id || null);
1164
+ }
1165
+
1166
+ // reasoning chains
1167
+ for (const chain of (payload.reasoning?.reasoning_chains || [])) {
1168
+ const fields = importedFields(chain);
1169
+ pushImportedCard(cards, 'reasoning', fields, chain.id || null);
1170
+ }
1171
+
1172
+ // self checks: array of plain strings in legacy payload
1173
+ for (const check of (payload.reasoning?.self_check || [])) {
1174
+ if (typeof check === 'string' && check.trim()) {
1175
+ const fields = { question: check.trim() };
1176
+ const id = legacyStableId('self_check', fields, 'legacy_sc');
1177
+ pushImportedCard(cards, 'self_check', fields, id);
1178
+ }
1179
+ }
1180
+
1181
+ // scenarios
1182
+ for (const sc of (payload.scenarios || [])) {
1183
+ pushImportedCard(cards, 'scenario', importedFields(sc), sc.id || null);
1184
+ }
1185
+
1186
+ // cases
1187
+ for (const cs of (payload.cases || [])) {
1188
+ pushImportedCard(cards, 'case', importedFields(cs), cs.id || null);
1189
+ }
1190
+
1191
+ // evolution stages
1192
+ for (const stage of (payload.evolution?.stages || [])) {
1193
+ pushImportedCard(cards, 'evolution_stage', importedFields(stage), stage.id || null);
1194
+ }
1195
+
1196
+ return cards;
1197
+ }
1198
+
816
1199
  /**
817
1200
  * Import cards from a legacy source folder into a new Studio project.
818
1201
  * Reads KDNA_*.json files and converts entries to draft judgment cards.
@@ -847,6 +1230,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
847
1230
  if (manifest.default_language) manifestData.default_language = manifest.default_language;
848
1231
  if (manifest.description) manifestData.description = manifest.description;
849
1232
  if (manifest.name) manifestData.name = manifest.name;
1233
+ if (manifest.title) manifestData.title = manifest.title;
850
1234
  if (manifest.keywords) manifestData.keywords = manifest.keywords;
851
1235
  if (manifest.license) manifestData.license = manifest.license;
852
1236
  if (manifest.author) manifestData.author = manifest.author;
@@ -887,25 +1271,17 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
887
1271
 
888
1272
  if (core) {
889
1273
  for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
890
- const fields = {};
1274
+ const fields = importedFields(ax);
891
1275
  for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type']) {
892
- if (key in ax) fields[key] = ax[key];
893
- else audit.missingFields.push(`axiom.${ax.id || '?'}.${key}`);
1276
+ if (!(key in ax)) audit.missingFields.push(`axiom.${ax.id || '?'}.${key}`);
894
1277
  }
895
1278
  importedCards.push(cardApi.createCard('axiom', fields, ax.id || null));
896
1279
  }
897
1280
  for (const ont of (Array.isArray(core.ontology) ? core.ontology : [])) {
898
- importedCards.push(cardApi.createCard('ontology', {
899
- one_sentence: ont.one_sentence || ont.essence || '',
900
- essence: ont.essence || '', boundary: ont.boundary || '',
901
- trigger_signal: ont.trigger_signal || '',
902
- }, ont.id || null));
1281
+ importedCards.push(cardApi.createCard('ontology', importedFields(ont), ont.id || null));
903
1282
  }
904
1283
  for (const b of (Array.isArray(core.boundaries) ? core.boundaries : [])) {
905
- importedCards.push(cardApi.createCard('boundary', {
906
- scope: b.scope || '', out_of_scope: b.out_of_scope || '',
907
- acceptable_exceptions: b.acceptable_exceptions || [],
908
- }, b.id || null));
1284
+ importedCards.push(cardApi.createCard('boundary', importedFields(b), b.id || null));
909
1285
  }
910
1286
  // Accept risks as a top-level array (`risks`) or wrapped in `risk_model`
911
1287
  // (either as `{risks: [...]}` or as an array itself).
@@ -916,23 +1292,16 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
916
1292
  else risksSource = [];
917
1293
  }
918
1294
  for (const r of risksSource) {
919
- importedCards.push(cardApi.createCard('risk', r.fields || r, r.id || null));
1295
+ importedCards.push(cardApi.createCard('risk', importedFields(r.fields || r), r.id || null));
920
1296
  }
921
1297
  // NEW: import stances
922
1298
  for (const s of (Array.isArray(core.stances) ? core.stances : [])) {
923
- const text = typeof s === 'string' ? s : s.statement || s.stance || '';
924
- importedCards.push(cardApi.createCard('stance', {
925
- statement: text,
926
- applies_when: s.applies_when || [],
927
- does_not_apply_when: s.does_not_apply_when || [],
928
- }, typeof s === 'object' ? s.id || null : null));
1299
+ const fields = typeof s === 'string' ? { statement: s } : importedFields(s);
1300
+ importedCards.push(cardApi.createCard('stance', fields, typeof s === 'object' ? s.id || null : null));
929
1301
  }
930
1302
  // NEW: import frameworks
931
1303
  for (const fw of (Array.isArray(core.frameworks) ? core.frameworks : [])) {
932
- importedCards.push(cardApi.createCard('framework', {
933
- name: fw.name || '', when_to_use: fw.when_to_use || '',
934
- steps: fw.steps || [],
935
- }, fw.id || null));
1304
+ importedCards.push(cardApi.createCard('framework', importedFields(fw), fw.id || null));
936
1305
  }
937
1306
  }
938
1307
 
@@ -940,27 +1309,25 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
940
1309
  // NEW: import terminology
941
1310
  if (patterns.terminology) {
942
1311
  for (const t of (Array.isArray(patterns.terminology.standard_terms) ? patterns.terminology.standard_terms : [])) {
943
- importedCards.push(cardApi.createCard('term', {
944
- term: t.term || '', definition: t.definition || '',
945
- }, t.id || null));
1312
+ importedCards.push(cardApi.createCard('term', importedFields(t), t.id || null));
946
1313
  }
947
1314
  for (const bt of (Array.isArray(patterns.terminology.banned_terms) ? patterns.terminology.banned_terms : [])) {
948
- importedCards.push(cardApi.createCard('banned_term', {
949
- term: bt.term || '', why: bt.why || '', replace_with: bt.replace_with || '',
950
- }, bt.id || null));
1315
+ importedCards.push(cardApi.createCard('banned_term', importedFields(bt), bt.id || null));
951
1316
  }
952
1317
  }
1318
+ for (const pattern of (Array.isArray(patterns.patterns) ? patterns.patterns : [])) {
1319
+ const fields = importedFields(pattern, { keepType: true });
1320
+ if (!fields.type) fields.type = 'pattern';
1321
+ importedCards.push(cardApi.createCard('pattern', fields, pattern.id || null));
1322
+ }
953
1323
  for (const ms of (Array.isArray(patterns.misunderstandings) ? patterns.misunderstandings : [])) {
954
- importedCards.push(cardApi.createCard('misunderstanding', {
955
- wrong: ms.wrong || '', correct: ms.correct || '',
956
- key_distinction: ms.key_distinction || '', why: ms.why || '',
957
- }, ms.id || null));
1324
+ importedCards.push(cardApi.createCard('misunderstanding', importedFields(ms), ms.id || null));
958
1325
  }
959
1326
  for (const sc of (Array.isArray(patterns.self_check) ? patterns.self_check : [])) {
960
- const q = typeof sc === 'string' ? sc : sc.question || '';
1327
+ const fields = typeof sc === 'string' ? { question: sc } : importedFields(sc);
961
1328
  importedCards.push(cardApi.createCard(
962
1329
  'self_check',
963
- { question: q },
1330
+ fields,
964
1331
  typeof sc === 'object' ? sc.id || null : null,
965
1332
  ));
966
1333
  }
@@ -973,7 +1340,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
973
1340
  for (const aesthetic of (Array.isArray(patterns.aesthetics) ? patterns.aesthetics : [])) {
974
1341
  importedCards.push(cardApi.createCard(
975
1342
  'aesthetic',
976
- aesthetic.fields || aesthetic,
1343
+ importedFields(aesthetic.fields || aesthetic),
977
1344
  aesthetic.id || null,
978
1345
  ));
979
1346
  }
@@ -982,20 +1349,14 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
982
1349
  // NEW: import scenarios
983
1350
  if (scenarios && Array.isArray(scenarios.scenes)) {
984
1351
  for (const scene of scenarios.scenes) {
985
- importedCards.push(cardApi.createCard('scenario', {
986
- name: scene.name || scene.id || '',
987
- trigger: scene.trigger || '', action: scene.action || '',
988
- }, scene.id || null));
1352
+ importedCards.push(cardApi.createCard('scenario', importedFields(scene), scene.id || null));
989
1353
  }
990
1354
  }
991
1355
 
992
1356
  // NEW: import cases
993
1357
  if (cases && Array.isArray(cases.cases)) {
994
1358
  for (const c of cases.cases) {
995
- importedCards.push(cardApi.createCard('case', {
996
- title: c.title || c.id || '',
997
- scenario: c.scenario || '', expected: c.expected || '',
998
- }, c.id || null));
1359
+ importedCards.push(cardApi.createCard('case', importedFields(c), c.id || null));
999
1360
  }
1000
1361
  }
1001
1362
 
@@ -1008,26 +1369,14 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
1008
1369
  // identity.
1009
1370
  if (reasoning && Array.isArray(reasoning.reasoning_chains)) {
1010
1371
  for (const rc of reasoning.reasoning_chains) {
1011
- importedCards.push(cardApi.createCard('reasoning', {
1012
- axiom: rc.axiom || '',
1013
- one_sentence: rc.one_sentence || rc.conclusion || '',
1014
- chain: Array.isArray(rc.chain) ? rc.chain : (rc.logic || []),
1015
- principle: rc.principle || rc.name || '',
1016
- concrete_action: rc.concrete_action || rc.so_what || '',
1017
- so_what: rc.so_what || rc.concrete_action || '',
1018
- conclusion: rc.conclusion || '',
1019
- }, rc.id || null));
1372
+ importedCards.push(cardApi.createCard('reasoning', importedFields(rc), rc.id || null));
1020
1373
  }
1021
1374
  }
1022
1375
 
1023
1376
  // NEW: import evolution stages
1024
1377
  if (evolution && Array.isArray(evolution.stages)) {
1025
1378
  for (const stage of evolution.stages) {
1026
- pushImportedCard(importedCards, 'evolution_stage', {
1027
- name: stage.name || stage.id || '',
1028
- level: stage.level != null ? stage.level : '',
1029
- description: stage.description || '',
1030
- }, stage.id || null);
1379
+ pushImportedCard(importedCards, 'evolution_stage', importedFields(stage), stage.id || null);
1031
1380
  }
1032
1381
  }
1033
1382
 
@@ -1038,6 +1387,7 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
1038
1387
  sourcePath: absSource,
1039
1388
  creatorIdentity: creatorIdentity || null,
1040
1389
  lineage: { type: 'migrated', parent_name: manifestData.name || null, parent_asset_uid: null, parent_version: manifestData.version || null, parent_asset_digest: null },
1390
+ judgmentCore: judgmentCoreFromLegacyPayload({ core: core || {} }),
1041
1391
  });
1042
1392
  // Preserve manifest metadata
1043
1393
  if (manifestData.asset_uid) project.asset_uid = manifestData.asset_uid;
@@ -1053,11 +1403,18 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
1053
1403
  if (!project.release) project.release = {};
1054
1404
  project.release.description = manifestData.description;
1055
1405
  }
1406
+ if (manifestData.title) project.title = manifestData.title;
1056
1407
  if (manifestData.license) project.license = manifestData.license;
1057
1408
  if (manifestData.author) project.author = manifestData.author;
1058
1409
  if (manifestData.languages) project.languages = manifestData.languages;
1059
1410
  if (manifestData.default_language) project.default_language = manifestData.default_language;
1060
1411
  if (manifestData.raw) project.source_manifest = publicManifestMetadata(manifestData.raw);
1412
+ if (patterns) project.source_patterns = JSON.parse(JSON.stringify(patterns));
1413
+ if (reasoning) project.source_reasoning = JSON.parse(JSON.stringify(reasoning));
1414
+ if (evolution) project.source_evolution = JSON.parse(JSON.stringify(evolution));
1415
+ if (Array.isArray(core?.core_structure)) {
1416
+ project.source_core_structure = JSON.parse(JSON.stringify(core.core_structure));
1417
+ }
1061
1418
 
1062
1419
  project.cards = importedCards;
1063
1420
  if (project.stages?.judgment_cards) {
@@ -1074,10 +1431,12 @@ function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, o
1074
1431
  }
1075
1432
 
1076
1433
  /**
1077
- * Minimal ZIP central directory parser — reads entries into a Map.
1434
+ * Minimal ZIP central directory parser — validates every entry before decompressing.
1078
1435
  */
1079
1436
  function readZipEntries(buf) {
1080
1437
  const entries = new Map();
1438
+ const metadata = [];
1439
+
1081
1440
  // Find end-of-central-directory record
1082
1441
  let eocdOffset = buf.length - 22;
1083
1442
  while (eocdOffset >= 0) {
@@ -1089,6 +1448,8 @@ function readZipEntries(buf) {
1089
1448
  const centralDirOffset = buf.readUInt32LE(eocdOffset + 16);
1090
1449
  let offset = centralDirOffset;
1091
1450
 
1451
+ // Pass 1: collect metadata, validate safety before any decompression
1452
+ let totalUncomp = 0;
1092
1453
  while (offset < eocdOffset) {
1093
1454
  const sig = buf.readUInt32LE(offset);
1094
1455
  if (sig !== 0x02014b50) break;
@@ -1101,20 +1462,47 @@ function readZipEntries(buf) {
1101
1462
  const localOffset = buf.readUInt32LE(offset + 42);
1102
1463
  const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen);
1103
1464
 
1104
- // Read local file header to get data
1105
- const localNameLen = buf.readUInt16LE(localOffset + 26);
1106
- const localExtraLen = buf.readUInt16LE(localOffset + 28);
1107
- const dataStart = localOffset + 30 + localNameLen + localExtraLen;
1108
-
1109
- if (compMethod === 0) {
1110
- entries.set(name, buf.subarray(dataStart, dataStart + uncompSize));
1111
- } else if (compMethod === 8) {
1112
- entries.set(name, zlib.inflateRawSync(buf.subarray(dataStart, dataStart + compSize)));
1465
+ // Security: path must not be absolute, must not escape
1466
+ if (!name || name.startsWith('/') || name.includes('..') || name.includes('\\')) {
1467
+ throw new Error(`ZIP entry "${name}" has an unsafe path`);
1113
1468
  }
1114
-
1469
+ // Duplicate detection
1470
+ if (entries.has(name) || metadata.some(m => m.name === name)) {
1471
+ throw new Error(`Duplicate ZIP entry: "${name}"`);
1472
+ }
1473
+ if (compMethod !== 0 && compMethod !== 8) {
1474
+ throw new Error(`ZIP entry "${name}" uses unsupported compression method ${compMethod}`);
1475
+ }
1476
+ totalUncomp += uncompSize;
1477
+ if (totalUncomp > MAX_TOTAL_UNCOMPRESSED) {
1478
+ throw new Error(`ZIP total uncompressed size ${totalUncomp} exceeds limit`);
1479
+ }
1480
+ if (uncompSize > MAX_SINGLE_ENTRY_UNCOMPRESSED) {
1481
+ throw new Error(`ZIP entry "${name}" declared size ${uncompSize} exceeds limit`);
1482
+ }
1483
+ const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
1484
+ metadata.push({ name, compMethod, compSize, uncompSize, dataStart });
1115
1485
  offset += 46 + nameLen + extraLen + commentLen;
1116
1486
  }
1117
1487
 
1488
+ if (metadata.length > MAX_ZIP_ENTRIES) {
1489
+ throw new Error(`ZIP container has too many entries (${metadata.length}, max ${MAX_ZIP_ENTRIES})`);
1490
+ }
1491
+
1492
+ // Pass 2: decompress into entries
1493
+ for (const m of metadata) {
1494
+ if (m.compMethod === 0) {
1495
+ entries.set(m.name, buf.subarray(m.dataStart, m.dataStart + m.uncompSize));
1496
+ } else {
1497
+ const raw = buf.subarray(m.dataStart, m.dataStart + m.compSize);
1498
+ const inflated = zlib.inflateRawSync(raw, { maxOutputLength: m.uncompSize });
1499
+ if (inflated.length !== m.uncompSize) {
1500
+ throw new Error(`ZIP entry "${m.name}" decompressed length ${inflated.length} != declared ${m.uncompSize}`);
1501
+ }
1502
+ entries.set(m.name, inflated);
1503
+ }
1504
+ }
1505
+
1118
1506
  return entries;
1119
1507
  }
1120
1508
 
@@ -1535,7 +1923,7 @@ function cmdCard(args) {
1535
1923
  const passphrase = resolvePassphrase(args);
1536
1924
  try {
1537
1925
  if (identity.encrypted && !passphrase) {
1538
- 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`);
1539
1927
  return null;
1540
1928
  }
1541
1929
  lockPayload.signature = creatorApi.signHumanLock(
@@ -1656,7 +2044,7 @@ function cmdMigrate(args) {
1656
2044
  const requestedName = option(args, '--name');
1657
2045
  let name = requestedName || path.basename(path.resolve(sourceDir || '.'));
1658
2046
  if (!sourceDir || (!out && !checkOnly) || (!by && !checkOnly) || (!statement && !checkOnly)) {
1659
- 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]');
1660
2048
  }
1661
2049
 
1662
2050
  const sourcePath = path.resolve(sourceDir);
@@ -1676,7 +2064,16 @@ function cmdMigrate(args) {
1676
2064
  try {
1677
2065
  // Step 1: import dev source, or use an existing Studio project directly.
1678
2066
  let creatorIdentity = null;
1679
- 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
+ }
1680
2077
  if (isStudioProject) {
1681
2078
  const loaded = readProject(sourcePath);
1682
2079
  project = loaded.project;
@@ -1887,20 +2284,21 @@ function cmdExport(args) {
1887
2284
  fail('The --format option is not supported. KDNA has one current asset format.');
1888
2285
  }
1889
2286
  if (args.includes('--sign')) {
1890
- 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.');
1891
2288
  }
1892
2289
  {
1893
2290
  const { project } = readProject(projectInput);
1894
- // BUG-11 (2026-06-27): previous logic required --password to be set
1895
- // before --password-stdin would take effect. That meant callers using
1896
- // `--password-stdin` alone (the recommended path) got `password = null`
1897
- // and the export ran unencrypted. Resolve the two flags independently:
1898
- // - --password-stdin → read password from stdin (preferred)
1899
- // - --password <pw> → take password from the flag (insecure)
1900
- // - --passphrase <pw> → legacy alias for --password
1901
- // If both are given, --password-stdin wins (it is the explicit intent).
1902
- // Use args.includes() for --password-stdin since it is a boolean flag
1903
- // (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
+ }
1904
2302
  const useStdin = args.includes('--password-stdin');
1905
2303
  let password;
1906
2304
  if (useStdin) {
@@ -1910,25 +2308,15 @@ function cmdExport(args) {
1910
2308
  if (process.stdin.isTTY) {
1911
2309
  fail(
1912
2310
  '--password-stdin requires the password to be piped in on stdin.\n' +
1913
- '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' +
1914
2312
  'If you are running interactively, omit --password-stdin and you will be prompted.'
1915
2313
  );
1916
2314
  }
1917
2315
  try {
1918
- password = fs.readFileSync(0, 'utf8').trim();
2316
+ password = readBoundedSecretStdin('Password');
1919
2317
  } catch (e) {
1920
2318
  fail(`Could not read password from stdin: ${e.message}`);
1921
2319
  }
1922
- } else {
1923
- // Bug (#55): prior version only consulted --password / --passphrase
1924
- // here, so a caller who set KDNA_PASSPHRASE for the signing path
1925
- // (works via resolvePassphrase) found that the runtime encryption
1926
- // password was silently null. Resolve from the same sources as
1927
- // the signing path so the two stay in sync.
1928
- password = process.env.KDNA_PASSPHRASE
1929
- || process.env.KDNA_PASSWORD
1930
- || option(args, '--password')
1931
- || option(args, '--passphrase');
1932
2320
  }
1933
2321
  exportProject(project, option(args, '--name') || project.name, path.resolve(out), {
1934
2322
  allowIncomplete: args.includes('--allow-incomplete'),
@@ -1944,7 +2332,9 @@ function cmdStudioInstall(args) {
1944
2332
  if (!target) fail('Usage: kdna-studio install <@scope/name|file.kdna>');
1945
2333
  try {
1946
2334
  const kdnaArgs = ['install', target, '--yes'];
1947
- if (args.includes('--trusted')) kdnaArgs.push('--trusted');
2335
+ if (args.includes('--trusted')) {
2336
+ fail('Asset signatures are outside the current Preview contract.');
2337
+ }
1948
2338
  const result = require('child_process').spawnSync('kdna', kdnaArgs, {
1949
2339
  stdio: 'inherit', encoding: 'utf8',
1950
2340
  });
@@ -1998,6 +2388,77 @@ function cmdLlm(args) {
1998
2388
  fail('Usage: kdna-studio llm <config|show>');
1999
2389
  }
2000
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
+
2001
2462
  function cmdIdentity(args) {
2002
2463
  const sub = args[0];
2003
2464
  if (sub === 'init') {
@@ -2016,7 +2477,7 @@ function cmdIdentity(args) {
2016
2477
  console.log(` Private key is encrypted — keep your passphrase safe.`);
2017
2478
  }
2018
2479
  } catch (err) {
2019
- fail(`Identity already exists. Use 'kdna-studio identity show' to view. ${err.message}`);
2480
+ failIdentityInit(err);
2020
2481
  }
2021
2482
  return;
2022
2483
  }
@@ -2034,22 +2495,14 @@ function cmdIdentity(args) {
2034
2495
  fail('Usage: kdna-studio identity <init|show>');
2035
2496
  }
2036
2497
 
2037
- function cmdReport(args) {
2038
- const { project } = readProject(args[0]);
2039
- const readiness = quality.computeReadiness(project);
2040
- const gate = projectApi.checkHumanLockGate(project);
2041
- console.log(JSON.stringify({ readiness, human_lock_gate: gate }, null, 2));
2042
- process.exit(gate.blocked ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
2043
- }
2044
-
2045
2498
  // Audit-locks: list every card that would fail the current Human Lock
2046
2499
  // gate because of missing or empty fields. Used during the
2047
2500
  // pre-migration review of source-tree assets (e.g. pro-20) so an
2048
2501
  // author can fill in the missing confidence / evidence_type / risk
2049
2502
  // description / stance statement BEFORE running `migrate`.
2050
2503
  //
2051
- // Bug (UX pro-20 migration): prior version of `kdna-studio report`
2052
- // 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
2053
2506
  // types and field names. An author migrating 20 source-tree
2054
2507
  // assets with 99 axioms + 140 risk cards + 70 stance cards had no
2055
2508
  // way to see, for a single asset, "which axioms are missing
@@ -2311,6 +2764,16 @@ async function cmdDistill(args) {
2311
2764
  suggested_card_type: c.type || c.suggested_card_type || 'axiom',
2312
2765
  supporting_evidence_ids: (c.evidence_ids || c.evidenceIds || c.supporting_evidence_ids || []).map(String),
2313
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
+ : {},
2314
2777
  candidate_status: 'proposed',
2315
2778
  scope_fit: c.scope_fit ?? c.scopeFit ?? true,
2316
2779
  };
@@ -2369,36 +2832,68 @@ function cmdCandidate(args) {
2369
2832
  }
2370
2833
 
2371
2834
  if (sub === 'promote') {
2372
- 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
+ );
2373
2841
  if (accepted.length === 0) fail('No candidates to promote. Accept some candidates first.');
2374
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
+
2375
2871
  for (const c of accepted) {
2376
- // Promote candidates to draft cards. For axioms we must populate every
2377
- // field the Human Lock gate and migrate path require — empty strings
2378
- // cause migrate to fail ("critical fields missing from axioms"). When
2379
- // a candidate lacks the field, fall back to the candidate's own
2380
- // one_sentence/full_statement so the lock gate at least has substance
2381
- // to verify. Authors are expected to enrich these fields before
2382
- // locking.
2383
- 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
+ };
2384
2886
  if (c.suggested_card_type === 'axiom') {
2385
- fields.one_sentence = c.one_sentence || '';
2386
- fields.full_statement = c.full_statement || c.one_sentence || '<TBD: full_statement>';
2387
- fields.why = c.why || '<TBD: why>';
2388
- // applies_when / does_not_apply_when are arrays, not strings.
2389
- // Empty string is invalid; emit an empty array so checkRequiredFields
2390
- // (which treats `length === 0` as missing) correctly reports them.
2391
- fields.applies_when = Array.isArray(c.applies_when) ? c.applies_when : [];
2392
- fields.does_not_apply_when = Array.isArray(c.does_not_apply_when) ? c.does_not_apply_when : [];
2393
- fields.failure_risk = c.failure_risk || '<TBD: failure_risk>';
2394
- } else {
2395
- fields.one_sentence = c.one_sentence || '';
2396
- 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;
2397
2889
  }
2398
2890
  const card = cardApi.createCard(c.suggested_card_type, fields);
2399
- card.evidence_refs = c.supporting_evidence_ids;
2891
+ card.evidence_refs = Array.isArray(c.supporting_evidence_ids)
2892
+ ? c.supporting_evidence_ids
2893
+ : [];
2400
2894
  project.cards = project.cards || [];
2401
2895
  project.cards.push(card);
2896
+ c.promoted_card_id = card.id;
2402
2897
  }
2403
2898
  if (project.stages?.judgment_cards) {
2404
2899
  project.stages.judgment_cards.total = project.cards.length;
@@ -2416,175 +2911,46 @@ async function cmdInterview(args) {
2416
2911
  const projectInput = args[0];
2417
2912
  const stage = option(args, '--stage');
2418
2913
  if (!projectInput) fail('Usage: kdna-studio interview <project> [--stage distill|clarify|correct|replay]');
2419
- const { project } = readProject(projectInput);
2914
+ const { projectPath, project } = readProject(projectInput);
2915
+ let results;
2420
2916
 
2421
2917
  if (stage) {
2422
- 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];
2423
2927
  console.log(`\n=== ${result.label} ===\n${result.content}`);
2424
2928
  } else {
2425
- await ai.interview.runInterviewInteractive(llm.config(), project, {});
2929
+ results = await ai.interview.runInterviewInteractive(llm.config(), project, {});
2426
2930
  }
2427
- }
2428
-
2429
- async function cmdFeynman(args) {
2430
- const projectInput = args[0];
2431
- const cardId = args[1];
2432
- if (!projectInput || !cardId) fail('Usage: kdna-studio feynman <project> <card-id>');
2433
- const useJson = args.includes('--json');
2434
- const { projectPath, project } = readProject(projectInput);
2435
- const cardIdx = (project.cards || []).findIndex(c => c.id === cardId);
2436
- if (cardIdx < 0) fail(`Card not found: ${cardId}`);
2437
- const card = project.cards[cardIdx];
2438
-
2439
- // Canonical field name in the card schema is `feynman_restatement`, not
2440
- // `feynman_text`. The old check was dead code (nothing writes the wrong
2441
- // field) and made this command fail on every card.
2442
- //
2443
- // Bug (#62 + #68 follow-up): the prior `if (!card.feynman_restatement)
2444
- // fail(...)` block rejected every card that had no human-written
2445
- // restatement, even when the AI evaluator in feynman.js was
2446
- // prepared to auto-synthesise one. A caller without a configured
2447
- // LLM (no `KDNA_API_KEY`, no `kdna-studio llm config`) would
2448
- // get a `fail` instead of a structured note. The fix removes the
2449
- // guard: a missing restatement is now handed off to the evaluator
2450
- // (which either asks the LLM to evaluate a synthesised one or,
2451
- // without an LLM, returns a structured `synthesised_restatement`
2452
- // for the caller to use as a starting point). The error message
2453
- // is kept as a secondary path: only if the evaluator itself fails
2454
- // does the command exit non-zero, and only then with a clear
2455
- // pointer to the manual `card update` path.
2456
- //
2457
- // We do still need a restatement-shaped object to feed the
2458
- // evaluator, so we synthesise one in-memory when the card has
2459
- // none. This does NOT persist the synthesised restatement to the
2460
- // project (so the user still has to opt in via `card update` to
2461
- // keep one across runs), but it lets the no-LLM path return a
2462
- // useful result instead of a hard fail.
2463
- if (!card.feynman_restatement) {
2464
- const synthesised = ai.feynman.synthFeynmanRestatement(card.fields || {});
2465
- card.feynman_restatement = { text: synthesised, synthesised: true };
2466
- console.warn(`No feynman_restatement on ${cardId}; using synthesised:\n "${synthesised}"`);
2467
- }
2468
-
2469
- if (!useJson) console.log(`Evaluating Feynman restatement for card: ${cardId}`);
2470
-
2471
- // Bug (#1 UX follow-up): if no LLM is configured and the caller
2472
- // did not pass --no-llm, route through cmdNeedsLlm which either
2473
- // gives a clear "configure LLM" error or accepts the --no-llm
2474
- // path. Without this branch, the no-LLM caller would fall
2475
- // through to the LLM call below and get a stack trace.
2476
- const llmGate = cmdNeedsLlm(args, 'feynmann');
2477
-
2478
- let result;
2479
- if (llmGate.noLlm) {
2480
- // Static evaluation: every criterion is "unsure" (✗), score 0,
2481
- // but the command still produces a useful structured result and
2482
- // saves the synthesised restatement to the card.
2483
- result = {
2484
- score: 0,
2485
- criteria: Object.fromEntries(Object.keys(ai.feynman.CRITERIA).map(k => [k, false])),
2486
- explanations: { noLlm: llmGate.message },
2487
- suggestions: [
2488
- 'No LLM configured — every criterion shows ✗. Run `kdna-studio llm config` and re-evaluate for a real score.',
2489
- 'Or set the synthesised restatement via: kdna-studio card update <project> ' + cardId + ' --field feynman_restatement=\'{"text":"<your own restatement>"}\'',
2490
- ],
2491
- };
2492
- } else {
2493
- result = await ai.feynman.evaluate(llmGate.cfg, card, {});
2494
- }
2495
- const score = result.score || 0;
2496
- const passed = score >= 4;
2497
-
2498
- if (useJson) {
2499
- console.log(JSON.stringify({
2500
- card_id: cardId,
2501
- score,
2502
- passed,
2503
- no_llm: !!llmGate.noLlm,
2504
- criteria: result.criteria || {},
2505
- suggestions: result.suggestions || [],
2506
- feynman_restatement: card.feynman_restatement,
2507
- }, null, 2));
2508
- } else {
2509
- console.log(`Score: ${score}/5 ${passed ? '✓ publishable' : '✗ below threshold (need 4/5)'}${llmGate.noLlm ? ' (--no-llm: static result)' : ''}`);
2510
- for (const [criterion, desc] of Object.entries(ai.feynman.CRITERIA)) {
2511
- const r = (result.criteria || {})[criterion] ? '✓' : '✗';
2512
- console.log(` ${r} ${criterion}`);
2513
- }
2514
- if (result.suggestions && result.suggestions.length > 0) {
2515
- console.log('\nSuggestions:');
2516
- result.suggestions.forEach(s => console.log(` - ${s}`));
2517
- }
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
+ );
2518
2950
  }
2519
-
2520
- card.feynman_evaluation = { score, criteria: result.criteria, suggestions: result.suggestions, evaluated_at: new Date().toISOString() };
2521
- project.cards[cardIdx] = card;
2522
2951
  writeProject(projectPath, project);
2523
2952
  }
2524
2953
 
2525
- async function cmdTest(args) {
2526
- const projectInput = args[0];
2527
- const input = option(args, '--input');
2528
- const preset = option(args, '--preset', 'baseline');
2529
- if (!projectInput || !input) fail('Usage: kdna-studio test <project> --input "<text>" [--preset baseline|edge|contradiction]');
2530
- const { project } = readProject(projectInput);
2531
- const domainName = project.name || 'Untitled Project';
2532
- const domainPrompt = `Domain: ${domainName}\n${project.description || ''}`;
2533
-
2534
- // Bug (#50): prior version hand-rolled the prompt for the 3 preset
2535
- // names and bypassed ai.testlab.testPreset entirely. The exported
2536
- // testPreset function was dead code, and the help text advertised
2537
- // names (baseline / edge / contradiction) that did not match the
2538
- // actual preset keys in testlab.js (baseline / edge_case /
2539
- // contradiction). Result: --preset edge silently ran the
2540
- // contradiction branch, and any caller that asked for "edge_case"
2541
- // fell through to the contradiction branch too.
2542
- //
2543
- // The fix routes the call through testPreset when a known preset is
2544
- // given, and accepts both help-text names (baseline / edge /
2545
- // contradiction) and the canonical testlab names (baseline / edge_case
2546
- // / contradiction).
2547
- const PRESET_ALIASES = {
2548
- baseline: 'baseline',
2549
- edge: 'edge_case',
2550
- edge_case: 'edge_case',
2551
- contradiction: 'contradiction',
2552
- };
2553
- const canonical = PRESET_ALIASES[preset] || 'baseline';
2554
- console.log(`Testing: ${domainName} [${preset}]`);
2555
-
2556
- // Bug (#1 UX): gate LLM-requiring paths through cmdNeedsLlm.
2557
- // The test command always needs an LLM (it compares without/with
2558
- // the loaded domain). --no-llm gives a static "no-op" comparison
2559
- // that lists the cards that *would* be applied.
2560
- const llmGate = cmdNeedsLlm(args, 'test');
2561
- const cfg = llmGate.noLlm ? null : llmGate.cfg;
2562
-
2563
- if (canonical === 'baseline') {
2564
- // Baseline is a single comparison: keep the prior single-result
2565
- // shape so existing parsers / consumers see the same output.
2566
- const prompt = `Test the core judgment: ${input}`;
2567
- if (cfg === null) {
2568
- // No-LLM static result
2569
- 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 || '' }));
2570
- console.log(JSON.stringify({ no_llm: true, message: llmGate.message, would_apply: cards }, null, 2));
2571
- return;
2572
- }
2573
- const result = await ai.testlab.compare(cfg, domainName, prompt, domainPrompt, {});
2574
- console.log(JSON.stringify(result, null, 2));
2575
- return;
2576
- }
2577
- // For non-baseline presets, run the preset + the baseline so the user
2578
- // can see both side by side. The preset prompt is the one in
2579
- // ai/testlab.js — that's the code path that was dead before.
2580
- if (cfg === null) {
2581
- console.log(JSON.stringify({ no_llm: true, message: llmGate.message }, null, 2));
2582
- return;
2583
- }
2584
- const result = await ai.testlab.testPreset(cfg, domainName, input, domainPrompt, {});
2585
- console.log(JSON.stringify(result[canonical] || result, null, 2));
2586
- }
2587
-
2588
2954
  const args = process.argv.slice(2);
2589
2955
  const cmd = args[0];
2590
2956
  if (!cmd || cmd === '--help' || cmd === '-h') {
@@ -2598,7 +2964,23 @@ if (cmd === '--version' || cmd === '-v') {
2598
2964
 
2599
2965
  (async () => {
2600
2966
  try {
2601
- 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));
2602
2984
  else if (cmd === 'migrate') cmdMigrate(args.slice(1));
2603
2985
  else if (cmd === 'import') cmdImport(args.slice(1));
2604
2986
  else if (cmd === 'filter') cmdFilter(args.slice(1));
@@ -2607,15 +2989,12 @@ try {
2607
2989
  else if (cmd === 'distill') await cmdDistill(args.slice(1));
2608
2990
  else if (cmd === 'candidate') cmdCandidate(args.slice(1));
2609
2991
  else if (cmd === 'interview') await cmdInterview(args.slice(1));
2610
- else if (cmd === 'feynman') await cmdFeynman(args.slice(1));
2611
- else if (cmd === 'test') await cmdTest(args.slice(1));
2612
2992
  else if (cmd === 'card') cmdCard(args.slice(1));
2613
2993
  else if (cmd === 'lock') cmdLock(args.slice(1));
2614
2994
  else if (cmd === 'compile') cmdCompile(args.slice(1));
2615
2995
  else if (cmd === 'export') cmdExport(args.slice(1));
2616
2996
  else if (cmd === 'llm') cmdLlm(args.slice(1));
2617
2997
  else if (cmd === 'identity') cmdIdentity(args.slice(1));
2618
- else if (cmd === 'report') cmdReport(args.slice(1));
2619
2998
  else if (cmd === 'audit-locks') cmdAuditLocks(args.slice(1));
2620
2999
  else if (cmd === 'install') cmdStudioInstall(args.slice(1));
2621
3000
  else if (cmd === 'update') cmdStudioUpdate(args.slice(1));