@aikdna/kdna-studio-cli 0.10.2 → 0.13.0-rc.components.2

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.
@@ -1,2952 +1,7 @@
1
1
  #!/usr/bin/env node
2
- const fs = require('fs');
3
- const os = require('os');
4
- const path = require('path');
5
- const crypto = require('crypto');
6
- const zlib = require('zlib');
7
- const cbor = require('cbor-x');
8
- const { execFileSync } = require('child_process');
9
-
10
- function loadStudioCore() {
11
- const publishedCore = require('@aikdna/kdna-studio-core');
12
- if (publishedCore.exportRuntime && publishedCore.protocolContract) return publishedCore;
13
- try {
14
- return require('../../kdna-studio-core/src');
15
- } catch (_) {
16
- return publishedCore;
17
- }
18
- }
19
-
20
- const {
21
- project: projectApi,
22
- cards: cardApi,
23
- evidence: evidenceApi,
24
- compile: compileApi,
25
- quality,
26
- creator: creatorApi,
27
- distillation: distillationApi,
28
- exportRuntime,
29
- protocolContract,
30
- } = loadStudioCore();
31
-
32
- const llm = require('../src/llm');
33
-
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
- const ai = require('../src/ai');
63
-
64
- const EXIT = { OK: 0, INPUT_ERROR: 2, HUMAN_LOCK_REQUIRED: 4, TRUST_FAILED: 5 };
65
-
66
- function usage() {
67
- console.log(`kdna-studio — Studio-compatible KDNA authoring CLI
68
-
69
- LLM (AI-powered authoring; every AI command accepts --no-llm for a static result):
70
- kdna-studio llm config [--provider <name>] [--model <name>] [--key-pipe] [--url <base-url>]
71
- 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.)
75
-
76
- Identity:
77
- kdna-studio identity init [--name <display-name>]
78
- kdna-studio identity show
79
-
80
- Create (three entry paths):
81
- kdna-studio create <project-dir> --name <@scope/name> [--author <name>] # blank
82
- kdna-studio create <project-dir> --from-kdna <file.kdna> --name <@scope/name>
83
- kdna-studio create <project-dir> --from-folder <source-dir> --name <@scope/name>
84
-
85
- 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]
87
- kdna-studio migrate <source-dir> --check --name <@scope/name> # pre-flight: report which fields would block the export without writing the .kdna
88
- kdna-studio audit-locks <project> [--type axiom|risk|stance|...] [--json] # list cards with missing Human Lock fields (per-card-type, per-field)
89
-
90
- Authoring:
91
- kdna-studio import <project> <source-file-or-dir> # import evidence (txt/md/json/yaml/csv/log/srt/vtt/html)
92
- kdna-studio filter <project> # check evidence for sensitive content
93
- kdna-studio source <project> # classify imported evidence against declared target
94
- kdna-studio card list <project>
95
- kdna-studio card add <project> <type> --field key=value [--field key=value] [--template <name>] [--no-strict]
96
- kdna-studio card update <project> <card-id> --field key=value
97
- kdna-studio card remove <project> <card-id>
98
- kdna-studio card approve <project> <card-id|--all> --by <id> --statement <text> [--sign] [--passphrase <pass>]
99
- kdna-studio card unlock <project> <card-id> --by <id> --statement <text>
100
- kdna-studio compile <project> --out <dir>
101
- kdna-studio export <project> --out <file.kdna> [--allow-incomplete] [--password <pw>|--password-stdin]
102
- kdna-studio export-route-card <domain-id> [--out=<path>] # Export route-card sidecar skeleton
103
- kdna-studio export-consumer-index [--entries=<domain-ids>] [--out=<path>] # Export consumer-index skeleton
104
-
105
- AI Authoring (requires LLM config: kdna-studio llm config):
106
- kdna-studio distill <project> --ai # AI-driven candidate extraction from evidence
107
- kdna-studio distill <project> --candidates <file.json> # load pre-generated AI candidates
108
- 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
-
112
- Distillation:
113
- kdna-studio target declare <project> # declare distillation target interactively
114
- kdna-studio target declare <project> --category <cat> --scope <scope> --granularity <gran> --task <task> [--include <area,area>] [--exclude <area,area>] [--load-condition <condition>]
115
- kdna-studio target show <project> # show current distillation target
116
- kdna-studio candidate list <project> # list candidates with scope and status
117
- kdna-studio candidate accept <project> <candidate-id>
118
- kdna-studio candidate reject <project> <candidate-id>
119
- kdna-studio candidate override <project> <candidate-id> # override scope gate
120
- kdna-studio candidate promote <project> # promote accepted+scope_fit → cards
121
- kdna-studio report <project>
122
- kdna-studio audit-locks <project> # list all axioms/risk/stance missing Human Lock fields
123
-
124
- Project may be a directory containing studio.project.json or a project JSON file.`);
125
- }
126
-
127
- function fail(message, code = EXIT.INPUT_ERROR) {
128
- console.error(`Error: ${message}`);
129
- process.exit(code);
130
- }
131
-
132
- // Bug (#65): `fail()` calls `process.exit`, which skips `finally`
133
- // blocks in the same call stack. Any tmpDir that a `try { ... }
134
- // finally { fs.rmSync(tmpDir) }` block is in the middle of running
135
- // will leak. The fix tracks every temporary directory the process
136
- // creates and removes it on process exit (including the exit path
137
- // triggered by `fail()`).
138
- const _tempDirs = new Set();
139
- function trackTempDir(dir) {
140
- if (dir) _tempDirs.add(dir);
141
- return dir;
142
- }
143
- function cleanupTempDirs() {
144
- for (const dir of _tempDirs) {
145
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
146
- }
147
- _tempDirs.clear();
148
- }
149
- process.on('exit', cleanupTempDirs);
150
- process.on('SIGINT', () => { cleanupTempDirs(); process.exit(130); });
151
- process.on('SIGTERM', () => { cleanupTempDirs(); process.exit(143); });
152
-
153
- function option(args, name, fallback = null) {
154
- const idx = args.indexOf(name);
155
- if (idx < 0) return fallback;
156
- const value = args[idx + 1];
157
- if (!value || value.startsWith('--')) fail(`Missing value for ${name}`);
158
- return value;
159
- }
160
-
161
- /**
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
169
- *
170
- * Returns the passphrase string, or null if none was provided.
171
- */
172
- function resolvePassphrase(args) {
173
- if (args.includes('--passphrase-stdin')) {
174
- if (process.stdin.isTTY) {
175
- fail(
176
- '--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'
178
- );
179
- }
180
- try {
181
- return fs.readFileSync(0, 'utf8').trim();
182
- } catch (e) {
183
- fail(`Could not read passphrase from stdin: ${e.message}`);
184
- }
185
- }
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
- return null;
193
- }
194
-
195
- function resolveApiKey(args) {
196
- // 1. KDNA_API_KEY environment variable (preferred)
197
- if (process.env.KDNA_API_KEY) return process.env.KDNA_API_KEY;
198
-
199
- // 2. Read from stdin via --key-pipe flag
200
- if (args.includes('--key-pipe')) {
201
- 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');
203
- return null;
204
- }
205
- try {
206
- return fs.readFileSync(0, 'utf8').trim();
207
- } catch (e) {
208
- console.error('Error reading API key from stdin:', e.message);
209
- return null;
210
- }
211
- }
212
-
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
- return null;
221
- }
222
-
223
- function optionsAll(args, name) {
224
- const values = [];
225
- for (let i = 0; i < args.length; i++) {
226
- if (args[i] === name) {
227
- const value = args[i + 1];
228
- if (!value || value.startsWith('--')) fail(`Missing value for ${name}`);
229
- values.push(value);
230
- i++;
231
- }
232
- }
233
- return values;
234
- }
235
-
236
- function resolveProjectPath(input) {
237
- if (!input) fail('Project path required');
238
- const abs = path.resolve(input);
239
- return fs.existsSync(abs) && fs.statSync(abs).isDirectory()
240
- ? path.join(abs, 'studio.project.json')
241
- : abs;
242
- }
243
-
244
- function readProject(input) {
245
- const projectPath = resolveProjectPath(input);
246
- if (!fs.existsSync(projectPath)) fail(`Project not found: ${projectPath}`);
247
- return { projectPath, project: projectApi.loadProject(fs.readFileSync(projectPath, 'utf8')) };
248
- }
249
-
250
- function writeProject(projectPath, project) {
251
- fs.mkdirSync(path.dirname(projectPath), { recursive: true });
252
- fs.writeFileSync(projectPath, projectApi.saveProject(project));
253
- }
254
-
255
- function writeFiles(outDir, files) {
256
- fs.mkdirSync(outDir, { recursive: true });
257
- for (const [name, content] of Object.entries(files)) {
258
- const target = path.join(outDir, name);
259
- fs.mkdirSync(path.dirname(target), { recursive: true });
260
- fs.writeFileSync(target, content);
261
- }
262
- }
263
-
264
- const ARRAY_FIELD_NAMES = new Set([
265
- 'applies_when',
266
- 'does_not_apply_when',
267
- 'acceptable_exceptions',
268
- ]);
269
-
270
- function parseFields(args) {
271
- const fields = {};
272
- for (const pair of optionsAll(args, '--field')) {
273
- const eq = pair.indexOf('=');
274
- if (eq < 1) fail(`Invalid --field "${pair}". Use key=value.`);
275
- let key = pair.slice(0, eq);
276
- const appendArray = key.endsWith('[]');
277
- if (appendArray) key = key.slice(0, -2);
278
- const raw = pair.slice(eq + 1);
279
- let value;
280
- if (raw.startsWith('[') || raw.startsWith('{')) {
281
- try {
282
- value = JSON.parse(raw);
283
- } catch (_) {
284
- value = raw;
285
- }
286
- } else if (raw.includes('|')) {
287
- value = raw.split('|').map((s) => s.trim()).filter(Boolean);
288
- } else {
289
- value = raw;
290
- }
291
-
292
- if (appendArray) {
293
- const values = Array.isArray(value) ? value : [value];
294
- fields[key] = [...(Array.isArray(fields[key]) ? fields[key] : []), ...values];
295
- } else if (ARRAY_FIELD_NAMES.has(key) && typeof value === 'string') {
296
- fields[key] = value.trim() ? [value.trim()] : [];
297
- } else {
298
- fields[key] = value;
299
- }
300
- }
301
- return fields;
302
- }
303
-
304
- function semverValue(value, fallback = '1.0.0') {
305
- const raw = String(value || '').trim();
306
- if (/^[0-9]+\.[0-9]+\.[0-9]+([+-].+)?$/.test(raw)) return raw;
307
- const twoPart = raw.match(/^([0-9]+)\.([0-9]+)$/);
308
- if (twoPart) return `${twoPart[1]}.${twoPart[2]}.0`;
309
- return fallback;
310
- }
311
-
312
- function isoDateTime(value) {
313
- if (!value) return new Date().toISOString();
314
- const raw = String(value);
315
- if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw}T00:00:00.000Z`;
316
- const d = new Date(raw);
317
- return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
318
- }
319
-
320
- function slugSegment(value, fallback = 'asset') {
321
- const s = String(value || fallback)
322
- .trim()
323
- .replace(/^@/, '')
324
- .replace(/[^a-zA-Z0-9_.-]+/g, '_')
325
- .replace(/^_+|_+$/g, '');
326
- return s || fallback;
327
- }
328
-
329
- function assetIdFromName(name) {
330
- const raw = String(name || '').trim();
331
- if (/^[a-zA-Z][a-zA-Z0-9_-]*(:[a-zA-Z0-9_.-]+)+$/.test(raw)) return raw;
332
- const scoped = raw.match(/^@?([^/:\s]+)\/([^/:\s]+)$/);
333
- if (scoped) return `kdna:${slugSegment(scoped[1], 'scope')}:${slugSegment(scoped[2], 'asset')}`;
334
- const parts = raw.split(':').filter(Boolean);
335
- if (parts.length >= 2) return `kdna:${parts.map((p) => slugSegment(p)).join(':')}`;
336
- return `kdna:studio:${slugSegment(raw, 'asset')}`;
337
- }
338
-
339
- function titleFromName(name, assetId) {
340
- const raw = String(name || '').trim();
341
- if (raw) return raw.replace(/^@/, '').replace('/', ' / ');
342
- return assetId;
343
- }
344
-
345
- function packageNameFromManifest(manifest = {}) {
346
- if (manifest.name) return manifest.name;
347
- const match = String(manifest.asset_id || '').match(/^kdna:([^:]+):(.+)$/);
348
- if (match) return `@${match[1]}/${match[2]}`;
349
- return manifest.title || manifest.asset_id || null;
350
- }
351
-
352
- function publicManifestMetadata(raw = {}) {
353
- const copy = { ...raw };
354
- delete copy.registry;
355
- delete copy.quality_badge;
356
- delete copy.signature;
357
- delete copy.media_type;
358
- return copy;
359
- }
360
-
361
- function cleanFields(fields = {}) {
362
- const cleaned = {};
363
- for (const [key, value] of Object.entries(fields || {})) {
364
- if (value !== undefined && value !== null) cleaned[key] = value;
365
- }
366
- return cleaned;
367
- }
368
-
369
- function importedFields(entry = {}, options = {}) {
370
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return {};
371
- const fields = JSON.parse(JSON.stringify(entry));
372
- for (const key of ['id', 'status', 'locked', 'human_lock', 'audit_log', 'source_authored']) {
373
- delete fields[key];
374
- }
375
- if (options.keepType !== true) delete fields.type;
376
- return cleanFields(fields);
377
- }
378
-
379
- function importedCard(type, fields = {}, id = null) {
380
- const card = cardApi.createCard(type, cleanFields(fields), id || null);
381
- if (card === null) {
382
- throw new Error(`failed to create card type="${type}" id="${id || '(auto)'}" — ` +
383
- `the card type may require additional required fields or the fields may be malformed`);
384
- }
385
- return card;
386
- }
387
-
388
- // Bug (#3 + #4 UX): payload.kdnab intentionally carries the same
389
- // misunderstanding card in two places — `patterns[]` (the runtime schema's
390
- // primary card list) and `reasoning.failure_modes[]` (the runtime schema's
391
- // "what bad judgment looks like" view). Both share the same `id`.
392
- // Prior version imported both, doubling every misunderstanding on
393
- // re-import. The dedup is by id: when a card with the same id is
394
- // pushed twice, the second push merges fields (the failure_modes
395
- // entry carries a different field shape than patterns; merging
396
- // keeps the richer failure_risk / applies_when / does_not_apply_when
397
- // from the patterns entry).
398
- const _importedIds = new Set();
399
- function pushImportedCard(cards, type, fields = {}, id = null) {
400
- if (id && _importedIds.has(id)) {
401
- // Already imported — merge richer fields into the existing card.
402
- const existing = cards.find(c => c.id === id);
403
- if (existing) {
404
- existing.fields = existing.fields || {};
405
- for (const [k, v] of Object.entries(fields || {})) {
406
- if (v !== undefined && v !== null && v !== '' &&
407
- (existing.fields[k] === undefined || existing.fields[k] === '' ||
408
- (Array.isArray(existing.fields[k]) && existing.fields[k].length === 0))) {
409
- existing.fields[k] = v;
410
- }
411
- }
412
- }
413
- return;
414
- }
415
- const card = importedCard(type, fields, id);
416
- // Register the id only after card creation succeeds; a failed creation
417
- // must not consume the id and prevent retry.
418
- if (card && id) _importedIds.add(id);
419
- if (card) cards.push(card);
420
- }
421
-
422
- function resetImportedIds() { _importedIds.clear(); }
423
-
424
- function decodePayload(bytes, manifest = {}) {
425
- const raw = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
426
- const encoding = manifest.payload?.encoding || manifest.container?.payload_encoding || null;
427
- if (encoding && encoding !== 'cbor') {
428
- throw new Error(`unsupported payload encoding "${encoding}"; current .kdna assets require CBOR`);
429
- }
430
- try {
431
- return cbor.decode(raw);
432
- } catch (error) {
433
- throw new Error(`payload.kdnab is not valid CBOR: ${error.message}`);
434
- }
435
- }
436
-
437
- // Runtime path: trust the producer (export-runtime) to have already shaped
438
- // each card's fields. The producer normalises everything into a
439
- // canonical shape, so we just round-trip the raw object. Bug (#57):
440
- // the legacy path below explicitly constructs each field. The two
441
- // paths cannot be unified without losing either runtime per-field
442
- // guarantees or legacy's defensive defaults — the asymmetry is
443
- // intentional. Documented here so a future reader does not "fix" it
444
- // by collapsing one path into the other.
445
- function cardsFromRuntimePayload(payload) {
446
- resetImportedIds();
447
- const cards = [];
448
- for (const sourceCard of (Array.isArray(payload.source_cards) ? payload.source_cards : [])) {
449
- pushImportedCard(cards, sourceCard.type, sourceCard.fields || {}, sourceCard.id || null);
450
- }
451
- if (cards.length > 0) return cards;
452
-
453
- for (const ax of (Array.isArray(payload.core?.axioms) ? payload.core?.axioms : [])) {
454
- pushImportedCard(cards, 'axiom', importedFields(ax), ax.id || null);
455
- }
456
- for (const ont of (Array.isArray(payload.core?.ontology) ? payload.core?.ontology : [])) {
457
- pushImportedCard(cards, 'ontology', importedFields(ont), ont.id || null);
458
- }
459
- for (const fw of (Array.isArray(payload.core?.frameworks) ? payload.core?.frameworks : [])) {
460
- pushImportedCard(cards, 'framework', importedFields(fw), fw.id || null);
461
- }
462
- for (const stance of (Array.isArray(payload.core?.stances) ? payload.core?.stances : [])) {
463
- pushImportedCard(cards, 'stance', importedFields(stance), stance.id || null);
464
- }
465
- for (const aesthetic of (Array.isArray(payload.core?.aesthetics) ? payload.core?.aesthetics : [])) {
466
- pushImportedCard(cards, 'aesthetic', importedFields(aesthetic), aesthetic.id || null);
467
- }
468
- for (const boundary of (Array.isArray(payload.core?.boundaries) ? payload.core?.boundaries : [])) {
469
- pushImportedCard(cards, 'boundary', importedFields(boundary), boundary.id || null);
470
- }
471
- // risk_model may be either an array of risk items (legacy) or
472
- // `{risks: [...]}` (current). Treat both shapes; ignore everything else
473
- // rather than crashing with "object is not iterable".
474
- const riskModel = payload.core?.risk_model;
475
- const riskItems = Array.isArray(riskModel) ? riskModel : (riskModel?.risks || []);
476
- for (const risk of riskItems) {
477
- pushImportedCard(cards, 'risk', importedFields(risk), risk.id || null);
478
- }
479
- for (const pattern of (Array.isArray(payload.patterns) ? payload.patterns : [])) {
480
- // Bug (#146): prior version dropped any payload.patterns entry
481
- // that lacked an explicit `type` field. The runtime schema lists
482
- // misunderstandings in patterns[] without a `type` discriminator
483
- // (their shape is `{wrong, correct, key_distinction, why}`),
484
- // and several exporters omit `type` to keep the on-the-wire
485
- // shape compact. The fix infers a type from the field set when
486
- // `type` is missing, and only falls through to "skip" when the
487
- // entry truly cannot be classified.
488
- let type = pattern.type;
489
- let fields = pattern;
490
- if (!type) {
491
- // Heuristics, in priority order — most specific to most general.
492
- if ('logic' in pattern && ('so_what' in pattern || 'conclusion' in pattern)) type = 'reasoning';
493
- else if ('wrong' in pattern && 'correct' in pattern) type = 'misunderstanding';
494
- else if ('term' in pattern && 'definition' in pattern) type = 'term';
495
- else if ('term' in pattern && 'why' in pattern && 'replace_with' in pattern) type = 'banned_term';
496
- else if ('what_it_looks_like' in pattern && 'how_to_fix' in pattern) type = 'pattern';
497
- else if ('name' in pattern && ('description' in pattern || 'one_sentence' in pattern)) type = 'aesthetic';
498
- else {
499
- // Truly unclassifiable. Log a one-line warning and skip
500
- // rather than crash, so the rest of the asset still imports.
501
- console.warn(
502
- `Skipping payload.patterns entry with no recognisable type ` +
503
- `(id=${pattern.id || '?'}, keys=${Object.keys(pattern).join(',') || '<empty>'})`
504
- );
505
- continue;
506
- }
507
- fields = importedFields(pattern);
508
- } else {
509
- fields = importedFields(pattern, { keepType: type === 'pattern' || LEGACY_PATTERN_SUBTYPES.has(type) });
510
- }
511
- if (LEGACY_PATTERN_SUBTYPES.has(type)) {
512
- fields = {
513
- ...fields,
514
- type,
515
- legacy_subtype: fields.legacy_subtype || type,
516
- };
517
- type = 'pattern';
518
- }
519
- pushImportedCard(cards, type, fields, pattern.id || null);
520
- }
521
- for (const scenario of (Array.isArray(payload.scenarios) ? payload.scenarios : [])) {
522
- pushImportedCard(cards, 'scenario', importedFields(scenario), scenario.id || null);
523
- }
524
- for (const item of (Array.isArray(payload.cases) ? payload.cases : [])) {
525
- pushImportedCard(cards, 'case', importedFields(item), item.id || null);
526
- }
527
- // The current payload contract has one self-check field. Refuse to infer a
528
- // parallel field name: Core validation owns format migration, while Studio
529
- // imports only the current validated payload shape.
530
- const rawSelfCheck = payload.reasoning?.self_check;
531
- const selfCheckArr = Array.isArray(rawSelfCheck) ? rawSelfCheck : [];
532
- for (const selfCheck of selfCheckArr) {
533
- const fields = typeof selfCheck === 'string' ? { question: selfCheck } : importedFields(selfCheck);
534
- pushImportedCard(cards, 'self_check', fields, typeof selfCheck === 'object' ? selfCheck.id || null : null);
535
- }
536
- for (const failureMode of (Array.isArray(payload.reasoning?.failure_modes) ? payload.reasoning?.failure_modes : [])) {
537
- // Bug (#145 follow-up): the prior version dropped three
538
- // judgment fields (failure_risk, applies_when, does_not_apply_when)
539
- // when reading failure_modes back out of an asset. A Studio
540
- // project that round-tripped through `migrate` lost
541
- // these fields every time. The fix reads them through, matching
542
- // the export-side map in kdna-studio-core's buildPayload.
543
- const fields = {
544
- ...importedFields(failureMode),
545
- wrong: failureMode.mode || failureMode.wrong || '',
546
- correct: failureMode.correct || '',
547
- key_distinction: failureMode.key_distinction || '',
548
- why: failureMode.why || '',
549
- failure_risk: failureMode.failure_risk || '',
550
- applies_when: Array.isArray(failureMode.applies_when) ? failureMode.applies_when : [],
551
- does_not_apply_when: Array.isArray(failureMode.does_not_apply_when) ? failureMode.does_not_apply_when : [],
552
- };
553
- delete fields.mode;
554
- pushImportedCard(cards, 'misunderstanding', fields, failureMode.id || null);
555
- }
556
- for (const chain of (Array.isArray(payload.reasoning?.reasoning_chains) ? payload.reasoning?.reasoning_chains : [])) {
557
- // Bug (#4 UX): compile/index.js synthesises one reasoning chain
558
- // per locked axiom (the legacy 1.0.0 path) and tags it
559
- // `source_authored: false`. Importing those synthesised chains
560
- // doubled the card count on round-trip. The fix skips chains
561
- // that the producer explicitly marked as synthesised.
562
- if (chain.source_authored === false) continue;
563
- pushImportedCard(cards, 'reasoning', importedFields(chain), chain.id || null);
564
- }
565
- for (const stage of (Array.isArray(payload.evolution?.stages) ? payload.evolution?.stages : [])) {
566
- // Distinguish source-authored stages (preserved identity) from
567
- // audit-log-derived stages (synthesised). The compile path tags
568
- // source_authored: true; import those verbatim and skip the rest
569
- // so the importer doesn't double-count what was already in the
570
- // locked judgment cards.
571
- if (stage.source_authored === true) {
572
- pushImportedCard(cards, 'evolution_stage', importedFields(stage), stage.id || null);
573
- }
574
- }
575
- return cards;
576
- }
577
-
578
- function cardsFromPayload(payload) {
579
- if (!payload || typeof payload !== 'object') return [];
580
- if (
581
- payload.profile === protocolContract.PAYLOAD_PROFILE &&
582
- payload.profile_version === protocolContract.PAYLOAD_PROFILE_VERSION
583
- ) {
584
- return cardsFromRuntimePayload(payload);
585
- }
586
- return [];
587
- }
588
-
589
- function judgmentCoreFromRuntimePayload(payload) {
590
- const core = payload?.core;
591
- if (!core || typeof core !== 'object' || Array.isArray(core)) return null;
592
- const declared = {};
593
- for (const field of ['highest_question', 'worldview', 'value_order', 'judgment_role']) {
594
- if (Object.hasOwn(core, field)) {
595
- declared[field] = JSON.parse(JSON.stringify(core[field]));
596
- }
597
- }
598
- return Object.keys(declared).length > 0 ? declared : null;
599
- }
600
-
601
- function buildManifest(project, name) {
602
- // Bug (#58): prior version was a hand-rolled manifest builder that
603
- // shared zero logic with the canonical buildManifest in
604
- // @aikdna/kdna-studio-core/src/export-runtime. The two paths
605
- // diverged on every field added since 1.0 (lineage, load_contract,
606
- // authoring block, etc.). The fix delegates the manifest shape to
607
- // kdna-studio-core's buildManifest so runtime export has a single
608
- // source of truth.
609
- //
610
- // We still need a default manifest here (the exportRuntimeAsset
611
- // path uses buildManifest internally; this function supplies the
612
- // defaults that pass-through into the runtime options). The actual
613
- // manifest the runtime ships is the one buildManifest produces, so
614
- // keeping these defaults in sync matters less than it used to.
615
- const source = project.source_manifest || {};
616
- const assetId = assetIdFromName(name || source.name || project.name);
617
- const author = source.author || project.author || {};
618
- return {
619
- format_version: protocolContract.FORMAT_VERSION,
620
- asset_id: assetId,
621
- asset_uid: project.asset_uid || source.asset_uid || 'urn:uuid:' + crypto.randomUUID(),
622
- asset_type: project.type === 'cluster' ? 'cluster' : 'domain',
623
- source_asset_type: source.asset_type || null,
624
- title: source.title || project.title || titleFromName(source.name || name || project.name, assetId),
625
- version: semverValue(project.release?.version || source.version, '1.0.0'),
626
- judgment_version: semverValue(project.release?.judgment_version || source.judgment_version || source.version, '1.0.0'),
627
- created_at: isoDateTime(source.created_at || source.created || project.created),
628
- updated_at: isoDateTime(source.updated_at || source.updated || project.updated),
629
- creator: {
630
- name: author.name || project.author?.name || 'Studio Export',
631
- id: author.id || project.author?.id || undefined,
632
- },
633
- compatibility: {
634
- min_loader_version: '0.19.0',
635
- profile: protocolContract.PAYLOAD_PROFILE,
636
- profile_version: protocolContract.PAYLOAD_PROFILE_VERSION,
637
- },
638
- payload: { path: 'payload.kdnab', encoding: 'cbor', encrypted: false },
639
- summary: source.core_insight || project.release?.description || source.description || '',
640
- description: source.description || project.release?.description || '',
641
- language: source.default_language || project.default_language || (Array.isArray(source.languages) ? source.languages[0] : undefined),
642
- languages: source.languages || project.languages || undefined,
643
- license: source.license || project.license || undefined,
644
- keywords: source.keywords || [],
645
- lineage: {
646
- type: project.lineage?.type === 'migrated' ? 'adaptation' : (project.lineage?.type || 'original'),
647
- fork_of: null,
648
- derived_from: source.name || project.lineage?.parent_name || null,
649
- source_lineage_type: project.lineage?.type || null,
650
- },
651
- load_contract: {
652
- default_profile: 'compact',
653
- profiles: {
654
- index: { requires_decryption: false, max_tokens_hint: 500, selection: 'manifest metadata', intended_for: ['discovery'] },
655
- compact: { requires_decryption: false, max_tokens_hint: 2000, selection: 'core judgment summary', intended_for: ['agent prompt'] },
656
- scenario: { requires_decryption: false, max_tokens_hint: 3000, selection: 'scenario cards', intended_for: ['situational loading'] },
657
- full: { requires_decryption: false, max_tokens_hint: 12000, selection: 'full manifest and payload', intended_for: ['audit', 'migration'] },
658
- },
659
- },
660
- source_manifest: publicManifestMetadata(source),
661
- };
662
- }
663
-
664
- function cmdCreate(args) {
665
- const dir = args[0];
666
- if (!dir) fail('Usage: kdna-studio create <project-dir> --name <name> [--author <name>] [--from-kdna <file> | --from-folder <dir>]');
667
- const abs = path.resolve(dir);
668
- if (fs.existsSync(abs)) fail(`Directory already exists: ${abs}`);
669
-
670
- const name = option(args, '--name', path.basename(abs));
671
- const authorName = option(args, '--author', '');
672
- const fromKdna = option(args, '--from-kdna');
673
- const fromFolder = option(args, '--from-folder');
674
-
675
- let sourceMode = 'blank';
676
- let lineage = null;
677
- let creatorIdentity = null;
678
-
679
- // Load creator identity if available
680
- try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* no identity yet */ }
681
-
682
- if (fromKdna) {
683
- sourceMode = 'kdna_asset';
684
- const kdnaData = importFromKdna(fromKdna);
685
- lineage = kdnaData.lineage;
686
- if (kdnaData.cards) {
687
- const sourceAuthor = kdnaData.source_manifest?.author || {};
688
- const project = projectApi.createProject(name, 'domain', {
689
- author: {
690
- name: authorName || option(args, '--author-name', sourceAuthor.name || ''),
691
- id: option(args, '--author-id', sourceAuthor.id || ''),
692
- },
693
- sourceMode,
694
- creatorIdentity,
695
- lineage,
696
- judgmentCore: kdnaData.judgment_core,
697
- });
698
- project.cards = kdnaData.cards;
699
- // Bug #15 / #28 follow-up: store the source KDNA_* files on the
700
- // project so a later export through exportRuntimeAsset can
701
- // forward them to compileDomain (which then preserves
702
- // changelog / version_notes / reasoning_chains / banned_terms
703
- // through the round-trip).
704
- if (kdnaData.source_patterns) project.source_patterns = kdnaData.source_patterns;
705
- if (kdnaData.source_reasoning) project.source_reasoning = kdnaData.source_reasoning;
706
- if (kdnaData.source_evolution) project.source_evolution = kdnaData.source_evolution;
707
- if (kdnaData.source_core_structure) project.source_core_structure = kdnaData.source_core_structure;
708
- if (kdnaData.source_manifest) project.source_manifest = publicManifestMetadata(kdnaData.source_manifest);
709
- if (kdnaData.source_manifest?.asset_uid) project.asset_uid = kdnaData.source_manifest.asset_uid;
710
- if (kdnaData.source_manifest?.title) project.title = kdnaData.source_manifest.title;
711
- if (kdnaData.source_manifest?.license) project.license = kdnaData.source_manifest.license;
712
- if (kdnaData.source_manifest?.languages) project.languages = kdnaData.source_manifest.languages;
713
- if (kdnaData.source_manifest?.language) project.default_language = kdnaData.source_manifest.language;
714
- if (kdnaData.source_manifest?.version) {
715
- if (!project.release) project.release = {};
716
- project.release.version = kdnaData.source_manifest.version;
717
- }
718
- if (kdnaData.source_manifest?.judgment_version) {
719
- if (!project.release) project.release = {};
720
- project.release.judgment_version = kdnaData.source_manifest.judgment_version;
721
- }
722
- if (kdnaData.source_manifest?.description || kdnaData.source_manifest?.summary) {
723
- if (!project.release) project.release = {};
724
- project.release.description = kdnaData.source_manifest.description || kdnaData.source_manifest.summary;
725
- }
726
- if (project.stages?.judgment_cards) {
727
- project.stages.judgment_cards.total = kdnaData.cards.length;
728
- project.stages.judgment_cards.status = 'in_progress';
729
- }
730
- fs.mkdirSync(abs, { recursive: true });
731
- writeProject(path.join(abs, 'studio.project.json'), project);
732
- console.log(`Created Studio project (source_mode: ${sourceMode}, imported: ${kdnaData.cards.length} cards): ${abs}`);
733
- return;
734
- }
735
- } else if (fromFolder) {
736
- sourceMode = 'source_folder';
737
- importFromFolder(fromFolder, abs, name, creatorIdentity);
738
- return;
739
- }
740
-
741
- const project = projectApi.createProject(name, 'domain', {
742
- author: { name: authorName || option(args, '--author-name', ''), id: option(args, '--author-id', '') },
743
- sourceMode,
744
- creatorIdentity,
745
- lineage,
746
- sourcePath: fromFolder ? path.resolve(fromFolder) : null,
747
- });
748
-
749
- if (fromKdna || fromFolder) {
750
- fs.mkdirSync(abs, { recursive: true });
751
- writeProject(path.join(abs, 'studio.project.json'), project);
752
- if (fromFolder) {
753
- // Cards were already added by importFromFolder
754
- }
755
- console.log(`Created Studio project (source_mode: ${sourceMode}): ${abs}`);
756
- return;
757
- }
758
-
759
- fs.mkdirSync(abs, { recursive: true });
760
- writeProject(path.join(abs, 'studio.project.json'), project);
761
- console.log(`Created Studio project: ${abs}`);
762
- }
763
-
764
- /**
765
- * Import cards from an existing .kdna asset into a new Studio project.
766
- * Cards are imported as draft — they do NOT inherit trust from the source.
767
- */
768
- function importFromKdna(kdnaPath) {
769
- resetImportedIds();
770
- const absKdna = path.resolve(kdnaPath);
771
- if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
772
- if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
773
-
774
- const stats = fs.statSync(absKdna);
775
- if (stats.size > 50 * 1024 * 1024) {
776
- fail(`KDNA file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${absKdna}`);
777
- }
778
-
779
- // ── read ZIP before Core validation so the profile can branch ──
780
- const zipBuf = fs.readFileSync(absKdna);
781
- const entries = readZipEntries(zipBuf);
782
- validateZipContainer(entries);
783
- if (!entries.has('kdna.json')) fail('Not a valid .kdna asset: missing kdna.json');
784
- if (!entries.has('payload.kdnab')) fail('Not a valid .kdna asset: missing payload.kdnab. Use --from-folder for legacy JSON source migration.');
785
-
786
- const manifest = JSON.parse(entries.get('kdna.json').toString());
787
- const payload = decodePayload(entries.get('payload.kdnab'), manifest);
788
- const profile = payload?.profile;
789
-
790
- // ── branch: current profile requires Core validate; legacy uses a frozen contract ──
791
- let runtimeCore, isLegacy;
792
- try { runtimeCore = require('@aikdna/kdna-core'); } catch {
793
- fail('@aikdna/kdna-core is required to validate --from-kdna imports.');
794
- }
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.`);
806
- }
807
-
808
- const lineage = {
809
- type: 'fork',
810
- parent_name: packageNameFromManifest(manifest),
811
- parent_asset_uid: manifest.asset_uid || null,
812
- parent_version: manifest.version || null,
813
- parent_asset_digest: manifest.content_digest || manifest.asset_digest || null,
814
- };
815
-
816
- const importedCards = [];
817
- let judgmentCore = null;
818
- 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
- }
826
- } catch (e) {
827
- fail(`Could not import cards from payload.kdnab: ${e.message}`);
828
- }
829
-
830
- if (importedCards.length === 0) {
831
- fail(`No current judgment cards could be extracted from ${absKdna}.`);
832
- }
833
-
834
- return {
835
- lineage,
836
- cards: importedCards,
837
- judgment_core: judgmentCore,
838
- source_manifest: manifest,
839
- source_patterns: null,
840
- source_reasoning: payload.reasoning && typeof payload.reasoning === 'object'
841
- ? JSON.parse(JSON.stringify(payload.reasoning))
842
- : null,
843
- source_evolution: payload.evolution && typeof payload.evolution === 'object'
844
- ? JSON.parse(JSON.stringify(payload.evolution))
845
- : null,
846
- source_core_structure: Array.isArray(payload.core?.core_structure)
847
- ? JSON.parse(JSON.stringify(payload.core.core_structure))
848
- : null,
849
- };
850
- }
851
-
852
- // ── legacy judgment-profile-v1 import support ───────────────────────────
853
-
854
- const LEGACY_PROFILE = 'judgment-profile-v1';
855
-
856
- const LEGACY_PATTERN_TYPE_MAP = {
857
- term: 'term',
858
- banned_term: 'banned_term',
859
- misunderstanding: 'misunderstanding',
860
- self_check: 'self_check',
861
- scenario: 'scenario',
862
- case: 'case',
863
- reasoning: 'reasoning',
864
- risk: 'risk',
865
- axiom: 'axiom',
866
- ontology: 'ontology',
867
- framework: 'framework',
868
- stance: 'stance',
869
- aesthetic: 'aesthetic',
870
- boundary: 'boundary',
871
- evolution_stage: 'evolution_stage',
872
- };
873
-
874
- // Pattern subtypes that map to the generic 'pattern' card; the original
875
- // subtype is preserved in fields.legacy_subtype for audit and re-export.
876
- const LEGACY_PATTERN_SUBTYPES = new Set([
877
- 'failure_pattern', 'design_pattern', 'response_pattern',
878
- 'stopping_pattern', 'completion_pattern', 'decision_pattern',
879
- 'reasoning_pattern', 'action_pattern', 'recovery_pattern',
880
- 'responsibility_pattern',
881
- ]);
882
-
883
- const MAX_ZIP_ENTRIES = 100;
884
- const MAX_SINGLE_ENTRY_UNCOMPRESSED = 10 * 1024 * 1024;
885
- const MAX_TOTAL_UNCOMPRESSED = 50 * 1024 * 1024;
886
- const REQUIRED_ZIP_ENTRIES = ['kdna.json', 'payload.kdnab', 'checksums.json', 'mimetype'];
887
-
888
- function validateZipContainer(entries) {
889
- if (entries.size > MAX_ZIP_ENTRIES) {
890
- fail(`ZIP container has too many entries (${entries.size}, max ${MAX_ZIP_ENTRIES})`);
891
- }
892
- for (const name of REQUIRED_ZIP_ENTRIES) {
893
- if (!entries.has(name)) fail(`Not a valid .kdna container: missing required entry "${name}"`);
894
- }
895
- let totalSize = 0;
896
- for (const [name, buf] of entries) {
897
- if (path.isAbsolute(name) || name.includes('..') || name.includes('\\')) {
898
- fail(`ZIP entry "${name}" has an unsafe path`);
899
- }
900
- if (Buffer.isBuffer(buf) && buf.length > MAX_SINGLE_ENTRY_UNCOMPRESSED) {
901
- fail(`ZIP entry "${name}" is too large (${buf.length} bytes)`);
902
- }
903
- totalSize += Buffer.isBuffer(buf) ? buf.length : 0;
904
- }
905
- if (totalSize > MAX_TOTAL_UNCOMPRESSED) {
906
- fail(`ZIP container total uncompressed size exceeds limit (${totalSize} bytes)`);
907
- }
908
- }
909
-
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
- function judgmentCoreFromLegacyPayload(payload) {
933
- const core = payload.core || {};
934
- const result = {};
935
- if (typeof core.highest_question === 'string' && core.highest_question) result.highest_question = core.highest_question;
936
- if (Array.isArray(core.worldview) && core.worldview.length) result.worldview = core.worldview;
937
- if (Array.isArray(core.value_order) && core.value_order.length) result.value_order = core.value_order;
938
- if (core.judgment_role && typeof core.judgment_role === 'object' && Object.keys(core.judgment_role).length) result.judgment_role = core.judgment_role;
939
- return Object.keys(result).length ? result : null;
940
- }
941
-
942
- function legacyStableId(type, fields, prefix) {
943
- // Deterministic ID for legacy items that lack a declared id.
944
- // Prefix is the parent scope (e.g. the asset name or domain id).
945
- const sorted = JSON.stringify(Object.entries(fields).sort());
946
- const h = require('crypto').createHash('sha256').update(`${type}:${sorted}`).digest('hex');
947
- return `${prefix}_${h.slice(0, 12)}`;
948
- }
949
-
950
- const _legacyDedupIds = new Set();
951
-
952
- function _legacyDedupPush(cards, type, fields, id) {
953
- // Deduplicate by id IF an id is declared. Items without ids are unique
954
- // (deterministic by content). A second push with the same id merges.
955
- if (!id) { pushImportedCard(cards, type, fields, null); return; }
956
- if (_legacyDedupIds.has(id)) { pushImportedCard(cards, type, fields, id); return; }
957
- _legacyDedupIds.add(id);
958
- pushImportedCard(cards, type, fields, id);
959
- }
960
-
961
- function cardsFromLegacyPayload(payload) {
962
- _legacyDedupIds.clear();
963
- const cards = [];
964
- const core = payload.core || {};
965
-
966
- // axioms
967
- for (const ax of (core.axioms || [])) {
968
- _legacyDedupPush(cards, 'axiom', importedFields(ax), ax.id || null);
969
- }
970
-
971
- // ontology
972
- for (const ont of (core.ontology || [])) {
973
- pushImportedCard(cards, 'ontology', importedFields(ont), ont.id || null);
974
- }
975
-
976
- // frameworks
977
- for (const fw of (core.frameworks || [])) {
978
- pushImportedCard(cards, 'framework', importedFields(fw), fw.id || null);
979
- }
980
-
981
- // stances
982
- for (const st of (core.stances || [])) {
983
- const fields = typeof st === 'string' ? { statement: st } : importedFields(st);
984
- pushImportedCard(cards, 'stance', fields, typeof st === 'object' ? st.id || null : null);
985
- }
986
-
987
- // boundaries
988
- for (const bd of (core.boundaries || [])) {
989
- pushImportedCard(cards, 'boundary', importedFields(bd), bd.id || null);
990
- }
991
-
992
- // risks
993
- const legacyRisks = Array.isArray(core.risk_model)
994
- ? core.risk_model
995
- : (Array.isArray(core.risk_model?.risks) ? core.risk_model.risks : []);
996
- for (const risk of legacyRisks) {
997
- pushImportedCard(cards, 'risk', importedFields(risk.fields || risk), risk.id || null);
998
- }
999
-
1000
- // patterns with explicit types (including legacy subtypes)
1001
- const patterns = payload.patterns || [];
1002
- for (const p of patterns) {
1003
- if (!p || typeof p !== 'object') continue;
1004
- const rawType = p.type;
1005
- if (!rawType) continue; // type-less items handled separately (misunderstandings below)
1006
-
1007
- if (LEGACY_PATTERN_SUBTYPES.has(rawType)) {
1008
- pushImportedCard(cards, 'pattern', {
1009
- ...importedFields(p, { keepType: true }),
1010
- legacy_subtype: rawType,
1011
- }, p.id || null);
1012
- } else if (rawType === 'term') {
1013
- pushImportedCard(cards, 'term', importedFields(p), p.id || null);
1014
- } else if (rawType === 'banned_term') {
1015
- pushImportedCard(cards, 'banned_term', importedFields(p), p.id || null);
1016
- } else if (LEGACY_PATTERN_TYPE_MAP.hasOwnProperty(rawType)) {
1017
- pushImportedCard(cards, LEGACY_PATTERN_TYPE_MAP[rawType], importedFields(p), p.id || null);
1018
- } else {
1019
- fail(`Unknown legacy pattern type "${rawType}" for card "${p.id || '(no id)'}"`);
1020
- }
1021
- }
1022
-
1023
- // misunderstandings: appear in patterns[] with no type and in
1024
- // reasoning.failure_modes[] with slightly different field names.
1025
- // Import from patterns[] (richer fields). The pushImportedCard
1026
- // dedup handles the failure_modes view via id matching.
1027
- for (const p of patterns) {
1028
- if (!p || typeof p !== 'object') continue;
1029
- if (p.type) continue; // already handled above
1030
- if (!p.wrong && !p.correct) continue;
1031
- pushImportedCard(cards, 'misunderstanding', importedFields(p), p.id || null);
1032
- }
1033
-
1034
- // reasoning chains
1035
- for (const chain of (payload.reasoning?.reasoning_chains || [])) {
1036
- const fields = importedFields(chain);
1037
- pushImportedCard(cards, 'reasoning', fields, chain.id || null);
1038
- }
1039
-
1040
- // self checks: array of plain strings in legacy payload
1041
- for (const check of (payload.reasoning?.self_check || [])) {
1042
- if (typeof check === 'string' && check.trim()) {
1043
- const fields = { question: check.trim() };
1044
- const id = legacyStableId('self_check', fields, 'legacy_sc');
1045
- pushImportedCard(cards, 'self_check', fields, id);
1046
- }
1047
- }
1048
-
1049
- // scenarios
1050
- for (const sc of (payload.scenarios || [])) {
1051
- pushImportedCard(cards, 'scenario', importedFields(sc), sc.id || null);
1052
- }
1053
-
1054
- // cases
1055
- for (const cs of (payload.cases || [])) {
1056
- pushImportedCard(cards, 'case', importedFields(cs), cs.id || null);
1057
- }
1058
-
1059
- // evolution stages
1060
- for (const stage of (payload.evolution?.stages || [])) {
1061
- pushImportedCard(cards, 'evolution_stage', importedFields(stage), stage.id || null);
1062
- }
1063
-
1064
- return cards;
1065
- }
1066
-
1067
- /**
1068
- * Import cards from a legacy source folder into a new Studio project.
1069
- * Reads KDNA_*.json files and converts entries to draft judgment cards.
1070
- * Outputs a schema audit report.
1071
- */
1072
- function importFromFolder(sourceDir, projectDir, projectName, creatorIdentity, opts = {}) {
1073
- resetImportedIds();
1074
- const absSource = path.resolve(sourceDir);
1075
- if (!fs.existsSync(absSource)) fail(`Source folder not found: ${absSource}`);
1076
- if (!fs.statSync(absSource).isDirectory()) fail('--from-folder requires a directory');
1077
-
1078
- const audit = { filesFound: [], cardsImported: 0, missingFields: [], schemaWarnings: [] };
1079
- const importedCards = [];
1080
- const manifestData = {};
1081
-
1082
- function loadJson(filename) {
1083
- const p = path.join(absSource, filename);
1084
- if (!fs.existsSync(p)) return null;
1085
- audit.filesFound.push(filename);
1086
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
1087
- catch { audit.schemaWarnings.push(`${filename}: invalid JSON`); return null; }
1088
- }
1089
-
1090
- // Load manifest for version/languages/description
1091
- const manifest = loadJson('kdna.json');
1092
- if (manifest) {
1093
- manifestData.raw = manifest;
1094
- if (manifest.version) manifestData.version = manifest.version;
1095
- if (manifest.asset_uid) manifestData.asset_uid = manifest.asset_uid;
1096
- if (manifest.judgment_version) manifestData.judgment_version = manifest.judgment_version;
1097
- if (manifest.languages) manifestData.languages = manifest.languages;
1098
- if (manifest.default_language) manifestData.default_language = manifest.default_language;
1099
- if (manifest.description) manifestData.description = manifest.description;
1100
- if (manifest.name) manifestData.name = manifest.name;
1101
- if (manifest.title) manifestData.title = manifest.title;
1102
- if (manifest.keywords) manifestData.keywords = manifest.keywords;
1103
- if (manifest.license) manifestData.license = manifest.license;
1104
- if (manifest.author) manifestData.author = manifest.author;
1105
- if (manifest.asset_type) manifestData.asset_type = manifest.asset_type;
1106
- if (manifest.core_insight) manifestData.core_insight = manifest.core_insight;
1107
- if (manifest.created_at || manifest.created) manifestData.created = manifest.created_at || manifest.created;
1108
- if (manifest.updated_at || manifest.updated) manifestData.updated = manifest.updated_at || manifest.updated;
1109
- // legacy-migration bookkeeping only: preserved in the Studio project for
1110
- // issuer-scoped governance; the Core 0.20 export manifest never projects it
1111
- if (manifest.risk_level) manifestData.risk_level = manifest.risk_level;
1112
- if (manifest.fitness_for_purpose) manifestData.fitness_for_purpose = manifest.fitness_for_purpose;
1113
- }
1114
-
1115
- const core = loadJson('KDNA_Core.json');
1116
- const patterns = loadJson('KDNA_Patterns.json');
1117
- const scenarios = loadJson('KDNA_Scenarios.json');
1118
- const cases = loadJson('KDNA_Cases.json');
1119
- const reasoning = loadJson('KDNA_Reasoning.json');
1120
- const evolution = loadJson('KDNA_Evolution.json');
1121
-
1122
- // If none of the standard KDNA_*.json files were found, that almost
1123
- // always means the user pointed at the wrong directory or the source uses
1124
- // a non-standard filename. Report what was *actually* present so they can
1125
- // rename or move their files.
1126
- const expectedFiles = ['KDNA_Core.json', 'KDNA_Patterns.json', 'KDNA_Scenarios.json',
1127
- 'KDNA_Cases.json', 'KDNA_Reasoning.json', 'KDNA_Evolution.json', 'kdna.json'];
1128
- const loadedCount = audit.filesFound.length;
1129
- if (loadedCount === 0) {
1130
- let dirContents = [];
1131
- try { dirContents = fs.readdirSync(absSource); } catch { /* ignore */ }
1132
- fail(
1133
- `No KDNA source files found in ${absSource}.\n` +
1134
- `Expected one of: ${expectedFiles.join(', ')}\n` +
1135
- `Directory contains ${dirContents.length} entr${dirContents.length === 1 ? 'y' : 'ies'}: ` +
1136
- (dirContents.length > 0 ? dirContents.slice(0, 20).join(', ') + (dirContents.length > 20 ? ', ...' : '') : '(empty)')
1137
- );
1138
- }
1139
-
1140
- if (core) {
1141
- for (const ax of (Array.isArray(core.axioms) ? core.axioms : [])) {
1142
- const fields = importedFields(ax);
1143
- for (const key of ['one_sentence', 'full_statement', 'why', 'applies_when', 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type']) {
1144
- if (!(key in ax)) audit.missingFields.push(`axiom.${ax.id || '?'}.${key}`);
1145
- }
1146
- importedCards.push(cardApi.createCard('axiom', fields, ax.id || null));
1147
- }
1148
- for (const ont of (Array.isArray(core.ontology) ? core.ontology : [])) {
1149
- importedCards.push(cardApi.createCard('ontology', importedFields(ont), ont.id || null));
1150
- }
1151
- for (const b of (Array.isArray(core.boundaries) ? core.boundaries : [])) {
1152
- importedCards.push(cardApi.createCard('boundary', importedFields(b), b.id || null));
1153
- }
1154
- // Accept risks as a top-level array (`risks`) or wrapped in `risk_model`
1155
- // (either as `{risks: [...]}` or as an array itself).
1156
- let risksSource = core.risks;
1157
- if (!Array.isArray(risksSource)) {
1158
- if (Array.isArray(core.risk_model)) risksSource = core.risk_model;
1159
- else if (Array.isArray(core.risk_model?.risks)) risksSource = core.risk_model.risks;
1160
- else risksSource = [];
1161
- }
1162
- for (const r of risksSource) {
1163
- importedCards.push(cardApi.createCard('risk', importedFields(r.fields || r), r.id || null));
1164
- }
1165
- // NEW: import stances
1166
- for (const s of (Array.isArray(core.stances) ? core.stances : [])) {
1167
- const fields = typeof s === 'string' ? { statement: s } : importedFields(s);
1168
- importedCards.push(cardApi.createCard('stance', fields, typeof s === 'object' ? s.id || null : null));
1169
- }
1170
- // NEW: import frameworks
1171
- for (const fw of (Array.isArray(core.frameworks) ? core.frameworks : [])) {
1172
- importedCards.push(cardApi.createCard('framework', importedFields(fw), fw.id || null));
1173
- }
1174
- }
1175
-
1176
- if (patterns) {
1177
- // NEW: import terminology
1178
- if (patterns.terminology) {
1179
- for (const t of (Array.isArray(patterns.terminology.standard_terms) ? patterns.terminology.standard_terms : [])) {
1180
- importedCards.push(cardApi.createCard('term', importedFields(t), t.id || null));
1181
- }
1182
- for (const bt of (Array.isArray(patterns.terminology.banned_terms) ? patterns.terminology.banned_terms : [])) {
1183
- importedCards.push(cardApi.createCard('banned_term', importedFields(bt), bt.id || null));
1184
- }
1185
- }
1186
- for (const pattern of (Array.isArray(patterns.patterns) ? patterns.patterns : [])) {
1187
- const fields = importedFields(pattern, { keepType: true });
1188
- if (!fields.type) fields.type = 'pattern';
1189
- importedCards.push(cardApi.createCard('pattern', fields, pattern.id || null));
1190
- }
1191
- for (const ms of (Array.isArray(patterns.misunderstandings) ? patterns.misunderstandings : [])) {
1192
- importedCards.push(cardApi.createCard('misunderstanding', importedFields(ms), ms.id || null));
1193
- }
1194
- for (const sc of (Array.isArray(patterns.self_check) ? patterns.self_check : [])) {
1195
- const fields = typeof sc === 'string' ? { question: sc } : importedFields(sc);
1196
- importedCards.push(cardApi.createCard(
1197
- 'self_check',
1198
- fields,
1199
- typeof sc === 'object' ? sc.id || null : null,
1200
- ));
1201
- }
1202
- // Bug (aesthetic round-trip): prior version dropped
1203
- // `patterns.aesthetics` on import. `cardsFromRuntimePayload` and
1204
- // `cardsFromLegacyPayload` both read the field, so a domain
1205
- // that authored aesthetic cards in either format would lose
1206
- // them when imported through `create --from-folder`. The fix
1207
- // adds the missing loop here.
1208
- for (const aesthetic of (Array.isArray(patterns.aesthetics) ? patterns.aesthetics : [])) {
1209
- importedCards.push(cardApi.createCard(
1210
- 'aesthetic',
1211
- importedFields(aesthetic.fields || aesthetic),
1212
- aesthetic.id || null,
1213
- ));
1214
- }
1215
- }
1216
-
1217
- // NEW: import scenarios
1218
- if (scenarios && Array.isArray(scenarios.scenes)) {
1219
- for (const scene of scenarios.scenes) {
1220
- importedCards.push(cardApi.createCard('scenario', importedFields(scene), scene.id || null));
1221
- }
1222
- }
1223
-
1224
- // NEW: import cases
1225
- if (cases && Array.isArray(cases.cases)) {
1226
- for (const c of cases.cases) {
1227
- importedCards.push(cardApi.createCard('case', importedFields(c), c.id || null));
1228
- }
1229
- }
1230
-
1231
- // NEW: import reasoning chains. Field names must match what compileReasoning
1232
- // reads (chain/principle/concrete_action/axiom/one_sentence) so the
1233
- // round-trip preserves the source's structure. Bug: prior code stored
1234
- // {name, conclusion, so_what}, none of which the compile path recognised,
1235
- // so the cards were silently rewritten as axiom-derived fallback chains
1236
- // — looking identical to misunderstanding cards and losing the source's
1237
- // identity.
1238
- if (reasoning && Array.isArray(reasoning.reasoning_chains)) {
1239
- for (const rc of reasoning.reasoning_chains) {
1240
- importedCards.push(cardApi.createCard('reasoning', importedFields(rc), rc.id || null));
1241
- }
1242
- }
1243
-
1244
- // NEW: import evolution stages
1245
- if (evolution && Array.isArray(evolution.stages)) {
1246
- for (const stage of evolution.stages) {
1247
- pushImportedCard(importedCards, 'evolution_stage', importedFields(stage), stage.id || null);
1248
- }
1249
- }
1250
-
1251
- audit.cardsImported = importedCards.length;
1252
-
1253
- const project = projectApi.createProject(projectName, 'domain', {
1254
- sourceMode: 'source_folder',
1255
- sourcePath: absSource,
1256
- creatorIdentity: creatorIdentity || null,
1257
- lineage: { type: 'migrated', parent_name: manifestData.name || null, parent_asset_uid: null, parent_version: manifestData.version || null, parent_asset_digest: null },
1258
- judgmentCore: judgmentCoreFromLegacyPayload({ core: core || {} }),
1259
- });
1260
- // Preserve manifest metadata
1261
- if (manifestData.asset_uid) project.asset_uid = manifestData.asset_uid;
1262
- if (manifestData.version) {
1263
- if (!project.release) project.release = {};
1264
- project.release.version = manifestData.version;
1265
- }
1266
- if (manifestData.judgment_version) {
1267
- if (!project.release) project.release = {};
1268
- project.release.judgment_version = manifestData.judgment_version;
1269
- }
1270
- if (manifestData.description) {
1271
- if (!project.release) project.release = {};
1272
- project.release.description = manifestData.description;
1273
- }
1274
- if (manifestData.title) project.title = manifestData.title;
1275
- if (manifestData.license) project.license = manifestData.license;
1276
- if (manifestData.author) project.author = manifestData.author;
1277
- if (manifestData.languages) project.languages = manifestData.languages;
1278
- if (manifestData.default_language) project.default_language = manifestData.default_language;
1279
- if (manifestData.raw) project.source_manifest = publicManifestMetadata(manifestData.raw);
1280
- if (patterns) project.source_patterns = JSON.parse(JSON.stringify(patterns));
1281
- if (reasoning) project.source_reasoning = JSON.parse(JSON.stringify(reasoning));
1282
- if (evolution) project.source_evolution = JSON.parse(JSON.stringify(evolution));
1283
- if (Array.isArray(core?.core_structure)) {
1284
- project.source_core_structure = JSON.parse(JSON.stringify(core.core_structure));
1285
- }
1286
-
1287
- project.cards = importedCards;
1288
- if (project.stages?.judgment_cards) {
1289
- project.stages.judgment_cards.total = importedCards.length;
1290
- project.stages.judgment_cards.status = 'in_progress';
1291
- }
1292
-
1293
- fs.mkdirSync(projectDir, { recursive: true });
1294
- writeProject(path.join(projectDir, 'studio.project.json'), project);
1295
- if (!opts.silent) {
1296
- console.log(JSON.stringify({ audit, imported: importedCards.length, source_mode: 'source_folder' }, null, 2));
1297
- }
1298
- return { project, projectPath: path.join(projectDir, 'studio.project.json'), audit };
1299
- }
1300
-
1301
- /**
1302
- * Minimal ZIP central directory parser — validates every entry before decompressing.
1303
- */
1304
- function readZipEntries(buf) {
1305
- const entries = new Map();
1306
- const metadata = [];
1307
-
1308
- // Find end-of-central-directory record
1309
- let eocdOffset = buf.length - 22;
1310
- while (eocdOffset >= 0) {
1311
- if (buf.readUInt32LE(eocdOffset) === 0x06054b50) break;
1312
- eocdOffset--;
1313
- }
1314
- if (eocdOffset < 0) throw new Error('Not a valid ZIP file');
1315
-
1316
- const centralDirOffset = buf.readUInt32LE(eocdOffset + 16);
1317
- let offset = centralDirOffset;
1318
-
1319
- // Pass 1: collect metadata, validate safety before any decompression
1320
- let totalUncomp = 0;
1321
- while (offset < eocdOffset) {
1322
- const sig = buf.readUInt32LE(offset);
1323
- if (sig !== 0x02014b50) break;
1324
- const nameLen = buf.readUInt16LE(offset + 28);
1325
- const extraLen = buf.readUInt16LE(offset + 30);
1326
- const commentLen = buf.readUInt16LE(offset + 32);
1327
- const compMethod = buf.readUInt16LE(offset + 10);
1328
- const compSize = buf.readUInt32LE(offset + 20);
1329
- const uncompSize = buf.readUInt32LE(offset + 24);
1330
- const localOffset = buf.readUInt32LE(offset + 42);
1331
- const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen);
1332
-
1333
- // Security: path must not be absolute, must not escape
1334
- if (!name || name.startsWith('/') || name.includes('..') || name.includes('\\')) {
1335
- throw new Error(`ZIP entry "${name}" has an unsafe path`);
1336
- }
1337
- // Duplicate detection
1338
- if (entries.has(name) || metadata.some(m => m.name === name)) {
1339
- throw new Error(`Duplicate ZIP entry: "${name}"`);
1340
- }
1341
- if (compMethod !== 0 && compMethod !== 8) {
1342
- throw new Error(`ZIP entry "${name}" uses unsupported compression method ${compMethod}`);
1343
- }
1344
- totalUncomp += uncompSize;
1345
- if (totalUncomp > MAX_TOTAL_UNCOMPRESSED) {
1346
- throw new Error(`ZIP total uncompressed size ${totalUncomp} exceeds limit`);
1347
- }
1348
- if (uncompSize > MAX_SINGLE_ENTRY_UNCOMPRESSED) {
1349
- throw new Error(`ZIP entry "${name}" declared size ${uncompSize} exceeds limit`);
1350
- }
1351
- const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
1352
- metadata.push({ name, compMethod, compSize, uncompSize, dataStart });
1353
- offset += 46 + nameLen + extraLen + commentLen;
1354
- }
1355
-
1356
- if (metadata.length > MAX_ZIP_ENTRIES) {
1357
- throw new Error(`ZIP container has too many entries (${metadata.length}, max ${MAX_ZIP_ENTRIES})`);
1358
- }
1359
-
1360
- // Pass 2: decompress into entries
1361
- for (const m of metadata) {
1362
- if (m.compMethod === 0) {
1363
- entries.set(m.name, buf.subarray(m.dataStart, m.dataStart + m.uncompSize));
1364
- } else {
1365
- const raw = buf.subarray(m.dataStart, m.dataStart + m.compSize);
1366
- const inflated = zlib.inflateRawSync(raw, { maxOutputLength: m.uncompSize });
1367
- if (inflated.length !== m.uncompSize) {
1368
- throw new Error(`ZIP entry "${m.name}" decompressed length ${inflated.length} != declared ${m.uncompSize}`);
1369
- }
1370
- entries.set(m.name, inflated);
1371
- }
1372
- }
1373
-
1374
- return entries;
1375
- }
1376
-
1377
- const aiFilter = require('../src/ai/filter');
1378
-
1379
- function cmdImport(args) {
1380
- const [projectInput, source] = args;
1381
- if (!projectInput || !source) fail('Usage: kdna-studio import <project> <source-file-or-dir>');
1382
- const { projectPath, project } = readProject(projectInput);
1383
-
1384
- const sourcePath = path.resolve(source);
1385
- if (!fs.existsSync(sourcePath)) fail(`Source not found: ${sourcePath}`);
1386
-
1387
- const TEXT_EXTS = new Set(['.txt', '.md', '.json', '.jsonl', '.yaml', '.yml', '.csv', '.log', '.srt', '.vtt', '.html', '.xml', '.rst']);
1388
- const BINARY_EXTS = new Set(['.pdf', '.docx', '.rtf']);
1389
- let imported = 0;
1390
- let skipped = 0;
1391
-
1392
- function extractBinaryText(filePath, ext) {
1393
- try {
1394
- if (ext === '.pdf') {
1395
- const result = execFileSync('pdftotext', ['-raw', filePath, '-'], {
1396
- encoding: 'utf8',
1397
- timeout: 30000,
1398
- maxBuffer: 5 * 1024 * 1024,
1399
- });
1400
- return { text: result, error: null };
1401
- }
1402
- if (ext === '.docx' || ext === '.rtf') {
1403
- const result = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], {
1404
- encoding: 'utf8',
1405
- timeout: 30000,
1406
- maxBuffer: 5 * 1024 * 1024,
1407
- });
1408
- return { text: result, error: null };
1409
- }
1410
- } catch (e) {
1411
- // Tool not installed or extraction failed — report distinguishably
1412
- if (e.code === 'ENOENT') {
1413
- return { text: null, error: 'tool_missing' };
1414
- }
1415
- return { text: null, error: 'extraction_failed', detail: e.message };
1416
- }
1417
- return { text: null, error: 'unsupported_format' };
1418
- }
1419
-
1420
- function importFile(filePath) {
1421
- const ext = path.extname(filePath).toLowerCase();
1422
- const name = path.basename(filePath);
1423
-
1424
- if (BINARY_EXTS.has(ext)) {
1425
- const extracted = extractBinaryText(filePath, ext);
1426
- if (!extracted.text || extracted.text.trim().length === 0) {
1427
- const tool = ext === '.pdf' ? 'pdftotext (poppler-utils)' : 'textutil (macOS built-in)';
1428
- if (extracted.error === 'tool_missing') {
1429
- console.warn(`Skipping ${ext} file (${tool} not installed): ${name}`);
1430
- } else if (extracted.error === 'extraction_failed') {
1431
- console.warn(`Skipping ${ext} file (extraction failed: ${extracted.detail}): ${name}`);
1432
- } else {
1433
- console.warn(`Skipping ${ext} file (empty or unsupported): ${name}`);
1434
- }
1435
- skipped++;
1436
- return;
1437
- }
1438
- if (extracted.text.length > 120000) {
1439
- console.warn(`Extracted text too large (${extracted.text.length} chars, max 120000): ${name}`);
1440
- skipped++;
1441
- return;
1442
- }
1443
- const evidence = evidenceApi.createEvidenceEntry('text', name, extracted.text.substring(0, 120000), filePath);
1444
- evidenceApi.addEvidence(project, evidence);
1445
- imported++;
1446
- return;
1447
- }
1448
-
1449
- // Detect binary content: try reading first 4KB as utf8; if it contains
1450
- // null bytes or too many non-printable chars, it's likely binary.
1451
- const buf = fs.readFileSync(filePath);
1452
- if (buf.length === 0) { console.warn(`Empty file: ${name}`); skipped++; return; }
1453
- const sample = buf.subarray(0, Math.min(buf.length, 4096));
1454
- const nulls = sample.filter(b => b === 0).length;
1455
- const nonPrintable = sample.filter(b => b !== 0 && (b < 0x20 || b === 0x7F) && b !== 0x0A && b !== 0x0D && b !== 0x09).length;
1456
- if (nulls > 0 || nonPrintable > sample.length * 0.1) {
1457
- console.warn(`Binary or non-text file skipped: ${name}`);
1458
- skipped++;
1459
- return;
1460
- }
1461
- let content;
1462
- let fd = null;
1463
- try {
1464
- fd = fs.openSync(filePath, 'r');
1465
- const buf = Buffer.alloc(120000);
1466
- const bytesRead = fs.readSync(fd, buf, 0, 120000, 0);
1467
- content = buf.toString('utf8', 0, bytesRead);
1468
- } catch { console.warn(`Cannot read: ${name}`); skipped++; return; }
1469
- finally {
1470
- // Bug (#51): prior version called fs.closeSync only on the
1471
- // success path, so any readSync failure left the file descriptor
1472
- // open. Under bulk import (300 files), a single error mid-loop
1473
- // could exhaust the per-process fd limit and silently break
1474
- // later imports.
1475
- if (fd !== null) {
1476
- try { fs.closeSync(fd); } catch { /* best effort */ }
1477
- }
1478
- }
1479
-
1480
- const evidence = evidenceApi.createEvidenceEntry('text', name, content, filePath);
1481
- evidenceApi.addEvidence(project, evidence);
1482
- imported++;
1483
- }
1484
-
1485
- if (fs.statSync(sourcePath).isDirectory()) {
1486
- const files = fs.readdirSync(sourcePath).map(f => path.join(sourcePath, f)).filter(f => fs.statSync(f).isFile());
1487
- for (const f of files) {
1488
- if (imported >= 300) { console.warn('Reached 300 file limit.'); break; }
1489
- importFile(f);
1490
- }
1491
- } else {
1492
- importFile(sourcePath);
1493
- }
1494
-
1495
- writeProject(projectPath, project);
1496
-
1497
- // Check for sensitive content
1498
- const evidenceList = project.evidence_materials || [];
1499
- const flagged = aiFilter.checkEvidence(evidenceList);
1500
- if (flagged.length > 0) {
1501
- console.warn(`\nSensitive content detected in ${flagged.length} file(s):`);
1502
- for (const f of flagged) console.warn(` ${f.filename}: ${f.flagged.join(', ')}`);
1503
- }
1504
-
1505
- console.log(`Imported: ${imported} file(s)${skipped > 0 ? `, ${skipped} skipped` : ''}`);
1506
- }
1507
-
1508
- function cmdFilter(args) {
1509
- const projectInput = args[0];
1510
- if (!projectInput) fail('Usage: kdna-studio filter <project>');
1511
- const { project } = readProject(projectInput);
1512
- const evidenceList = project.evidence_materials || [];
1513
- if (evidenceList.length === 0) { console.log('No evidence to filter.'); return; }
1514
-
1515
- const flagged = aiFilter.checkEvidence(evidenceList);
1516
- if (flagged.length === 0) {
1517
- console.log(`All ${evidenceList.length} evidence file(s) passed sensitive content filter.`);
1518
- } else {
1519
- console.log(`Sensitive content detected in ${flagged.length}/${evidenceList.length} file(s):`);
1520
- for (const f of flagged) {
1521
- console.log(` ${f.filename}: ${f.flagged.join(', ')}`);
1522
- for (const [domain, match] of Object.entries(f.matches)) console.log(` ${domain}: "${match}"`);
1523
- }
1524
- }
1525
- }
1526
-
1527
- const CARD_REQUIRED_FIELDS = {
1528
- axiom: [
1529
- 'one_sentence', 'full_statement', 'why',
1530
- 'applies_when', 'does_not_apply_when', 'failure_risk',
1531
- 'confidence', 'evidence_type',
1532
- ],
1533
- boundary: ['scope', 'out_of_scope'],
1534
- misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
1535
- self_check: ['question'],
1536
- scenario: ['name', 'trigger', 'action', 'expected'],
1537
- case: ['title', 'scenario', 'input', 'expected'],
1538
- risk: ['name', 'description', 'mitigation'],
1539
- stance: ['statement'],
1540
- pattern: ['type', 'name', 'one_sentence', 'what_it_looks_like', 'how_to_fix', 'failure_risk'],
1541
- };
1542
-
1543
- const CARD_FIELD_RULES = {
1544
- axiom: {
1545
- applies_when: { type: 'array' },
1546
- does_not_apply_when: { type: 'array' },
1547
- confidence: { allowed: ['low', 'medium', 'high'] },
1548
- evidence_type: { allowed: ['case_observation', 'theoretical', 'empirical', 'analogy', 'principle', 'practice'] },
1549
- },
1550
- boundary: {
1551
- acceptable_exceptions: { type: 'array', optional: true },
1552
- },
1553
- stance: {
1554
- applies_when: { type: 'array', optional: true },
1555
- },
1556
- misunderstanding: {
1557
- applies_when: { type: 'array', optional: true },
1558
- does_not_apply_when: { type: 'array', optional: true },
1559
- },
1560
- };
1561
-
1562
- function fieldIssue(fields, name, rule = {}) {
1563
- const val = fields[name];
1564
- if (val === undefined || val === null || val === '' ||
1565
- (Array.isArray(val) && val.length === 0)) {
1566
- return rule.optional ? null : { field: name, issue: 'missing' };
1567
- }
1568
- if (rule.type === 'array' && !Array.isArray(val)) {
1569
- return { field: name, issue: 'wrong_type', have_type: typeof val, need_type: 'array' };
1570
- }
1571
- if (rule.allowed && !rule.allowed.includes(val)) {
1572
- return { field: name, issue: 'invalid_value', have: val, allowed: rule.allowed };
1573
- }
1574
- return null;
1575
- }
1576
-
1577
- function formatFieldIssue(issue) {
1578
- if (issue.issue === 'missing') return issue.field;
1579
- if (issue.issue === 'wrong_type') return `${issue.field} (must be ${issue.need_type})`;
1580
- if (issue.issue === 'invalid_value') return `${issue.field} (must be one of: ${issue.allowed.join('|')})`;
1581
- return `${issue.field} (${issue.issue})`;
1582
- }
1583
-
1584
- function checkRequiredFields(type, fields) {
1585
- const required = CARD_REQUIRED_FIELDS[type];
1586
- if (!required) return [];
1587
- const issues = [];
1588
- for (const f of required) {
1589
- const issue = fieldIssue(fields, f, CARD_FIELD_RULES[type]?.[f] || {});
1590
- if (issue) issues.push(formatFieldIssue(issue));
1591
- }
1592
- for (const [name, rule] of Object.entries(CARD_FIELD_RULES[type] || {})) {
1593
- if (required.includes(name)) continue;
1594
- const issue = fieldIssue(fields, name, rule);
1595
- if (issue) issues.push(formatFieldIssue(issue));
1596
- }
1597
- return issues;
1598
- }
1599
-
1600
- function criticalAxiomIssues(project) {
1601
- const issues = [];
1602
- for (const card of (Array.isArray(project.cards) ? project.cards : [])) {
1603
- if (card.type !== 'axiom') continue;
1604
- const fields = card.fields || {};
1605
- for (const field of ['applies_when', 'does_not_apply_when', 'failure_risk']) {
1606
- const issue = fieldIssue(fields, field, CARD_FIELD_RULES.axiom[field] || {});
1607
- if (issue) issues.push(`${card.id}: ${formatFieldIssue(issue)}`);
1608
- }
1609
- }
1610
- return issues;
1611
- }
1612
-
1613
- function cardStrictTemplate(type, fields) {
1614
- const required = CARD_REQUIRED_FIELDS[type];
1615
- if (!required) return fields;
1616
- const filled = { ...fields };
1617
- for (const f of required) {
1618
- const val = filled[f];
1619
- if (val === undefined || val === null || val === '' ||
1620
- (Array.isArray(val) && val.length === 0)) {
1621
- filled[f] = `<TBD: ${f}>`;
1622
- }
1623
- }
1624
- return filled;
1625
- }
1626
-
1627
- function cmdCard(args) {
1628
- const sub = args[0];
1629
- if (sub === 'list') {
1630
- const { project } = readProject(args[1]);
1631
- // Bug (#2 UX): the --json flag was accepted but ignored — the
1632
- // command printed the same TSV either way. A script consumer
1633
- // that wanted to pipe the result into `jq` or a downstream
1634
- // tool got garbage. The fix respects --json and emits a real
1635
- // JSON document.
1636
- const useJson = args.includes('--json');
1637
- const rows = (project.cards || []).map(card => ({
1638
- id: card.id,
1639
- type: card.type,
1640
- status: card.status,
1641
- locked: !!card.locked,
1642
- fields: card.fields || {},
1643
- }));
1644
- if (useJson) {
1645
- console.log(JSON.stringify({ count: rows.length, cards: rows }, null, 2));
1646
- } else {
1647
- for (const r of rows) {
1648
- console.log(`${r.id}\t${r.type}\t${r.status}\t${r.locked ? 'locked' : 'unlocked'}`);
1649
- }
1650
- }
1651
- return;
1652
- }
1653
- if (sub === 'add') {
1654
- const projectInput = args[1];
1655
- const type = args[2];
1656
- if (!projectInput || !type) fail('Usage: kdna-studio card add <project> <type> --field key=value [--template <name>] [--no-strict]');
1657
- const { projectPath, project } = readProject(projectInput);
1658
- let fields = parseFields(args.slice(3));
1659
-
1660
- const useTemplate = option(args, '--template');
1661
- if (useTemplate === 'axiom-strict') {
1662
- if (type !== 'axiom') fail('--template axiom-strict only applies to axiom cards');
1663
- fields = cardStrictTemplate('axiom', fields);
1664
- }
1665
-
1666
- const isNoStrict = args.includes('--no-strict');
1667
- if (!isNoStrict) {
1668
- const missing = checkRequiredFields(type, fields);
1669
- if (missing.length > 0) {
1670
- fail(
1671
- `Missing required fields for ${type}: ${missing.join(', ')}\n` +
1672
- `Use --no-strict to add a partial card.`
1673
- );
1674
- }
1675
- }
1676
-
1677
- // Warn: one_sentence is the compact profile display field
1678
- if (type === 'axiom') {
1679
- const oneSentence = fields.one_sentence;
1680
- if (!oneSentence || String(oneSentence).startsWith('<TBD')) {
1681
- console.warn('Warning: one_sentence is used by kdna load --profile=compact.');
1682
- console.warn('Without it, the compact output may show a placeholder or truncated full_statement.');
1683
- console.warn('Add --field one_sentence="..." for best results.\n');
1684
- }
1685
- }
1686
-
1687
- const card = cardApi.createCard(type, fields);
1688
- project.cards.push(card);
1689
- if (project.stages?.judgment_cards) {
1690
- project.stages.judgment_cards.total = project.cards.length;
1691
- project.stages.judgment_cards.status = 'in_progress';
1692
- }
1693
- writeProject(projectPath, project);
1694
- if (args.includes('--json')) {
1695
- console.log(JSON.stringify({ added: true, card }, null, 2));
1696
- } else {
1697
- console.log(`Added card: ${card.id}`);
1698
- }
1699
- return;
1700
- }
1701
- if (sub === 'update') {
1702
- const projectInput = args[1];
1703
- const cardId = args[2];
1704
- if (!projectInput || !cardId) fail('Usage: kdna-studio card update <project> <card-id> --field key=value OR kdna-studio card update <project> <card-id> --from-file <path.json>');
1705
- const { projectPath, project } = readProject(projectInput);
1706
- const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1707
- if (idx < 0) fail(`Card not found: ${cardId}`);
1708
- const card = project.cards[idx];
1709
- if (card.locked) fail(`Cannot update locked card: ${cardId}. Unlock first with card unlock.`);
1710
- // Bug (UX pro-20 migration): prior version only accepted
1711
- // --field key=value, so an author migrating 20 source-tree
1712
- // assets had to run one CLI invocation per field. With
1713
- // confidence + evidence_type as 8th / 9th required fields on
1714
- // axiom, a 99-axiom asset became 99 * 2 = 198 invocations.
1715
- // The fix accepts --from-file <path.json> so an author can
1716
- // write a single JSON file per card (or per project) and
1717
- // apply it in one call. JSON shape: {field: value, ...}.
1718
- // kdna-studio card update . ax_xxx --from-file ./fills/ax_xxx.json
1719
- // For batched updates across many cards, see the
1720
- // --from-file-projects variant below, which reads a list
1721
- // of {id, fields} records.
1722
- const fromFileIdx = args.indexOf('--from-file');
1723
- if (fromFileIdx >= 0) {
1724
- const filePath = args[fromFileIdx + 1];
1725
- if (!filePath) fail('--from-file requires a path');
1726
- if (!fs.existsSync(filePath)) fail(`--from-file: file not found: ${filePath}`);
1727
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1728
- if (typeof data !== 'object' || Array.isArray(data) || data === null) {
1729
- fail(`--from-file: expected a JSON object mapping field names to values`);
1730
- }
1731
- for (const [k, v] of Object.entries(data)) {
1732
- if (k === 'id' || k === 'type' || k === 'status' || k === 'locked') {
1733
- fail(`--from-file: cannot edit reserved field "${k}"`);
1734
- }
1735
- if (card.fields) card.fields[k] = v;
1736
- }
1737
- } else {
1738
- const updates = parseFields(args.slice(3));
1739
- for (const [k, v] of Object.entries(updates)) {
1740
- if (card.fields) card.fields[k] = v;
1741
- }
1742
- }
1743
- project.cards[idx] = card;
1744
- writeProject(projectPath, project);
1745
- console.log(`Updated card: ${cardId}`);
1746
- return;
1747
- }
1748
- if (sub === 'remove') {
1749
- const projectInput = args[1];
1750
- const cardId = args[2];
1751
- if (!projectInput || !cardId) fail('Usage: kdna-studio card remove <project> <card-id>');
1752
- const { projectPath, project } = readProject(projectInput);
1753
- const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1754
- if (idx < 0) fail(`Card not found: ${cardId}`);
1755
- if (project.cards[idx].locked) fail(`Cannot remove locked card: ${cardId}. Unlock first with card unlock.`);
1756
- project.cards.splice(idx, 1);
1757
- if (project.stages?.judgment_cards) {
1758
- project.stages.judgment_cards.total = project.cards.length;
1759
- }
1760
- writeProject(projectPath, project);
1761
- console.log(`Removed card: ${cardId}`);
1762
- return;
1763
- }
1764
- if (sub === 'approve') {
1765
- const projectInput = args[1];
1766
- const approveAll = args.includes('--all');
1767
- const cardId = approveAll ? null : args[2];
1768
- if (!projectInput || (!cardId && !approveAll)) {
1769
- fail('Usage: kdna-studio card approve <project> <card-id|--all> --by <id> --statement <text> [--sign]');
1770
- }
1771
- const by = option(args, '--by');
1772
- const statement = option(args, '--statement');
1773
- if (!by || !statement) fail('card approve requires --by and --statement');
1774
- const { projectPath, project } = readProject(projectInput);
1775
- const errors = [];
1776
-
1777
- const lockOneCard = (card) => {
1778
- if (card.status === 'draft') card = cardApi.transitionCard(card, 'revised', { by });
1779
- const lockPayload = {
1780
- by,
1781
- statement,
1782
- checked: { applies_when: true, does_not_apply_when: true, failure_risk: true },
1783
- };
1784
- if (args.includes('--sign')) {
1785
- const identity = creatorApi.loadIdentity();
1786
- if (!identity) {
1787
- errors.push(`${card.id}: No creator identity. Run: kdna-studio identity init`);
1788
- return null;
1789
- }
1790
- lockPayload.creator_id = identity.creator_id;
1791
- const passphrase = resolvePassphrase(args);
1792
- try {
1793
- if (identity.encrypted && !passphrase) {
1794
- errors.push(`${card.id}: Private key is encrypted — provide --passphrase-stdin or KDNA_PASSPHRASE`);
1795
- return null;
1796
- }
1797
- lockPayload.signature = creatorApi.signHumanLock(
1798
- card.id, statement, cardApi.cardJudgmentFingerprint(card), null, passphrase,
1799
- );
1800
- } catch (e) {
1801
- errors.push(`${card.id}: Signing failed: ${e.message}`);
1802
- return null;
1803
- }
1804
- }
1805
- return cardApi.lockCard(card, lockPayload);
1806
- };
1807
-
1808
- if (cardId) {
1809
- const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1810
- if (idx < 0) fail(`Card not found: ${cardId}`);
1811
- if (project.cards[idx].locked) fail(`Card already locked: ${cardId}`);
1812
- const locked = lockOneCard(project.cards[idx]);
1813
- if (!locked) fail(errors[0]);
1814
- project.cards[idx] = locked;
1815
- const signed = args.includes('--sign') ? ' (signed)' : '';
1816
- console.log(`Approved and Human Locked: ${cardId}${signed}`);
1817
- } else {
1818
- let approved = 0;
1819
- let skipped = 0;
1820
- for (let i = 0; i < (project.cards || []).length; i++) {
1821
- const card = project.cards[i];
1822
- if (card.locked) { skipped++; continue; }
1823
- const locked = lockOneCard(card);
1824
- if (!locked) continue;
1825
- project.cards[i] = locked;
1826
- approved++;
1827
- }
1828
- if (errors.length > 0) {
1829
- fail(`Failed to approve ${errors.length} card(s):\n ${errors.join('\n ')}`);
1830
- }
1831
- if (approved === 0) {
1832
- if (skipped > 0) fail(`No unlocked cards to approve (${skipped} already locked).`);
1833
- fail('No unlocked cards to approve.');
1834
- }
1835
- const signed = args.includes('--sign') ? ' (signed)' : '';
1836
- console.log(`Approved and Human Locked: ${approved} cards${signed}${skipped > 0 ? ` (${skipped} already locked)` : ''}`);
1837
- }
1838
- if (project.stages?.judgment_cards) {
1839
- project.stages.judgment_cards.locked = project.cards.filter((c) => c.locked).length;
1840
- project.stages.judgment_cards.total = project.cards.length;
1841
- }
1842
- writeProject(projectPath, project);
1843
- return;
1844
- }
1845
- if (sub === 'unlock') {
1846
- const projectInput = args[1];
1847
- const cardId = args[2];
1848
- if (!projectInput || !cardId) fail('Usage: kdna-studio card unlock <project> <card-id> --by <id> --statement <text>');
1849
- const by = option(args, '--by');
1850
- const statement = option(args, '--statement');
1851
- if (!by || !statement) fail('card unlock requires --by and --statement');
1852
- const { projectPath, project } = readProject(projectInput);
1853
- const idx = (project.cards || []).findIndex((c) => c.id === cardId);
1854
- if (idx < 0) fail(`Card not found: ${cardId}`);
1855
- const card = project.cards[idx];
1856
- if (!card.locked) fail(`Card is not locked: ${cardId}`);
1857
- card.locked = false;
1858
- card.status = 'draft';
1859
- delete card.human_lock;
1860
- if (!card.audit_log) card.audit_log = [];
1861
- card.audit_log.push({
1862
- action: 'unlocked',
1863
- by,
1864
- statement,
1865
- timestamp: new Date().toISOString(),
1866
- });
1867
- project.cards[idx] = card;
1868
- if (project.stages?.judgment_cards) {
1869
- project.stages.judgment_cards.locked = project.cards.filter((c) => c.locked).length;
1870
- }
1871
- writeProject(projectPath, project);
1872
- console.log(`Unlocked card: ${cardId}`);
1873
- return;
1874
- }
1875
- fail('Usage: kdna-studio card <list|add|update|remove|approve|unlock> ...');
1876
- }
1877
-
1878
- function cmdLock(args) {
1879
- const { project } = readProject(args[0]);
1880
- const gate = projectApi.checkHumanLockGate(project);
1881
- if (gate.blocked) {
1882
- console.error('Human Lock Gate blocked export:');
1883
- for (const issue of gate.issues) console.error(` - ${issue.cardId}: ${issue.reason}`);
1884
- process.exit(EXIT.HUMAN_LOCK_REQUIRED);
1885
- }
1886
- console.log(`Human Lock Gate passed: ${gate.lockedJudgmentCards} locked judgment cards`);
1887
- }
1888
-
1889
- function cmdMigrate(args) {
1890
- // Bug (UX pro-20 migration): prior version of `migrate` always
1891
- // attempted the full pipeline — read source folder, import to
1892
- // Studio project, lock all cards, then export. An author migrating
1893
- // 20 source-tree assets had no way to know in advance whether
1894
- // the assets would pass the current Human Lock gate. The fix
1895
- // adds `--check` which runs the import + the critical-missing
1896
- // check + the lock-gate check, then prints a report and exits
1897
- // without writing the .kdna file. Pair with `audit-locks` for
1898
- // the full pre-migration report.
1899
- //
1900
- // Usage:
1901
- // kdna-studio migrate <source-dir> --check --name <@scope/name>
1902
- //
1903
- // Exit codes:
1904
- // 0 — would succeed
1905
- // 2 — INPUT_ERROR (missing args)
1906
- // 4 — would fail (Human Lock gate or critical axiom field)
1907
- const checkOnly = args.includes('--check');
1908
- const sourceDir = args[0];
1909
- const out = option(args, '--out');
1910
- const by = option(args, '--by');
1911
- const statement = option(args, '--statement');
1912
- const requestedName = option(args, '--name');
1913
- let name = requestedName || path.basename(path.resolve(sourceDir || '.'));
1914
- 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]');
1916
- }
1917
-
1918
- const sourcePath = path.resolve(sourceDir);
1919
- if (option(args, '--format')) {
1920
- fail('The --format option is not supported. KDNA has one current asset format.');
1921
- }
1922
- const isStudioProject = fs.existsSync(path.join(sourcePath, 'studio.project.json'));
1923
- let tmpDir = null;
1924
- let projectPath = null;
1925
- let project = null;
1926
-
1927
- // Wrap everything in try / finally so a mid-migrate failure (e.g. missing
1928
- // critical fields, gate blocked, signing error) still cleans up tmpDir.
1929
- // Bug: prior code only cleaned up on its success paths; any
1930
- // older export path that threw leaked `/tmp/kdna-migrate-*` entries
1931
- // indefinitely.
1932
- try {
1933
- // Step 1: import dev source, or use an existing Studio project directly.
1934
- let creatorIdentity = null;
1935
- try { creatorIdentity = creatorApi.loadIdentity(); } catch { /* proceed without */ }
1936
- if (isStudioProject) {
1937
- const loaded = readProject(sourcePath);
1938
- project = loaded.project;
1939
- projectPath = loaded.projectPath;
1940
- if (!requestedName && project.name) name = project.name;
1941
- } else {
1942
- tmpDir = trackTempDir(fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-migrate-')));
1943
- const imported = importFromFolder(sourceDir, tmpDir, name, creatorIdentity, { silent: true });
1944
- project = imported.project;
1945
- projectPath = path.join(tmpDir, 'studio.project.json');
1946
- }
1947
-
1948
- // Bug (#54): --allow-incomplete used to be read AFTER the critical
1949
- // fields check, so a caller asking to bypass the gate was rejected
1950
- // for the wrong reason. Resolve the flag here so the critical-missing
1951
- // check honours it (and reports the bypass correctly) instead of
1952
- // always failing.
1953
- const allowIncompleteEarly = args.includes('--allow-incomplete');
1954
-
1955
- // Reject if critical judgment fields are missing or malformed on axioms.
1956
- const criticalMissing = criticalAxiomIssues(project);
1957
- if (criticalMissing.length > 0) {
1958
- if (allowIncompleteEarly) {
1959
- console.warn(
1960
- `--allow-incomplete: bypassing ${criticalMissing.length} critical axiom-field check(s). ` +
1961
- `The exported .kdna will load but kdna-loader may not match this domain on those signals.`,
1962
- );
1963
- } else {
1964
- fail(
1965
- `Cannot migrate: ${criticalMissing.length} critical fields missing from axioms.\n` +
1966
- ` These fields are required for domain routing (kdna-loader uses them to\n` +
1967
- ` decide when to load this domain). Add them to your source files first,\n` +
1968
- ` or pass --allow-incomplete to bypass.\n` +
1969
- ` Missing:\n ` + criticalMissing.slice(0, 10).join('\n ') +
1970
- (criticalMissing.length > 10 ? `\n ... and ${criticalMissing.length - 10} more` : '')
1971
- );
1972
- }
1973
- }
1974
-
1975
- // --check branch: print a pre-migration report and exit. The
1976
- // project object already has every card from the source tree
1977
- // imported, so we can run the same audit-locks logic here
1978
- // without re-reading the source. This is the single-call
1979
- // equivalent of `kdna-studio create --from-folder <src> &&
1980
- // kdna-studio audit-locks .` — but it requires no
1981
- // intermediate write.
1982
- if (checkOnly) {
1983
- const REQUIRED = {
1984
- axiom: ['confidence', 'evidence_type', 'one_sentence', 'full_statement', 'why',
1985
- 'applies_when', 'does_not_apply_when', 'failure_risk'],
1986
- risk: ['name', 'description', 'mitigation'],
1987
- stance: ['statement', 'applies_when'],
1988
- misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
1989
- boundary: ['scope', 'out_of_scope'],
1990
- };
1991
- const summary = { project: name, source: sourceDir, total: project.cards.length, by_type: {}, would_block: false };
1992
- for (const card of (project.cards || [])) {
1993
- const req = REQUIRED[card.type];
1994
- if (!req) continue;
1995
- const missing = [];
1996
- for (const f of req) {
1997
- const issue = fieldIssue(card.fields || {}, f, CARD_FIELD_RULES[card.type]?.[f] || {});
1998
- if (issue) missing.push(formatFieldIssue(issue));
1999
- }
2000
- if (missing.length > 0) {
2001
- if (!summary.by_type[card.type]) summary.by_type[card.type] = [];
2002
- summary.by_type[card.type].push({ id: card.id, missing });
2003
- summary.would_block = true;
2004
- }
2005
- }
2006
- console.log(JSON.stringify(summary, null, 2));
2007
- process.exit(summary.would_block ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
2008
- }
2009
-
2010
- // Step 2: approve and Human Lock all cards
2011
- let locked = 0;
2012
- for (let i = 0; i < (project.cards || []).length; i++) {
2013
- let card = project.cards[i];
2014
- if (card.locked) { locked++; continue; }
2015
- if (card.status === 'draft') card = cardApi.transitionCard(card, 'revised', { by });
2016
- const lockPayload = {
2017
- by,
2018
- statement,
2019
- checked: { applies_when: true, does_not_apply_when: true, failure_risk: true },
2020
- };
2021
- if (args.includes('--sign')) {
2022
- const identity = creatorApi.loadIdentity();
2023
- if (!identity) fail('No creator identity. Run: kdna-studio identity init --name "Your Name"', EXIT.TRUST_FAILED);
2024
- lockPayload.creator_id = identity.creator_id;
2025
- const passphrase = resolvePassphrase(args);
2026
- try {
2027
- lockPayload.signature = creatorApi.signHumanLock(
2028
- card.id, statement, cardApi.cardJudgmentFingerprint(card), null, passphrase,
2029
- );
2030
- } catch (e) {
2031
- fail(`Failed to sign Human Lock for ${card.id}: ${e.message}`, EXIT.TRUST_FAILED);
2032
- }
2033
- }
2034
- project.cards[i] = cardApi.lockCard(card, lockPayload);
2035
- locked++;
2036
- }
2037
- if (project.stages?.judgment_cards) {
2038
- project.stages.judgment_cards.locked = locked;
2039
- project.stages.judgment_cards.total = project.cards.length;
2040
- }
2041
- writeProject(projectPath, project);
2042
- console.log(`Approved and Human Locked: ${locked} cards`);
2043
-
2044
- // Step 3: verify Human Lock gate
2045
- const gate = projectApi.checkHumanLockGate(project);
2046
- if (gate.blocked) {
2047
- const reasons = gate.issues.map((i) => `${i.cardId}: ${i.reason}`).join('\n - ');
2048
- fail(`Human Lock Gate blocked: ${reasons}`, EXIT.HUMAN_LOCK_REQUIRED);
2049
- }
2050
-
2051
- const absOut = path.resolve(out);
2052
- const allowIncomplete = args.includes('--allow-incomplete');
2053
- // Step 4: export the canonical runtime asset.
2054
- exportProject(project, name, absOut, { allowIncomplete });
2055
- if (tmpDir) {
2056
- try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* best effort */ }
2057
- tmpDir = null;
2058
- }
2059
- } finally {
2060
- // Single, guaranteed cleanup point for tmpDir even when an earlier
2061
- // step (critical-missing check, gate block, signing failure) bails
2062
- // out via `fail()`. `fail()` ultimately calls process.exit, but
2063
- // if a future caller swallows that or the runtime traps the throw,
2064
- // we still leave the filesystem clean.
2065
- if (tmpDir) {
2066
- try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* best effort */ }
2067
- }
2068
- }
2069
- }
2070
-
2071
- function exportProject(project, name, outPath, opts = {}) {
2072
- let core;
2073
- try { core = require('@aikdna/kdna-core'); } catch (e) {
2074
- fail('@aikdna/kdna-core is required for runtime export.', 2);
2075
- }
2076
- if (!exportRuntime || typeof exportRuntime.exportRuntimeAsset !== 'function') {
2077
- fail('@aikdna/kdna-studio-core with exportRuntime.exportRuntimeAsset is required for runtime export.', 2);
2078
- }
2079
- const criticalIssues = criticalAxiomIssues(project);
2080
- if (criticalIssues.length > 0) {
2081
- if (opts.allowIncomplete) {
2082
- console.warn(
2083
- `--allow-incomplete: bypassing ${criticalIssues.length} critical axiom-field check(s). ` +
2084
- `The exported .kdna will load but kdna-loader may not match this domain on those signals.`,
2085
- );
2086
- } else {
2087
- fail(
2088
- `Cannot export: ${criticalIssues.length} critical axiom field issue(s).\n` +
2089
- ` These fields are required for domain routing (kdna-loader uses them to\n` +
2090
- ` decide when to load this domain). Fix them first, or pass\n` +
2091
- ` --allow-incomplete to bypass.\n` +
2092
- ` Issues:\n ` + criticalIssues.slice(0, 10).join('\n ') +
2093
- (criticalIssues.length > 10 ? `\n ... and ${criticalIssues.length - 10} more` : ''),
2094
- EXIT.HUMAN_LOCK_REQUIRED,
2095
- );
2096
- }
2097
- }
2098
- const exportedCards = (project.cards || []).filter(c => c.status !== 'deprecated');
2099
- const runtimeDir = trackTempDir(fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-runtime-')));
2100
- try {
2101
- const manifestDefaults = buildManifest(project, name);
2102
- const runtimeAsset = exportRuntime.exportRuntimeAsset(project, {
2103
- asset_id: manifestDefaults.asset_id,
2104
- asset_uid: manifestDefaults.asset_uid,
2105
- title: manifestDefaults.title,
2106
- created_at: manifestDefaults.created_at,
2107
- updated_at: manifestDefaults.updated_at,
2108
- access: project.release?.access || project.source_manifest?.access || 'public',
2109
- password: opts.password || undefined,
2110
- });
2111
- writeFiles(runtimeDir, runtimeAsset.files);
2112
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
2113
- core.pack(runtimeDir, outPath);
2114
- const vr = core.validate(outPath);
2115
- if (!vr.overall_valid) fail('runtime export validation failed: ' + (vr.problems || []).join('; '));
2116
- console.log('Exported: ' + outPath);
2117
- console.log(' Name: ' + name + ' Cards: ' + exportedCards.length + ' Validated: runtime contract passed');
2118
- } finally {
2119
- try { fs.rmSync(runtimeDir, { recursive: true }); } catch { /* cleanup */ }
2120
- }
2121
- }
2122
-
2123
- function compileProject(projectInput) {
2124
- const { project } = readProject(projectInput);
2125
- return { project, result: compileApi.compileDomain(project) };
2126
- }
2127
-
2128
- function cmdCompile(args) {
2129
- const projectInput = args[0];
2130
- const out = option(args, '--out', './dist/studio-build');
2131
- if (!projectInput) fail('Usage: kdna-studio compile <project> --out <dir>');
2132
- const { result } = compileProject(projectInput);
2133
- writeFiles(path.resolve(out), result.files);
2134
- console.log(`Compiled Studio build output: ${path.resolve(out)}`);
2135
- console.log(`Build ID: ${result.identity.build_id}`);
2136
- }
2137
-
2138
- function cmdExport(args) {
2139
- const projectInput = args[0];
2140
- const out = option(args, '--out');
2141
- if (!projectInput || !out) fail('Usage: kdna-studio export <project> --out <file.kdna> [--allow-incomplete]');
2142
- if (option(args, '--format')) {
2143
- fail('The --format option is not supported. KDNA has one current asset format.');
2144
- }
2145
- if (args.includes('--sign')) {
2146
- fail('Asset signing is a separate runtime step. Export first, then run: kdna sign <asset.kdna>');
2147
- }
2148
- {
2149
- 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").
2160
- const useStdin = args.includes('--password-stdin');
2161
- let password;
2162
- if (useStdin) {
2163
- // fs.readFileSync(0) on a TTY waits forever for input and never
2164
- // returns — the export appears to hang. Refuse up front with a
2165
- // clear pointer to the right invocation.
2166
- if (process.stdin.isTTY) {
2167
- fail(
2168
- '--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' +
2170
- 'If you are running interactively, omit --password-stdin and you will be prompted.'
2171
- );
2172
- }
2173
- try {
2174
- password = fs.readFileSync(0, 'utf8').trim();
2175
- } catch (e) {
2176
- fail(`Could not read password from stdin: ${e.message}`);
2177
- }
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
- }
2189
- exportProject(project, option(args, '--name') || project.name, path.resolve(out), {
2190
- allowIncomplete: args.includes('--allow-incomplete'),
2191
- password,
2192
- });
2193
- return;
2194
- }
2195
- }
2196
-
2197
- function cmdStudioInstall(args) {
2198
- // Delegate to kdna CLI for runtime install operations
2199
- const target = args[0];
2200
- if (!target) fail('Usage: kdna-studio install <@scope/name|file.kdna>');
2201
- try {
2202
- const kdnaArgs = ['install', target, '--yes'];
2203
- if (args.includes('--trusted')) kdnaArgs.push('--trusted');
2204
- const result = require('child_process').spawnSync('kdna', kdnaArgs, {
2205
- stdio: 'inherit', encoding: 'utf8',
2206
- });
2207
- if (result.status !== 0) process.exit(result.status);
2208
- } catch (e) {
2209
- console.error('Error spawning kdna CLI:', e.message);
2210
- fail('kdna CLI not found. Install with: npm install -g @aikdna/kdna-cli');
2211
- }
2212
- }
2213
-
2214
- function cmdStudioUpdate(args) {
2215
- const target = args[0];
2216
- if (!target) fail('Usage: kdna-studio update <@scope/name>');
2217
- // kdna update does not exist in kdna-cli 0.28.x. The correct
2218
- // path is to re-install via npm or use kdna install to pull
2219
- // the latest published version of the asset.
2220
- console.log(`To update ${target}, run:`);
2221
- console.log(` npm install -g @aikdna/kdna-cli@latest`);
2222
- console.log(` kdna install ${target}`);
2223
- console.log(``);
2224
- console.log(`Or for a project-local install:`);
2225
- console.log(` cd <project-dir> && npm install @aikdna/kdna-cli@latest`);
2226
- process.exit(0);
2227
- }
2228
-
2229
- function cmdLlm(args) {
2230
- const sub = args[0];
2231
- if (sub === 'show') {
2232
- const cfg = llm.config();
2233
- console.log(`Provider : ${cfg.provider || '(not set)'}`);
2234
- console.log(`Model : ${cfg.model || '(not set)'}`);
2235
- console.log(`Base URL : ${cfg.baseURL || '(not set)'}`);
2236
- console.log(`API Key : ${cfg.apiKey ? '(set)' : '(not set)'}`);
2237
- return;
2238
- }
2239
- if (sub === 'config') {
2240
- const provider = option(args, '--provider') || option(args, '-p');
2241
- const model = option(args, '--model') || option(args, '-m');
2242
- const apiKey = resolveApiKey(args);
2243
- const baseURL = option(args, '--url') || option(args, '-u');
2244
- const updates = {};
2245
- if (provider) updates.provider = provider;
2246
- if (model) updates.model = model;
2247
- if (apiKey) updates.apiKey = apiKey;
2248
- if (baseURL) updates.baseURL = baseURL;
2249
- if (Object.keys(updates).length === 0) fail('Usage: kdna-studio llm config --provider <name> [--model <name>] [--key-pipe] [--url <base-url>]');
2250
- const cfg = llm.configure(updates);
2251
- console.log(JSON.stringify({ provider: cfg.provider, model: cfg.model, baseURL: cfg.baseURL }, null, 2));
2252
- return;
2253
- }
2254
- fail('Usage: kdna-studio llm <config|show>');
2255
- }
2256
-
2257
- function cmdIdentity(args) {
2258
- const sub = args[0];
2259
- if (sub === 'init') {
2260
- const name = option(args, '--name', process.env.USER || process.env.USERNAME || '');
2261
- const passphrase = resolvePassphrase(args);
2262
- try {
2263
- const identity = creatorApi.initIdentity(name, null, passphrase);
2264
- console.log(`Creator identity initialized:`);
2265
- console.log(` creator_id: ${identity.creator_id}`);
2266
- console.log(` display_name: ${identity.display_name}`);
2267
- console.log(` identity_dir: ${identity.identity_dir}`);
2268
- console.log(` public_key saved: ${identity.public_key_path}`);
2269
- console.log(` encrypted: ${identity.encrypted}`);
2270
- console.log(`\nBack up your private key at: ${creatorApi.privateKeyPath()}`);
2271
- if (identity.encrypted) {
2272
- console.log(` Private key is encrypted — keep your passphrase safe.`);
2273
- }
2274
- } catch (err) {
2275
- fail(`Identity already exists. Use 'kdna-studio identity show' to view. ${err.message}`);
2276
- }
2277
- return;
2278
- }
2279
- if (sub === 'show') {
2280
- const identity = creatorApi.loadIdentity();
2281
- if (!identity) fail('No identity found. Run: kdna-studio identity init --name "Your Name"');
2282
- console.log(JSON.stringify({
2283
- creator_id: identity.creator_id,
2284
- display_name: identity.display_name,
2285
- verified: identity.verified || false,
2286
- created_at: identity.created_at,
2287
- }, null, 2));
2288
- return;
2289
- }
2290
- fail('Usage: kdna-studio identity <init|show>');
2291
- }
2292
-
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
- // Audit-locks: list every card that would fail the current Human Lock
2302
- // gate because of missing or empty fields. Used during the
2303
- // pre-migration review of source-tree assets (e.g. pro-20) so an
2304
- // author can fill in the missing confidence / evidence_type / risk
2305
- // description / stance statement BEFORE running `migrate`.
2306
- //
2307
- // Bug (UX pro-20 migration): prior version of `kdna-studio report`
2308
- // returned a flat human_lock_gate.issues list, which mixed card
2309
- // types and field names. An author migrating 20 source-tree
2310
- // assets with 99 axioms + 140 risk cards + 70 stance cards had no
2311
- // way to see, for a single asset, "which axioms are missing
2312
- // confidence?" — they had to grep the project JSON. The fix adds
2313
- // a per-type, per-field breakdown.
2314
- function cmdAuditLocks(args) {
2315
- const { project } = readProject(args[0]);
2316
- const useJson = args.includes('--json');
2317
- const onlyType = option(args, '--type'); // 'axiom' | 'risk' | 'stance' | 'misunderstanding' | null
2318
-
2319
- const cards = project.cards || [];
2320
- const findings = {
2321
- project: project.name,
2322
- card_count: cards.length,
2323
- locked_count: cards.filter(c => c.locked).length,
2324
- by_type: {},
2325
- };
2326
-
2327
- // Per-card-type required field lists. Mirrors the current lockCard
2328
- // gate (kdna-studio-core/src/cards/index.js) so the output is
2329
- // exactly the field set the gate will reject.
2330
- const REQUIRED = {
2331
- axiom: ['one_sentence', 'full_statement', 'why', 'applies_when',
2332
- 'does_not_apply_when', 'failure_risk', 'confidence', 'evidence_type'],
2333
- risk: ['name', 'description', 'mitigation'],
2334
- stance: ['statement', 'applies_when'],
2335
- misunderstanding: ['wrong', 'correct', 'key_distinction', 'why'],
2336
- boundary: ['scope', 'out_of_scope'],
2337
- self_check: ['question'],
2338
- };
2339
- const THRESHOLDS = {
2340
- axiom: {
2341
- one_sentence: { min: 5, type: 'string' },
2342
- full_statement: { min: 20, type: 'string' },
2343
- why: { min: 20, type: 'string' },
2344
- applies_when: { type: 'array', minLength: 1 },
2345
- does_not_apply_when: { type: 'array', minLength: 1 },
2346
- failure_risk: { min: 1, type: 'string' },
2347
- confidence: { allowed: ['low', 'medium', 'high'] },
2348
- evidence_type: { allowed: ['case_observation', 'theoretical', 'empirical', 'analogy', 'principle', 'practice'] },
2349
- },
2350
- risk: {
2351
- name: { min: 1, type: 'string' },
2352
- description: { min: 20, type: 'string' },
2353
- mitigation: { min: 1, type: 'string' },
2354
- },
2355
- stance: {
2356
- statement: { min: 1, type: 'string' },
2357
- applies_when: { type: 'array', minLength: 0 },
2358
- },
2359
- };
2360
-
2361
- function checkField(type, fields, name, rule) {
2362
- const value = fields[name];
2363
- if (value === undefined || value === null || value === '') {
2364
- return { field: name, issue: 'missing' };
2365
- }
2366
- if (rule.type === 'string' && rule.min && String(value).length < rule.min) {
2367
- return { field: name, issue: 'too_short', have_length: String(value).length, need_min: rule.min };
2368
- }
2369
- if (rule.allowed && !rule.allowed.includes(value)) {
2370
- return { field: name, issue: 'invalid_value', have: value, allowed: rule.allowed };
2371
- }
2372
- if (rule.type === 'array' && !Array.isArray(value)) {
2373
- return { field: name, issue: 'wrong_type', have_type: typeof value, need_type: 'array' };
2374
- }
2375
- if (rule.type === 'array' && rule.minLength && value.length < rule.minLength) {
2376
- return { field: name, issue: 'too_short', have_length: value.length, need_min: rule.minLength };
2377
- }
2378
- return null;
2379
- }
2380
-
2381
- for (const card of cards) {
2382
- const req = REQUIRED[card.type];
2383
- if (!req) continue;
2384
- if (onlyType && card.type !== onlyType) continue;
2385
- const rule = THRESHOLDS[card.type] || {};
2386
- const missing = [];
2387
- for (const name of req) {
2388
- const issue = checkField(card.type, card.fields || {}, name, rule[name] || {});
2389
- if (issue) missing.push(issue);
2390
- }
2391
- if (missing.length > 0) {
2392
- if (!findings.by_type[card.type]) {
2393
- findings.by_type[card.type] = { missing_count: 0, cards: [] };
2394
- }
2395
- findings.by_type[card.type].missing_count += 1;
2396
- findings.by_type[card.type].cards.push({
2397
- id: card.id,
2398
- one_sentence: card.fields?.one_sentence || card.fields?.statement || card.fields?.question || '',
2399
- missing,
2400
- });
2401
- }
2402
- }
2403
-
2404
- if (useJson) {
2405
- console.log(JSON.stringify(findings, null, 2));
2406
- } else {
2407
- console.log(`Project: ${findings.project}`);
2408
- console.log(`Cards: ${findings.card_count} (locked: ${findings.locked_count})`);
2409
- const types = Object.keys(findings.by_type);
2410
- if (types.length === 0) {
2411
- console.log('\nAll cards pass the current Human Lock gate. Ready to migrate.');
2412
- return;
2413
- }
2414
- for (const t of types) {
2415
- const block = findings.by_type[t];
2416
- console.log(`\n${t}: ${block.missing_count} card(s) with missing fields`);
2417
- for (const card of block.cards) {
2418
- console.log(` ${card.id} ${(card.one_sentence || '').slice(0, 60)}`);
2419
- for (const m of card.missing) {
2420
- if (m.issue === 'missing') {
2421
- console.log(` - ${m.field}: missing`);
2422
- } else if (m.issue === 'too_short') {
2423
- console.log(` - ${m.field}: too short (have ${m.have_length}, need ≥ ${m.need_min})`);
2424
- } else if (m.issue === 'invalid_value') {
2425
- console.log(` - ${m.field}: invalid value "${m.have}", allowed: ${m.allowed.join('|')}`);
2426
- } else {
2427
- console.log(` - ${m.field}: ${m.issue}`);
2428
- }
2429
- }
2430
- }
2431
- }
2432
- }
2433
- }
2434
-
2435
- // ─── Distillation Commands ──────────────────────────────────────────
2436
-
2437
- function cmdTarget(args) {
2438
- const sub = args[0];
2439
- if (sub === 'declare') {
2440
- const projectInput = args[1];
2441
- if (!projectInput) fail('Usage: kdna-studio target declare <project> --category <cat> --scope <scope> --granularity <gran> --task <task>');
2442
- const { projectPath, project } = readProject(projectInput);
2443
- const category = option(args, '--category', 'expression_writing');
2444
- const scope = option(args, '--scope', 'personal');
2445
- const granularity = option(args, '--granularity', 'core_principles');
2446
- const taskScope = option(args, '--task', 'general content creation');
2447
- const include = option(args, '--include');
2448
- const exclude = option(args, '--exclude');
2449
- const loadCondition = option(args, '--load-condition');
2450
- const domainName = project.name || path.basename(path.dirname(projectPath), '.json');
2451
-
2452
- const target = distillationApi.createDistillationTarget({
2453
- domainName,
2454
- domainCategory: category,
2455
- ownerScope: scope,
2456
- granularity,
2457
- taskScope,
2458
- includeAreas: include ? include.split(',').map(s => s.trim()).filter(Boolean) : [],
2459
- excludeAreas: exclude ? exclude.split(',').map(s => s.trim()).filter(Boolean) : [],
2460
- loadCondition: loadCondition || '',
2461
- });
2462
-
2463
- const validation = distillationApi.validateDistillationTarget(target);
2464
- if (!validation.valid) fail(`Invalid target: ${validation.errors.join('; ')}`);
2465
-
2466
- project.distillation_target = target;
2467
- writeProject(projectPath, project);
2468
- console.log(JSON.stringify({ declared: true, target }, null, 2));
2469
- return;
2470
- }
2471
- if (sub === 'show') {
2472
- const { project } = readProject(args[1]);
2473
- const target = project.distillation_target;
2474
- if (!target) fail('No distillation target declared. Run: kdna-studio target declare <project>');
2475
- console.log(JSON.stringify(target, null, 2));
2476
- return;
2477
- }
2478
- fail('Usage: kdna-studio target <declare|show>');
2479
- }
2480
-
2481
- function cmdSourceClassify(args) {
2482
- const projectInput = args[0];
2483
- if (!projectInput) fail('Usage: kdna-studio source <project>');
2484
- const { projectPath, project } = readProject(projectInput);
2485
- const target = project.distillation_target;
2486
- if (!target) fail('Declare a distillation target first: kdna-studio target declare <project>');
2487
-
2488
- // Bug (#66 + #69): the previous version of this comment claimed
2489
- // `evidence_materials` was canonical and `evidence` was the legacy
2490
- // fallback. The reality was the opposite — `addEvidence` in
2491
- // @aikdna/kdna-studio-core wrote only to `project.evidence`, so
2492
- // every consumer that read `project.evidence_materials` (this
2493
- // command, cmdFilter, cmdDistill) saw an empty list. The fix in
2494
- // studio-core 1.7.5 makes `addEvidence` write to BOTH fields; the
2495
- // canonical name is now the one this comment names, not the one
2496
- // it used to name. The fallback `|| project.evidence` stays so
2497
- // legacy projects (pre-1.7.5) still classify correctly.
2498
- const evidence = project.evidence_materials || project.evidence || [];
2499
- const results = evidence.map(e => {
2500
- const text = (e.content || e.title || '').toLowerCase();
2501
- const domainWords = (target.include_areas || []).concat([target.domain_category, target.task_scope]);
2502
- const hitCount = domainWords.filter(w => text.includes(w.toLowerCase())).length;
2503
- let relevance;
2504
- if (hitCount >= 2) relevance = 'relevant';
2505
- else if (hitCount === 1) relevance = 'weakly_relevant';
2506
- else {
2507
- const otherHits = Object.keys(distillationApi.DOMAIN_CATEGORIES)
2508
- .filter(k => k !== target.domain_category)
2509
- .filter(k => text.includes(k)).length;
2510
- relevance = otherHits > 0 ? 'split_domain' : 'weakly_relevant';
2511
- }
2512
- return { id: e.id, title: e.title, relevance };
2513
- });
2514
-
2515
- const summary = {};
2516
- for (const r of results) { summary[r.relevance] = (summary[r.relevance] || 0) + 1; }
2517
-
2518
- project.distillation_evidence_relevance = results;
2519
- writeProject(projectPath, project);
2520
- console.log(JSON.stringify({ summary, results }, null, 2));
2521
- }
2522
-
2523
- async function cmdDistill(args) {
2524
- const projectInput = args[0];
2525
- const candidatesFile = option(args, '--candidates');
2526
- const useAI = args.includes('--ai');
2527
- // Bug (#1 UX): prior version called resolveApiKey(args)
2528
- // unconditionally, which is fine when --candidates is set (the
2529
- // call is a no-op) but it also surfaced the env-var resolution
2530
- // on every run, so a caller that never asked for the LLM was
2531
- // still at the mercy of `resolveApiKey` warnings. Defer the
2532
- // LLM-key resolution to the --ai branch.
2533
- const provider = useAI ? option(args, '--provider') : null;
2534
- const model = useAI ? option(args, '--model') : null;
2535
- const apiKey = useAI ? resolveApiKey(args) : null;
2536
- if (!projectInput) fail('Usage: kdna-studio distill <project> [--ai] [--candidates <file.json>]');
2537
- if (!candidatesFile && !useAI) fail('Either --ai or --candidates <file.json> required.');
2538
-
2539
- const { projectPath, project } = readProject(projectInput);
2540
- const target = project.distillation_target;
2541
- if (!target) fail('Declare a distillation target first: kdna-studio target declare <project>');
2542
-
2543
- let rawCandidates;
2544
- if (useAI) {
2545
- const evidence = (project.evidence_materials || []).map(e => ({ filename: e.filename || e.name, content: e.raw_text || e.content || '' }));
2546
- if (evidence.length === 0) console.warn('No evidence materials found. AI distillation works best with imported evidence. Run: kdna-studio import <project> <file>');
2547
-
2548
- console.log('Running AI distillation...');
2549
- rawCandidates = await ai.distill(llm.config(), evidence, target, { provider, model, apiKey });
2550
- console.log(`AI extracted ${rawCandidates.length} candidates.`);
2551
- } else {
2552
- const rawPath = path.resolve(candidatesFile);
2553
- if (!fs.existsSync(rawPath)) fail(`Candidates file not found: ${rawPath}`);
2554
- const stats = fs.statSync(rawPath);
2555
- if (stats.size > 50 * 1024 * 1024) {
2556
- fail(`Candidates file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${rawPath}`);
2557
- }
2558
- rawCandidates = JSON.parse(fs.readFileSync(rawPath, 'utf8'));
2559
- if (!Array.isArray(rawCandidates)) fail('Candidates file must contain a JSON array');
2560
- }
2561
-
2562
- const candidates = rawCandidates.map(c => {
2563
- const base = {
2564
- id: c.candidate_id || c.id || `cand_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2565
- one_sentence: c.one_sentence || c.oneSentence || '',
2566
- full_statement: c.full_statement || c.fullStatement || '',
2567
- suggested_card_type: c.type || c.suggested_card_type || 'axiom',
2568
- supporting_evidence_ids: (c.evidence_ids || c.evidenceIds || c.supporting_evidence_ids || []).map(String),
2569
- confidence: c.confidence || 'medium',
2570
- candidate_status: 'proposed',
2571
- scope_fit: c.scope_fit ?? c.scopeFit ?? true,
2572
- };
2573
- return distillationApi.applyScopeGate(base, target);
2574
- });
2575
-
2576
- const summary = distillationApi.candidateStatusSummary(candidates);
2577
- project.distillation_candidates = candidates;
2578
- writeProject(projectPath, project);
2579
- console.log(JSON.stringify({ summary, count: candidates.length }, null, 2));
2580
- }
2581
-
2582
- function cmdCandidate(args) {
2583
- const sub = args[0];
2584
- const projectInput = args[1];
2585
- if (!sub || !projectInput) fail('Usage: kdna-studio candidate <list|accept|reject|override|promote>');
2586
-
2587
- const { projectPath, project } = readProject(projectInput);
2588
- const candidates = project.distillation_candidates || [];
2589
-
2590
- if (sub === 'list') {
2591
- for (const c of candidates) {
2592
- const scopeMarker = c.scope_fit ? '' : ' [OUT_OF_SCOPE]';
2593
- const split = c.suggested_split_domain ? ` → ${c.suggested_split_domain}` : '';
2594
- console.log(`${c.id}\t${c.candidate_status}\t${c.suggested_card_type}\t${c.confidence}\t${c.one_sentence.substring(0,60)}${scopeMarker}${split}`);
2595
- }
2596
- console.log(JSON.stringify(distillationApi.candidateStatusSummary(candidates), null, 2));
2597
- return;
2598
- }
2599
-
2600
- if (sub === 'accept' || sub === 'reject') {
2601
- const candId = args[2];
2602
- if (!candId) fail(`Usage: kdna-studio candidate ${sub} <project> <candidate-id>`);
2603
- const idx = candidates.findIndex(c => c.id === candId);
2604
- if (idx < 0) fail(`Candidate not found: ${candId}`);
2605
- candidates[idx].candidate_status = sub === 'accept' ? 'accepted' : 'rejected';
2606
- project.distillation_candidates = candidates;
2607
- writeProject(projectPath, project);
2608
- console.log(`${sub === 'accept' ? 'Accepted' : 'Rejected'}: ${candId}`);
2609
- return;
2610
- }
2611
-
2612
- if (sub === 'override') {
2613
- const candId = args[2];
2614
- if (!candId) fail('Usage: kdna-studio candidate override <project> <candidate-id>');
2615
- const idx = candidates.findIndex(c => c.id === candId);
2616
- if (idx < 0) fail(`Candidate not found: ${candId}`);
2617
- candidates[idx].scope_fit = true;
2618
- candidates[idx].domain_relevance_score = 50;
2619
- candidates[idx].relevance_evidence = 'User explicitly overrode scope gate via CLI';
2620
- candidates[idx].suggested_split_domain = null;
2621
- project.distillation_candidates = candidates;
2622
- writeProject(projectPath, project);
2623
- console.log(`Scope gate overridden: ${candId}`);
2624
- return;
2625
- }
2626
-
2627
- if (sub === 'promote') {
2628
- const accepted = candidates.filter(c => c.candidate_status === 'accepted' && c.scope_fit !== false);
2629
- if (accepted.length === 0) fail('No candidates to promote. Accept some candidates first.');
2630
-
2631
- 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 = {};
2640
- 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 || '';
2653
- }
2654
- const card = cardApi.createCard(c.suggested_card_type, fields);
2655
- card.evidence_refs = c.supporting_evidence_ids;
2656
- project.cards = project.cards || [];
2657
- project.cards.push(card);
2658
- }
2659
- if (project.stages?.judgment_cards) {
2660
- project.stages.judgment_cards.total = project.cards.length;
2661
- project.stages.judgment_cards.status = 'in_progress';
2662
- }
2663
- writeProject(projectPath, project);
2664
- console.log(`Promoted ${accepted.length} candidates → ${accepted.length} Studio cards`);
2665
- return;
2666
- }
2667
-
2668
- fail('Usage: kdna-studio candidate <list|accept|reject|override|promote>');
2669
- }
2670
-
2671
- async function cmdInterview(args) {
2672
- const projectInput = args[0];
2673
- const stage = option(args, '--stage');
2674
- if (!projectInput) fail('Usage: kdna-studio interview <project> [--stage distill|clarify|correct|replay]');
2675
- const { project } = readProject(projectInput);
2676
-
2677
- if (stage) {
2678
- const result = await ai.interview.runInterview(llm.config(), project, stage, '', {});
2679
- console.log(`\n=== ${result.label} ===\n${result.content}`);
2680
- } else {
2681
- await ai.interview.runInterviewInteractive(llm.config(), project, {});
2682
- }
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
- }
2774
- }
2775
-
2776
- card.feynman_evaluation = { score, criteria: result.criteria, suggestions: result.suggestions, evaluated_at: new Date().toISOString() };
2777
- project.cards[cardIdx] = card;
2778
- writeProject(projectPath, project);
2779
- }
2780
-
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
- const args = process.argv.slice(2);
2845
- const cmd = args[0];
2846
- if (!cmd || cmd === '--help' || cmd === '-h') {
2847
- usage();
2848
- process.exit(EXIT.OK);
2849
- }
2850
- if (cmd === '--version' || cmd === '-v') {
2851
- console.log(require('../package.json').version);
2852
- process.exit(EXIT.OK);
2853
- }
2854
-
2855
- (async () => {
2856
- try {
2857
- if (cmd === 'create') cmdCreate(args.slice(1));
2858
- else if (cmd === 'migrate') cmdMigrate(args.slice(1));
2859
- else if (cmd === 'import') cmdImport(args.slice(1));
2860
- else if (cmd === 'filter') cmdFilter(args.slice(1));
2861
- else if (cmd === 'target') cmdTarget(args.slice(1));
2862
- else if (cmd === 'source') cmdSourceClassify(args.slice(1));
2863
- else if (cmd === 'distill') await cmdDistill(args.slice(1));
2864
- else if (cmd === 'candidate') cmdCandidate(args.slice(1));
2865
- 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
- else if (cmd === 'card') cmdCard(args.slice(1));
2869
- else if (cmd === 'lock') cmdLock(args.slice(1));
2870
- else if (cmd === 'compile') cmdCompile(args.slice(1));
2871
- else if (cmd === 'export') cmdExport(args.slice(1));
2872
- else if (cmd === 'llm') cmdLlm(args.slice(1));
2873
- else if (cmd === 'identity') cmdIdentity(args.slice(1));
2874
- else if (cmd === 'report') cmdReport(args.slice(1));
2875
- else if (cmd === 'audit-locks') cmdAuditLocks(args.slice(1));
2876
- else if (cmd === 'install') cmdStudioInstall(args.slice(1));
2877
- else if (cmd === 'update') cmdStudioUpdate(args.slice(1));
2878
- else if (cmd === 'export-route-card') cmdExportRouteCard(args.slice(1));
2879
- else if (cmd === 'export-consumer-index') cmdExportConsumerIndex(args.slice(1));
2880
- else {
2881
- usage();
2882
- fail(`Unknown command: ${cmd}`);
2883
- }
2884
- } catch (err) {
2885
- fail(err.message || String(err));
2886
- }
2887
- })();
2888
-
2889
- function cmdExportRouteCard(args) {
2890
- const domainId = args[0];
2891
- if (!domainId) fail('Usage: kdna-studio export-route-card <domain-id> [--out=<path>]');
2892
- const outArg = option(args, '--out');
2893
- const outPath = outArg || `${slugSegment(domainId, 'domain')}.route-card.json`;
2894
-
2895
- const card = {
2896
- route_card: "0.1.0",
2897
- domain_id: domainId,
2898
- role: "primary",
2899
- boundaries: {
2900
- applies_when: [],
2901
- does_not_apply_when: [],
2902
- },
2903
- neighbors: [],
2904
- advisor_edges: [],
2905
- provenance: {
2906
- generated_by: "kdna-studio",
2907
- generated_at: new Date().toISOString(),
2908
- input_hash: crypto.createHash("sha256").update(domainId).digest("hex").slice(0, 12),
2909
- review_status: "draft_generated",
2910
- },
2911
- };
2912
-
2913
- const absOut = path.resolve(outPath);
2914
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
2915
- fs.writeFileSync(absOut, JSON.stringify(card, null, 2) + "\n");
2916
- console.log(`Route card skeleton written: ${absOut}`);
2917
- }
2918
-
2919
- function cmdExportConsumerIndex(args) {
2920
- const entriesArg = option(args, '--entries');
2921
- const outArg = option(args, '--out');
2922
- const outPath = outArg || 'consumer-index.json';
2923
-
2924
- const domainIds = entriesArg
2925
- ? entriesArg.split(",").map((s) => s.trim()).filter(Boolean)
2926
- : [];
2927
-
2928
- const index = {
2929
- consumer_index: "0.1.0",
2930
- entries: domainIds.map((domainId) => ({
2931
- domain_id: domainId,
2932
- status: "draft_generated",
2933
- enabled: false,
2934
- route_preference: {
2935
- primary_for: [],
2936
- advisor_for: [],
2937
- never_for: [],
2938
- },
2939
- provenance: {
2940
- generated_by: "kdna-studio",
2941
- generated_at: new Date().toISOString(),
2942
- input_hash: crypto.createHash("sha256").update(domainId).digest("hex").slice(0, 12),
2943
- source: "studio-export",
2944
- },
2945
- })),
2946
- };
2947
-
2948
- const absOut = path.resolve(outPath);
2949
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
2950
- fs.writeFileSync(absOut, JSON.stringify(index, null, 2) + "\n");
2951
- console.log(`Consumer index skeleton written: ${absOut}`);
2952
- }
2
+ 'use strict';
3
+ require('../src/terminal-workspace.js').main(process.argv.slice(2)).catch(error => {
4
+ const code = typeof error.code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(error.code) ? error.code : 'CLI_FAILURE';
5
+ process.stderr.write(JSON.stringify({ status: 'rejected', code }) + '\n');
6
+ process.exitCode = 2;
7
+ });