@aikdna/kdna-studio-cli 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/kdna-studio.js +113 -17
- package/package.json +2 -3
- package/src/llm/config.js +20 -7
- package/src/llm/index.js +3 -2
- package/tests/cli.test.js +0 -869
- package/tests/e2e-export-completeness.test.js +0 -93
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ kdna-studio export my_domain --format v1 --out dist/my_domain.kdna
|
|
|
56
56
|
Candidate promotion is scope-gated: only candidates with `status == accepted` and `scope_fit == true` are promoted to cards by default. Use `kdna-studio candidate override <project> <candidate-id>` only when a human intentionally overrides the scope gate.
|
|
57
57
|
|
|
58
58
|
The current Studio CLI export workflow uses approved cards as release evidence.
|
|
59
|
-
This is Studio project policy, not a
|
|
59
|
+
This is Studio project policy, not a Core GA format-validity rule. Human
|
|
60
60
|
Lock and other provenance records are optional review evidence, not validity
|
|
61
61
|
requirements.
|
|
62
62
|
|
|
@@ -75,10 +75,10 @@ loading existing `.kdna` assets.
|
|
|
75
75
|
## Runtime Export Contract
|
|
76
76
|
|
|
77
77
|
`kdna-studio export --format v1` is the canonical runtime export path. It uses
|
|
78
|
-
`@aikdna/kdna-studio-core` to compile the Studio project into a
|
|
78
|
+
`@aikdna/kdna-studio-core` to compile the Studio project into a Core GA
|
|
79
79
|
runtime asset and then packs it with `@aikdna/kdna-core`.
|
|
80
80
|
|
|
81
|
-
A
|
|
81
|
+
A Core GA runtime export contains only these top-level entries:
|
|
82
82
|
|
|
83
83
|
- `mimetype`
|
|
84
84
|
- `kdna.json`
|
package/bin/kdna-studio.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const zlib = require('zlib');
|
|
7
7
|
const cbor = require('cbor-x');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
8
9
|
|
|
9
10
|
function loadStudioCore() {
|
|
10
11
|
const publishedCore = require('@aikdna/kdna-studio-core');
|
|
@@ -36,7 +37,7 @@ function usage() {
|
|
|
36
37
|
console.log(`kdna-studio — Studio-compatible KDNA authoring CLI
|
|
37
38
|
|
|
38
39
|
LLM (AI-powered authoring):
|
|
39
|
-
kdna-studio llm config [--provider <name>] [--model <name>] [--key
|
|
40
|
+
kdna-studio llm config [--provider <name>] [--model <name>] [--key-pipe] [--url <base-url>]
|
|
40
41
|
kdna-studio llm show
|
|
41
42
|
|
|
42
43
|
Identity:
|
|
@@ -54,7 +55,7 @@ Migrate (dev source or Studio project → canonical .kdna in one command):
|
|
|
54
55
|
Authoring:
|
|
55
56
|
kdna-studio import <project> <source-file-or-dir> # import evidence (txt/md/json/yaml/csv/log/srt/vtt/html)
|
|
56
57
|
kdna-studio filter <project> # check evidence for sensitive content
|
|
57
|
-
kdna-studio source
|
|
58
|
+
kdna-studio source <project> # classify imported evidence against declared target
|
|
58
59
|
kdna-studio card list <project>
|
|
59
60
|
kdna-studio card add <project> <type> --field key=value [--field key=value] [--template <name>] [--no-strict]
|
|
60
61
|
kdna-studio card update <project> <card-id> --field key=value
|
|
@@ -98,6 +99,34 @@ function option(args, name, fallback = null) {
|
|
|
98
99
|
return value;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
function resolveApiKey(args) {
|
|
103
|
+
// 1. KDNA_API_KEY environment variable (preferred)
|
|
104
|
+
if (process.env.KDNA_API_KEY) return process.env.KDNA_API_KEY;
|
|
105
|
+
|
|
106
|
+
// 2. Read from stdin via --key-pipe flag
|
|
107
|
+
if (args.includes('--key-pipe')) {
|
|
108
|
+
if (process.stdin.isTTY) {
|
|
109
|
+
console.error('Error: --key-pipe requires stdin to be piped. Example: echo $KDNA_API_KEY | kdna-studio llm config --key-pipe');
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return fs.readFileSync(0, 'utf8').trim();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error('Error reading API key from stdin:', e.message);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Deprecated --key / -k flag (backward compat)
|
|
121
|
+
const key = option(args, '--key', null) || option(args, '-k', null);
|
|
122
|
+
if (key) {
|
|
123
|
+
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).');
|
|
124
|
+
return key;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
function optionsAll(args, name) {
|
|
102
131
|
const values = [];
|
|
103
132
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -520,6 +549,10 @@ function importFromKdna(kdnaPath) {
|
|
|
520
549
|
if (!fs.existsSync(absKdna)) fail(`KDNA asset not found: ${absKdna}`);
|
|
521
550
|
if (!absKdna.endsWith('.kdna')) fail('--from-kdna requires a .kdna file');
|
|
522
551
|
|
|
552
|
+
const stats = fs.statSync(absKdna);
|
|
553
|
+
if (stats.size > 50 * 1024 * 1024) {
|
|
554
|
+
fail(`KDNA file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${absKdna}`);
|
|
555
|
+
}
|
|
523
556
|
const zipBuf = fs.readFileSync(absKdna);
|
|
524
557
|
const entries = readZipEntries(zipBuf);
|
|
525
558
|
if (!entries.has('kdna.json')) fail('Not a valid .kdna asset: missing kdna.json');
|
|
@@ -842,13 +875,60 @@ function cmdImport(args) {
|
|
|
842
875
|
let imported = 0;
|
|
843
876
|
let skipped = 0;
|
|
844
877
|
|
|
878
|
+
function extractBinaryText(filePath, ext) {
|
|
879
|
+
try {
|
|
880
|
+
if (ext === '.pdf') {
|
|
881
|
+
const result = execFileSync('pdftotext', ['-raw', filePath, '-'], {
|
|
882
|
+
encoding: 'utf8',
|
|
883
|
+
timeout: 30000,
|
|
884
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
885
|
+
});
|
|
886
|
+
return { text: result, error: null };
|
|
887
|
+
}
|
|
888
|
+
if (ext === '.docx' || ext === '.rtf') {
|
|
889
|
+
const result = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], {
|
|
890
|
+
encoding: 'utf8',
|
|
891
|
+
timeout: 30000,
|
|
892
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
893
|
+
});
|
|
894
|
+
return { text: result, error: null };
|
|
895
|
+
}
|
|
896
|
+
} catch (e) {
|
|
897
|
+
// Tool not installed or extraction failed — report distinguishably
|
|
898
|
+
if (e.code === 'ENOENT') {
|
|
899
|
+
return { text: null, error: 'tool_missing' };
|
|
900
|
+
}
|
|
901
|
+
return { text: null, error: 'extraction_failed', detail: e.message };
|
|
902
|
+
}
|
|
903
|
+
return { text: null, error: 'unsupported_format' };
|
|
904
|
+
}
|
|
905
|
+
|
|
845
906
|
function importFile(filePath) {
|
|
846
907
|
const ext = path.extname(filePath).toLowerCase();
|
|
847
908
|
const name = path.basename(filePath);
|
|
848
909
|
|
|
849
910
|
if (BINARY_EXTS.has(ext)) {
|
|
850
|
-
|
|
851
|
-
|
|
911
|
+
const extracted = extractBinaryText(filePath, ext);
|
|
912
|
+
if (!extracted.text || extracted.text.trim().length === 0) {
|
|
913
|
+
const tool = ext === '.pdf' ? 'pdftotext (poppler-utils)' : 'textutil (macOS built-in)';
|
|
914
|
+
if (extracted.error === 'tool_missing') {
|
|
915
|
+
console.warn(`Skipping ${ext} file (${tool} not installed): ${name}`);
|
|
916
|
+
} else if (extracted.error === 'extraction_failed') {
|
|
917
|
+
console.warn(`Skipping ${ext} file (extraction failed: ${extracted.detail}): ${name}`);
|
|
918
|
+
} else {
|
|
919
|
+
console.warn(`Skipping ${ext} file (empty or unsupported): ${name}`);
|
|
920
|
+
}
|
|
921
|
+
skipped++;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (extracted.text.length > 120000) {
|
|
925
|
+
console.warn(`Extracted text too large (${extracted.text.length} chars, max 120000): ${name}`);
|
|
926
|
+
skipped++;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const evidence = evidenceApi.createEvidenceEntry('text', name, extracted.text.substring(0, 120000), filePath);
|
|
930
|
+
evidenceApi.addEvidence(project, evidence);
|
|
931
|
+
imported++;
|
|
852
932
|
return;
|
|
853
933
|
}
|
|
854
934
|
|
|
@@ -865,13 +945,13 @@ function cmdImport(args) {
|
|
|
865
945
|
return;
|
|
866
946
|
}
|
|
867
947
|
let content;
|
|
868
|
-
try {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
948
|
+
try {
|
|
949
|
+
const fd = fs.openSync(filePath, 'r');
|
|
950
|
+
const buf = Buffer.alloc(120000);
|
|
951
|
+
const bytesRead = fs.readSync(fd, buf, 0, 120000, 0);
|
|
952
|
+
fs.closeSync(fd);
|
|
953
|
+
content = buf.toString('utf8', 0, bytesRead);
|
|
954
|
+
} catch { console.warn(`Cannot read: ${name}`); skipped++; return; }
|
|
875
955
|
|
|
876
956
|
const evidence = evidenceApi.createEvidenceEntry('text', name, content, filePath);
|
|
877
957
|
evidenceApi.addEvidence(project, evidence);
|
|
@@ -997,6 +1077,16 @@ function cmdCard(args) {
|
|
|
997
1077
|
}
|
|
998
1078
|
}
|
|
999
1079
|
|
|
1080
|
+
// Warn: one_sentence is the compact profile display field
|
|
1081
|
+
if (type === 'axiom') {
|
|
1082
|
+
const oneSentence = fields.one_sentence;
|
|
1083
|
+
if (!oneSentence || String(oneSentence).startsWith('<TBD')) {
|
|
1084
|
+
console.warn('Warning: one_sentence is used by kdna load --profile=compact.');
|
|
1085
|
+
console.warn('Without it, the compact output may show a placeholder or truncated full_statement.');
|
|
1086
|
+
console.warn('Add --field one_sentence="..." for best results.\n');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1000
1090
|
const card = cardApi.createCard(type, fields);
|
|
1001
1091
|
project.cards.push(card);
|
|
1002
1092
|
if (project.stages?.judgment_cards) {
|
|
@@ -1584,7 +1674,8 @@ function cmdStudioInstall(args) {
|
|
|
1584
1674
|
stdio: 'inherit', encoding: 'utf8',
|
|
1585
1675
|
});
|
|
1586
1676
|
if (result.status !== 0) process.exit(result.status);
|
|
1587
|
-
} catch {
|
|
1677
|
+
} catch (e) {
|
|
1678
|
+
console.error('Error spawning kdna CLI:', e.message);
|
|
1588
1679
|
fail('kdna CLI not found. Install with: npm install -g @aikdna/kdna-cli');
|
|
1589
1680
|
}
|
|
1590
1681
|
}
|
|
@@ -1597,7 +1688,8 @@ function cmdStudioUpdate(args) {
|
|
|
1597
1688
|
stdio: 'inherit', encoding: 'utf8',
|
|
1598
1689
|
});
|
|
1599
1690
|
if (result.status !== 0) process.exit(result.status);
|
|
1600
|
-
} catch {
|
|
1691
|
+
} catch (e) {
|
|
1692
|
+
console.error('Error spawning kdna CLI:', e.message);
|
|
1601
1693
|
fail('kdna CLI not found. Install with: npm install -g @aikdna/kdna-cli');
|
|
1602
1694
|
}
|
|
1603
1695
|
}
|
|
@@ -1615,14 +1707,14 @@ function cmdLlm(args) {
|
|
|
1615
1707
|
if (sub === 'config') {
|
|
1616
1708
|
const provider = option(args, '--provider') || option(args, '-p');
|
|
1617
1709
|
const model = option(args, '--model') || option(args, '-m');
|
|
1618
|
-
const apiKey =
|
|
1710
|
+
const apiKey = resolveApiKey(args);
|
|
1619
1711
|
const baseURL = option(args, '--url') || option(args, '-u');
|
|
1620
1712
|
const updates = {};
|
|
1621
1713
|
if (provider) updates.provider = provider;
|
|
1622
1714
|
if (model) updates.model = model;
|
|
1623
1715
|
if (apiKey) updates.apiKey = apiKey;
|
|
1624
1716
|
if (baseURL) updates.baseURL = baseURL;
|
|
1625
|
-
if (Object.keys(updates).length === 0) fail('Usage: kdna-studio llm config --provider <name> [--model <name>] [--key
|
|
1717
|
+
if (Object.keys(updates).length === 0) fail('Usage: kdna-studio llm config --provider <name> [--model <name>] [--key-pipe] [--url <base-url>]');
|
|
1626
1718
|
const cfg = llm.configure(updates);
|
|
1627
1719
|
console.log(JSON.stringify({ provider: cfg.provider, model: cfg.model, baseURL: cfg.baseURL }, null, 2));
|
|
1628
1720
|
return;
|
|
@@ -1722,7 +1814,7 @@ function cmdTarget(args) {
|
|
|
1722
1814
|
|
|
1723
1815
|
function cmdSourceClassify(args) {
|
|
1724
1816
|
const projectInput = args[0];
|
|
1725
|
-
if (!projectInput) fail('Usage: kdna-studio source
|
|
1817
|
+
if (!projectInput) fail('Usage: kdna-studio source <project>');
|
|
1726
1818
|
const { projectPath, project } = readProject(projectInput);
|
|
1727
1819
|
const target = project.distillation_target;
|
|
1728
1820
|
if (!target) fail('Declare a distillation target first: kdna-studio target declare <project>');
|
|
@@ -1758,7 +1850,7 @@ async function cmdDistill(args) {
|
|
|
1758
1850
|
const useAI = args.includes('--ai');
|
|
1759
1851
|
const provider = option(args, '--provider');
|
|
1760
1852
|
const model = option(args, '--model');
|
|
1761
|
-
const apiKey =
|
|
1853
|
+
const apiKey = resolveApiKey(args);
|
|
1762
1854
|
if (!projectInput) fail('Usage: kdna-studio distill <project> [--ai] [--candidates <file.json>]');
|
|
1763
1855
|
if (!candidatesFile && !useAI) fail('Either --ai or --candidates <file.json> required.');
|
|
1764
1856
|
|
|
@@ -1777,6 +1869,10 @@ async function cmdDistill(args) {
|
|
|
1777
1869
|
} else {
|
|
1778
1870
|
const rawPath = path.resolve(candidatesFile);
|
|
1779
1871
|
if (!fs.existsSync(rawPath)) fail(`Candidates file not found: ${rawPath}`);
|
|
1872
|
+
const stats = fs.statSync(rawPath);
|
|
1873
|
+
if (stats.size > 50 * 1024 * 1024) {
|
|
1874
|
+
fail(`Candidates file too large (${(stats.size / (1024 * 1024)).toFixed(1)} MiB, max 50 MiB): ${rawPath}`);
|
|
1875
|
+
}
|
|
1780
1876
|
rawCandidates = JSON.parse(fs.readFileSync(rawPath, 'utf8'));
|
|
1781
1877
|
if (!Array.isArray(rawCandidates)) fail('Candidates file must contain a JSON array');
|
|
1782
1878
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-studio-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Official KDNA Studio command-line authoring entry for creating, reviewing, compiling, and exporting .kdna judgment assets through the KDNA toolchain.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
11
|
"src/",
|
|
12
|
-
"tests/",
|
|
13
12
|
"README.md"
|
|
14
13
|
],
|
|
15
14
|
"scripts": {
|
|
@@ -48,4 +47,4 @@
|
|
|
48
47
|
"devDependencies": {
|
|
49
48
|
"@aikdna/kdna-core": "^0.12.5"
|
|
50
49
|
}
|
|
51
|
-
}
|
|
50
|
+
}
|
package/src/llm/config.js
CHANGED
|
@@ -24,7 +24,7 @@ function loadConfig() {
|
|
|
24
24
|
|
|
25
25
|
// 1. Read from ~/.kdna/config.json
|
|
26
26
|
const configPath = path.join(os.homedir(), '.kdna', 'config.json');
|
|
27
|
-
try { if (fs.existsSync(configPath)) Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf8')).llm || {}); } catch {}
|
|
27
|
+
try { if (fs.existsSync(configPath)) Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf8')).llm || {}); } catch (e) { console.error('Error reading LLM config file:', e.message); }
|
|
28
28
|
|
|
29
29
|
// 2. Env vars override
|
|
30
30
|
if (process.env.KDNA_LLM_PROVIDER) config.provider = process.env.KDNA_LLM_PROVIDER;
|
|
@@ -41,12 +41,25 @@ function loadConfig() {
|
|
|
41
41
|
if (wk.models.length > 0 && !config.model) config.model = wk.models[0];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// 4.
|
|
45
|
-
if (!config.apiKey) {
|
|
46
|
-
const keyEnv = `KDNA_LLM_API_KEY_${
|
|
44
|
+
// 4. Provider-specific env var (KDNA_LLM_API_KEY_<PROVIDER>)
|
|
45
|
+
if (!config.apiKey && config.provider) {
|
|
46
|
+
const keyEnv = `KDNA_LLM_API_KEY_${config.provider.toUpperCase().replace(/-/g, '_')}`;
|
|
47
47
|
if (process.env[keyEnv]) config.apiKey = process.env[keyEnv];
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 5. Provider-native fallback — each provider only uses its own native env var
|
|
51
|
+
if (!config.apiKey && config.provider) {
|
|
52
|
+
const PROVIDER_NATIVE_KEYS = {
|
|
53
|
+
openai: 'OPENAI_API_KEY',
|
|
54
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
55
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
56
|
+
groq: 'GROQ_API_KEY',
|
|
57
|
+
gemini: 'GEMINI_API_KEY',
|
|
58
|
+
perplexity: 'PERPLEXITY_API_KEY',
|
|
59
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
60
|
+
};
|
|
61
|
+
const nativeEnv = PROVIDER_NATIVE_KEYS[config.provider];
|
|
62
|
+
if (nativeEnv && process.env[nativeEnv]) config.apiKey = process.env[nativeEnv];
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
return config;
|
|
@@ -55,7 +68,7 @@ function loadConfig() {
|
|
|
55
68
|
function saveConfig(updates) {
|
|
56
69
|
const configPath = path.join(os.homedir(), '.kdna', 'config.json');
|
|
57
70
|
let existing = {};
|
|
58
|
-
try { if (fs.existsSync(configPath)) existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
71
|
+
try { if (fs.existsSync(configPath)) existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { console.error('Error reading config file for save:', e.message); }
|
|
59
72
|
existing.llm = { ...(existing.llm || {}), ...updates };
|
|
60
73
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
61
74
|
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n');
|
package/src/llm/index.js
CHANGED
|
@@ -29,11 +29,12 @@ async function chatWithJSON(messages, options = {}) {
|
|
|
29
29
|
const result = await chat(messages, { ...options, responseFormat: { type: 'json_object' } });
|
|
30
30
|
try {
|
|
31
31
|
result.data = JSON.parse(result.content);
|
|
32
|
-
} catch {
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error('Error parsing LLM JSON response:', e.message);
|
|
33
34
|
// Try to extract JSON from markdown code blocks
|
|
34
35
|
const match = result.content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
35
36
|
if (match) {
|
|
36
|
-
try { result.data = JSON.parse(match[1].trim()); } catch {
|
|
37
|
+
try { result.data = JSON.parse(match[1].trim()); } catch (e2) { console.error('Error parsing JSON from markdown code block:', e2.message); }
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
return result;
|
package/tests/cli.test.js
DELETED
|
@@ -1,869 +0,0 @@
|
|
|
1
|
-
const assert = require('node:assert/strict');
|
|
2
|
-
const fs = require('node:fs');
|
|
3
|
-
const os = require('node:os');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { spawnSync } = require('node:child_process');
|
|
6
|
-
const test = require('node:test');
|
|
7
|
-
|
|
8
|
-
let kdnaCore = null;
|
|
9
|
-
try { kdnaCore = require('@aikdna/kdna-core'); } catch { /* optional — runtime digest cross-verification */ }
|
|
10
|
-
|
|
11
|
-
const root = path.resolve(__dirname, '..');
|
|
12
|
-
const cli = path.join(root, 'bin', 'kdna-studio.js');
|
|
13
|
-
|
|
14
|
-
function run(args, options = {}) {
|
|
15
|
-
return spawnSync(process.execPath, [cli, ...args], {
|
|
16
|
-
cwd: options.cwd || root,
|
|
17
|
-
env: {
|
|
18
|
-
...process.env,
|
|
19
|
-
KDNA_IDENTITY_DIR: path.join(options.tmp || os.tmpdir(), 'no-identity'),
|
|
20
|
-
...(options.env || {}),
|
|
21
|
-
},
|
|
22
|
-
encoding: 'utf8',
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function tmpDir() {
|
|
27
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-studio-cli-'));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function assertCanonicalRuntimeContainer(outFile) {
|
|
31
|
-
const layout = kdnaCore.readV1Layout(outFile);
|
|
32
|
-
const names = (layout.entries || []).map((entry) => entry.name).sort();
|
|
33
|
-
assert.deepEqual(names, ['checksums.json', 'kdna.json', 'mimetype', 'payload.kdnab']);
|
|
34
|
-
assert.equal(names.includes('KDNA_Core.json'), false);
|
|
35
|
-
assert.equal(names.includes('KDNA_Patterns.json'), false);
|
|
36
|
-
return layout;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function createLockedProject(t) {
|
|
40
|
-
const tmp = tmpDir();
|
|
41
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
42
|
-
const projectDir = path.join(tmp, 'project');
|
|
43
|
-
let result = run(['create', projectDir, '--name', '@test/example'], { tmp });
|
|
44
|
-
assert.equal(result.status, 0, result.stderr);
|
|
45
|
-
|
|
46
|
-
const evidencePath = path.join(tmp, 'evidence.md');
|
|
47
|
-
fs.writeFileSync(evidencePath, 'Evidence that specific claims should outrank broad claims.');
|
|
48
|
-
result = run(['import', projectDir, evidencePath], { tmp });
|
|
49
|
-
assert.equal(result.status, 0, result.stderr);
|
|
50
|
-
|
|
51
|
-
result = run([
|
|
52
|
-
'card',
|
|
53
|
-
'add',
|
|
54
|
-
projectDir,
|
|
55
|
-
'axiom',
|
|
56
|
-
'--field',
|
|
57
|
-
'one_sentence=Prefer specific evidence over broad claims',
|
|
58
|
-
'--field',
|
|
59
|
-
'full_statement=When evaluating a claim the agent must weigh the specificity of the supporting evidence. A narrow claim backed by concrete data should be preferred over a broad claim supported only by general principles.',
|
|
60
|
-
'--field',
|
|
61
|
-
'why=Without this axiom the agent may accept broad plausible-sounding claims as equally credible as narrow evidence-backed ones leading to false balance.',
|
|
62
|
-
'--field',
|
|
63
|
-
'applies_when=["reviewing content"]',
|
|
64
|
-
'--field',
|
|
65
|
-
'does_not_apply_when=["pure formatting"]',
|
|
66
|
-
'--field',
|
|
67
|
-
'failure_risk=generic advice',
|
|
68
|
-
'--field',
|
|
69
|
-
'confidence=high',
|
|
70
|
-
'--field',
|
|
71
|
-
'evidence_type=practice',
|
|
72
|
-
], { tmp });
|
|
73
|
-
assert.equal(result.status, 0, result.stderr);
|
|
74
|
-
const cardId = result.stdout.match(/Added card: (\S+)/)?.[1];
|
|
75
|
-
assert.ok(cardId, result.stdout);
|
|
76
|
-
|
|
77
|
-
result = run([
|
|
78
|
-
'card',
|
|
79
|
-
'approve',
|
|
80
|
-
projectDir,
|
|
81
|
-
cardId,
|
|
82
|
-
'--by',
|
|
83
|
-
'expert',
|
|
84
|
-
'--statement',
|
|
85
|
-
'I confirm this judgment.',
|
|
86
|
-
], { tmp });
|
|
87
|
-
assert.equal(result.status, 0, result.stderr);
|
|
88
|
-
|
|
89
|
-
return { tmp, projectDir, cardId };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
test('prints help', () => {
|
|
93
|
-
const result = run(['--help']);
|
|
94
|
-
assert.equal(result.status, 0);
|
|
95
|
-
assert.match(result.stdout, /kdna-studio/);
|
|
96
|
-
assert.match(result.stdout, /export <project>/);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('rejects unknown commands with input error', () => {
|
|
100
|
-
const result = run(['unknown']);
|
|
101
|
-
assert.equal(result.status, 2);
|
|
102
|
-
assert.match(result.stderr, /Unknown command/);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('card approve --all locks every unlocked card', (t) => {
|
|
106
|
-
const tmp = tmpDir();
|
|
107
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
108
|
-
const projectDir = path.join(tmp, 'project');
|
|
109
|
-
|
|
110
|
-
let result = run(['create', projectDir, '--name', '@test/approve-all'], { tmp });
|
|
111
|
-
assert.equal(result.status, 0, result.stderr);
|
|
112
|
-
|
|
113
|
-
result = run([
|
|
114
|
-
'card',
|
|
115
|
-
'add',
|
|
116
|
-
projectDir,
|
|
117
|
-
'axiom',
|
|
118
|
-
'--field',
|
|
119
|
-
'one_sentence=Prefer specific evidence over broad claims',
|
|
120
|
-
'--field',
|
|
121
|
-
'full_statement=When evaluating a claim the agent must weigh the specificity of the supporting evidence. A narrow claim backed by concrete data should be preferred over a broad claim supported only by general principles.',
|
|
122
|
-
'--field',
|
|
123
|
-
'why=Without this axiom the agent may accept broad plausible-sounding claims as equally credible as narrow evidence-backed ones leading to false balance.',
|
|
124
|
-
'--field',
|
|
125
|
-
'applies_when=["reviewing content"]',
|
|
126
|
-
'--field',
|
|
127
|
-
'does_not_apply_when=["pure formatting"]',
|
|
128
|
-
'--field',
|
|
129
|
-
'failure_risk=generic advice',
|
|
130
|
-
'--field',
|
|
131
|
-
'confidence=high',
|
|
132
|
-
'--field',
|
|
133
|
-
'evidence_type=practice',
|
|
134
|
-
], { tmp });
|
|
135
|
-
assert.equal(result.status, 0, result.stderr);
|
|
136
|
-
|
|
137
|
-
result = run([
|
|
138
|
-
'card',
|
|
139
|
-
'approve',
|
|
140
|
-
projectDir,
|
|
141
|
-
'--all',
|
|
142
|
-
'--by',
|
|
143
|
-
'expert',
|
|
144
|
-
'--statement',
|
|
145
|
-
'I confirm every unlocked card in this project.',
|
|
146
|
-
], { tmp });
|
|
147
|
-
assert.equal(result.status, 0, result.stderr);
|
|
148
|
-
assert.match(result.stdout, /Approved and Human Locked: 1 cards/);
|
|
149
|
-
|
|
150
|
-
const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
|
|
151
|
-
assert.equal(project.cards.length, 1);
|
|
152
|
-
assert.equal(project.cards[0].locked, true);
|
|
153
|
-
assert.ok(project.cards[0].human_lock);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('creates a Studio project and rejects accidental overwrite', (t) => {
|
|
157
|
-
const tmp = tmpDir();
|
|
158
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
159
|
-
const projectDir = path.join(tmp, 'project');
|
|
160
|
-
|
|
161
|
-
let result = run(['create', projectDir, '--name', '@test/example'], { tmp });
|
|
162
|
-
assert.equal(result.status, 0, result.stderr);
|
|
163
|
-
const projectPath = path.join(projectDir, 'studio.project.json');
|
|
164
|
-
assert.ok(fs.existsSync(projectPath));
|
|
165
|
-
const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
|
|
166
|
-
assert.equal(project.name, '@test/example');
|
|
167
|
-
|
|
168
|
-
result = run(['create', projectDir, '--name', '@test/example'], { tmp });
|
|
169
|
-
assert.equal(result.status, 2);
|
|
170
|
-
assert.match(result.stderr, /Directory already exists/);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test('blocks compile and export until judgment cards are Human Locked', (t) => {
|
|
174
|
-
const tmp = tmpDir();
|
|
175
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
176
|
-
const projectDir = path.join(tmp, 'project');
|
|
177
|
-
let result = run(['create', projectDir, '--name', '@test/example'], { tmp });
|
|
178
|
-
assert.equal(result.status, 0, result.stderr);
|
|
179
|
-
|
|
180
|
-
result = run([
|
|
181
|
-
'card',
|
|
182
|
-
'add',
|
|
183
|
-
projectDir,
|
|
184
|
-
'axiom',
|
|
185
|
-
'--no-strict',
|
|
186
|
-
'--field',
|
|
187
|
-
'one_sentence=Prefer specific evidence over broad claims',
|
|
188
|
-
], { tmp });
|
|
189
|
-
assert.equal(result.status, 0, result.stderr);
|
|
190
|
-
|
|
191
|
-
result = run(['lock', projectDir], { tmp });
|
|
192
|
-
assert.equal(result.status, 4);
|
|
193
|
-
assert.match(result.stderr, /Human Lock Gate blocked export/);
|
|
194
|
-
|
|
195
|
-
result = run(['compile', projectDir, '--out', path.join(tmp, 'build')], { tmp });
|
|
196
|
-
assert.equal(result.status, 4);
|
|
197
|
-
assert.match(result.stderr, /Human Lock Gate blocked compile/);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test('runs the trusted authoring workflow through compile and export', (t) => {
|
|
201
|
-
const { tmp, projectDir, cardId } = createLockedProject(t);
|
|
202
|
-
|
|
203
|
-
let result = run(['card', 'list', projectDir], { tmp });
|
|
204
|
-
assert.equal(result.status, 0, result.stderr);
|
|
205
|
-
assert.match(result.stdout, new RegExp(`${cardId}\\s+axiom\\s+locked\\s+locked`));
|
|
206
|
-
|
|
207
|
-
result = run(['lock', projectDir], { tmp });
|
|
208
|
-
assert.equal(result.status, 0, result.stderr);
|
|
209
|
-
assert.match(result.stdout, /Human Lock Gate passed/);
|
|
210
|
-
|
|
211
|
-
const buildDir = path.join(tmp, 'build');
|
|
212
|
-
result = run(['compile', projectDir, '--out', buildDir], { tmp });
|
|
213
|
-
assert.equal(result.status, 0, result.stderr);
|
|
214
|
-
assert.ok(fs.existsSync(path.join(buildDir, 'kdna.json')));
|
|
215
|
-
assert.ok(fs.existsSync(path.join(buildDir, 'KDNA_CARD.json')));
|
|
216
|
-
assert.ok(fs.existsSync(path.join(buildDir, 'reports', 'provenance-report.json')));
|
|
217
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(buildDir, 'kdna.json'), 'utf8'));
|
|
218
|
-
assert.equal(manifest.authoring.compiler, '@aikdna/kdna-studio-core');
|
|
219
|
-
assert.equal(manifest.authoring.human_confirmed, true);
|
|
220
|
-
assert.ok(manifest.authoring.human_lock_count >= 1);
|
|
221
|
-
|
|
222
|
-
// Content digest must be present and non-trivial
|
|
223
|
-
assert.ok(manifest.content_digest, 'manifest must have content_digest');
|
|
224
|
-
assert.match(manifest.content_digest, /^sha256:[0-9a-f]{64}$/);
|
|
225
|
-
assert.ok(manifest.authoring.content_digest, 'authoring must have content_digest');
|
|
226
|
-
|
|
227
|
-
const outFile = path.join(tmp, 'dist', 'example.kdna');
|
|
228
|
-
result = run(['export', projectDir, '--out', outFile], { tmp });
|
|
229
|
-
assert.equal(result.status, 0, result.stderr);
|
|
230
|
-
assert.ok(fs.existsSync(outFile));
|
|
231
|
-
const zipMagic = fs.readFileSync(outFile).subarray(0, 4).toString('hex');
|
|
232
|
-
assert.equal(zipMagic, '504b0304');
|
|
233
|
-
const receipt = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'build-receipt.json'), 'utf8'));
|
|
234
|
-
assert.equal(receipt.signature_status, 'unsigned');
|
|
235
|
-
assert.match(receipt.asset_digest, /^sha256:/);
|
|
236
|
-
|
|
237
|
-
// ── Digest chain consistency ──────────────────────────────────
|
|
238
|
-
// All three sources MUST agree on content_digest.
|
|
239
|
-
const exportedManifest = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'kdna.json'), 'utf8'));
|
|
240
|
-
const exportedProvenance = JSON.parse(fs.readFileSync(path.join(tmp, 'dist', 'provenance-report.json'), 'utf8'));
|
|
241
|
-
assert.equal(exportedManifest.content_digest, receipt.content_digest,
|
|
242
|
-
'manifest.content_digest !== build-receipt.content_digest');
|
|
243
|
-
assert.equal(exportedProvenance.content_digest, receipt.content_digest,
|
|
244
|
-
'provenance-report.content_digest !== build-receipt.content_digest');
|
|
245
|
-
assert.ok(exportedManifest.content_digest.startsWith('sha256:'),
|
|
246
|
-
'content_digest must be sha256: prefix');
|
|
247
|
-
assert.equal(exportedManifest.content_digest.length, 71,
|
|
248
|
-
'content_digest must be sha256: + 64 hex chars');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test('refuses signing without runtime identity keys', (t) => {
|
|
252
|
-
const { tmp, projectDir } = createLockedProject(t);
|
|
253
|
-
const outFile = path.join(tmp, 'dist', 'signed.kdna');
|
|
254
|
-
const result = run(['export', projectDir, '--out', outFile, '--sign'], { tmp });
|
|
255
|
-
assert.equal(result.status, 5);
|
|
256
|
-
assert.match(result.stderr, /Signing requires KDNA identity keys/);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
// ── Identity ────────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
test('identity init creates local creator identity', (t) => {
|
|
262
|
-
const tmp = tmpDir();
|
|
263
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
264
|
-
const identityDir = path.join(tmp, 'identity');
|
|
265
|
-
|
|
266
|
-
const result = run(['identity', 'init', '--name', 'Test Creator'], {
|
|
267
|
-
tmp,
|
|
268
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
269
|
-
});
|
|
270
|
-
assert.equal(result.status, 0, result.stderr);
|
|
271
|
-
assert.match(result.stdout, /Creator identity initialized/);
|
|
272
|
-
assert.match(result.stdout, /kdna:creator:ed25519:/);
|
|
273
|
-
|
|
274
|
-
// Verify files were created
|
|
275
|
-
assert.ok(fs.existsSync(path.join(identityDir, 'kdna.key')));
|
|
276
|
-
assert.ok(fs.existsSync(path.join(identityDir, 'kdna.pub')));
|
|
277
|
-
assert.ok(fs.existsSync(path.join(identityDir, 'creator.json')));
|
|
278
|
-
const creatorJson = JSON.parse(fs.readFileSync(path.join(identityDir, 'creator.json'), 'utf8'));
|
|
279
|
-
assert.match(creatorJson.creator_id, /^kdna:creator:ed25519:/);
|
|
280
|
-
assert.equal(creatorJson.display_name, 'Test Creator');
|
|
281
|
-
assert.equal(creatorJson.verified, false);
|
|
282
|
-
|
|
283
|
-
// Double init should fail
|
|
284
|
-
const result2 = run(['identity', 'init', '--name', 'Duplicate'], {
|
|
285
|
-
tmp,
|
|
286
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
287
|
-
});
|
|
288
|
-
assert.equal(result2.status, 2);
|
|
289
|
-
assert.match(result2.stderr, /already exists/);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test('identity show displays current identity', (t) => {
|
|
293
|
-
const tmp = tmpDir();
|
|
294
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
295
|
-
const identityDir = path.join(tmp, 'identity');
|
|
296
|
-
|
|
297
|
-
// Init first
|
|
298
|
-
run(['identity', 'init', '--name', 'Test User'], {
|
|
299
|
-
tmp,
|
|
300
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
const result = run(['identity', 'show'], {
|
|
304
|
-
tmp,
|
|
305
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
306
|
-
});
|
|
307
|
-
assert.equal(result.status, 0, result.stderr);
|
|
308
|
-
const info = JSON.parse(result.stdout);
|
|
309
|
-
assert.match(info.creator_id, /^kdna:creator:ed25519:/);
|
|
310
|
-
assert.equal(info.display_name, 'Test User');
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test('identity show fails when not initialized', (t) => {
|
|
314
|
-
const tmp = tmpDir();
|
|
315
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
316
|
-
|
|
317
|
-
const result = run(['identity', 'show'], {
|
|
318
|
-
tmp,
|
|
319
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: path.join(tmp, 'no-such') },
|
|
320
|
-
});
|
|
321
|
-
assert.equal(result.status, 2);
|
|
322
|
-
assert.match(result.stderr, /No identity found/);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
// ── Create from folder ──────────────────────────────────────────────
|
|
326
|
-
|
|
327
|
-
test('create --from-folder imports legacy JSON source, outputs audit', (t) => {
|
|
328
|
-
const tmp = tmpDir();
|
|
329
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
330
|
-
|
|
331
|
-
const sourceDir = path.join(tmp, 'legacy-source');
|
|
332
|
-
fs.mkdirSync(sourceDir, { recursive: true });
|
|
333
|
-
|
|
334
|
-
// Create legacy KDNA files
|
|
335
|
-
const core = {
|
|
336
|
-
meta: { domain: 'test', version: '0.1.0', created: '2026-01-01', purpose: 'test', load_condition: 'always' },
|
|
337
|
-
axioms: [{
|
|
338
|
-
id: 'ax_legacy',
|
|
339
|
-
one_sentence: 'Test axiom from legacy folder.',
|
|
340
|
-
full_statement: 'A complete testable explanation from a legacy source folder.',
|
|
341
|
-
why: 'Without this legacy axiom, agent judgment defaults to untrained behavior.',
|
|
342
|
-
applies_when: ['testing legacy import'],
|
|
343
|
-
does_not_apply_when: ['production'],
|
|
344
|
-
failure_risk: 'May produce incorrect results from untrained judgment.',
|
|
345
|
-
}],
|
|
346
|
-
ontology: [{ id: 'ont_legacy', one_sentence: 'Legacy concept.', essence: 'Operational meaning.', boundary: 'Not the opposite.', trigger_signal: 'keyword' }],
|
|
347
|
-
boundaries: [{ id: 'bnd_legacy', scope: 'Legacy scope.', out_of_scope: 'Not covered by legacy.', acceptable_exceptions: [] }],
|
|
348
|
-
stances: [],
|
|
349
|
-
};
|
|
350
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Core.json'), JSON.stringify(core));
|
|
351
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Patterns.json'), JSON.stringify({
|
|
352
|
-
meta: { domain: 'test', version: '0.1.0', created: '2026-01-01', purpose: 'patterns', load_condition: 'always' },
|
|
353
|
-
misunderstandings: [{ id: 'ms_legacy', wrong: 'Legacy wrong belief.', correct: 'Correct understanding.', key_distinction: 'The key distinction between wrong and correct.' }],
|
|
354
|
-
self_check: ['Did I verify the legacy import?'],
|
|
355
|
-
}));
|
|
356
|
-
|
|
357
|
-
const projectDir = path.join(tmp, 'project');
|
|
358
|
-
const result = run(['create', projectDir, '--from-folder', sourceDir, '--name', '@test/legacy-import'], { tmp });
|
|
359
|
-
assert.equal(result.status, 0, result.stderr);
|
|
360
|
-
|
|
361
|
-
const output = JSON.parse(result.stdout);
|
|
362
|
-
assert.ok(output.audit, 'must have audit info');
|
|
363
|
-
assert.ok(output.audit.filesFound.includes('KDNA_Core.json'));
|
|
364
|
-
assert.ok(output.audit.filesFound.includes('KDNA_Patterns.json'));
|
|
365
|
-
assert.ok(output.imported > 0, `imported cards: ${output.imported}`);
|
|
366
|
-
assert.equal(output.source_mode, 'source_folder');
|
|
367
|
-
|
|
368
|
-
// Read project and verify cards were imported
|
|
369
|
-
const projectPath = path.join(projectDir, 'studio.project.json');
|
|
370
|
-
assert.ok(fs.existsSync(projectPath));
|
|
371
|
-
const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
|
|
372
|
-
assert.equal(project.source_mode, 'source_folder');
|
|
373
|
-
assert.ok(project.cards.length > 0);
|
|
374
|
-
assert.equal(project.lineage.type, 'migrated');
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test('migrate --format v1 exports canonical runtime payload without source entries', (t) => {
|
|
378
|
-
assert.ok(kdnaCore, '@aikdna/kdna-core is required for v1 export verification');
|
|
379
|
-
const tmp = tmpDir();
|
|
380
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
381
|
-
|
|
382
|
-
const sourceDir = path.join(tmp, 'legacy-source');
|
|
383
|
-
fs.mkdirSync(sourceDir, { recursive: true });
|
|
384
|
-
fs.writeFileSync(path.join(sourceDir, 'kdna.json'), JSON.stringify({
|
|
385
|
-
name: '@test/legacy-import',
|
|
386
|
-
version: '0.7.3',
|
|
387
|
-
judgment_version: '2026.05',
|
|
388
|
-
asset_type: 'domain_judgment',
|
|
389
|
-
description: 'Legacy import fixture.',
|
|
390
|
-
core_insight: 'Preserve real judgment structure.',
|
|
391
|
-
author: { name: 'Test Author', id: 'test-author' },
|
|
392
|
-
license: { type: 'CC-BY-4.0' },
|
|
393
|
-
keywords: ['legacy', 'v1'],
|
|
394
|
-
languages: ['en'],
|
|
395
|
-
default_language: 'en',
|
|
396
|
-
quality_badge: 'tested',
|
|
397
|
-
registry: { repo: 'https://example.invalid/legacy' },
|
|
398
|
-
}));
|
|
399
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Core.json'), JSON.stringify({
|
|
400
|
-
axioms: [{
|
|
401
|
-
id: 'ax_legacy',
|
|
402
|
-
one_sentence: 'Prefer specific evidence over broad claims.',
|
|
403
|
-
full_statement: 'When evaluating a claim the agent must preserve specific evidence and not collapse it into generic advice.',
|
|
404
|
-
why: 'Without this judgment the agent produces plausible but weak summaries.',
|
|
405
|
-
applies_when: ['reviewing content'],
|
|
406
|
-
does_not_apply_when: ['pure formatting'],
|
|
407
|
-
failure_risk: 'May over-diagnose simple copy edits.',
|
|
408
|
-
}],
|
|
409
|
-
ontology: [{ id: 'on_legacy', essence: 'Specificity anchor', boundary: 'Not generic polish', trigger_signal: 'abstract claim' }],
|
|
410
|
-
frameworks: [{ id: 'fw_legacy', name: 'Diagnose before rewrite', when_to_use: 'reviewing drafts', steps: ['diagnose', 'rewrite'] }],
|
|
411
|
-
stances: [{ id: 'st_legacy', stance: 'Structure first', applies_when: ['draft review'], does_not_apply_when: ['grammar only'] }],
|
|
412
|
-
}));
|
|
413
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Patterns.json'), JSON.stringify({
|
|
414
|
-
terminology: {
|
|
415
|
-
standard_terms: [{ term: 'specificity anchor', definition: 'A concrete detail.' }],
|
|
416
|
-
banned_terms: [{ term: 'make it better', why: 'Non-diagnostic.', replace_with: 'name the structural issue' }],
|
|
417
|
-
},
|
|
418
|
-
misunderstandings: [{ wrong: 'Smooth means good.', correct: 'Smooth can hide weak thinking.', key_distinction: 'surface vs structure', why: 'Prevents false praise.' }],
|
|
419
|
-
self_check: ['Did I preserve the structural judgment?'],
|
|
420
|
-
}));
|
|
421
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Scenarios.json'), JSON.stringify({ scenes: [{ id: 'sc_legacy', name: 'Draft review', trigger: 'user asks for review', action: 'diagnose first' }] }));
|
|
422
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Cases.json'), JSON.stringify({ cases: [{ id: 'ca_legacy', title: 'Smooth but empty', scenario: 'draft reads well', expected: 'flag missing claim' }] }));
|
|
423
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Reasoning.json'), JSON.stringify({ reasoning_chains: [{ id: 're_legacy', name: 'structure-first', conclusion: 'diagnose structure first', so_what: 'avoid surface polish' }] }));
|
|
424
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Evolution.json'), JSON.stringify({ stages: [{ id: 'ev_legacy', name: 'Structural reviewer', description: 'Moves beyond surface editing.' }] }));
|
|
425
|
-
|
|
426
|
-
const outFile = path.join(tmp, 'legacy.kdna');
|
|
427
|
-
const result = run([
|
|
428
|
-
'migrate', sourceDir,
|
|
429
|
-
'--out', outFile,
|
|
430
|
-
'--name', '@test/legacy-import',
|
|
431
|
-
'--by', 'tester',
|
|
432
|
-
'--statement', 'I confirm this v1 migration fixture.',
|
|
433
|
-
'--format', 'v1',
|
|
434
|
-
], { tmp });
|
|
435
|
-
assert.equal(result.status, 0, result.stderr);
|
|
436
|
-
|
|
437
|
-
const validation = kdnaCore.validate(outFile);
|
|
438
|
-
assert.equal(validation.overall_valid, true, JSON.stringify(validation.problems));
|
|
439
|
-
const inspected = kdnaCore.inspect(outFile);
|
|
440
|
-
assert.equal(inspected.asset_id, 'kdna:test:legacy-import');
|
|
441
|
-
assert.equal(inspected.checksums_present, true);
|
|
442
|
-
const layout = assertCanonicalRuntimeContainer(outFile);
|
|
443
|
-
assert.equal(layout.manifest.lineage.type, 'adaptation');
|
|
444
|
-
assert.equal(layout.manifest.lineage.source_lineage_type, 'migrated');
|
|
445
|
-
|
|
446
|
-
const full = kdnaCore.loadV1(outFile, { profile: 'full', as: 'json' }).content;
|
|
447
|
-
assert.equal(full.manifest.creator.name, 'Test Author');
|
|
448
|
-
assert.equal(full.manifest.version, '0.7.3');
|
|
449
|
-
assert.equal(full.manifest.judgment_version, '2026.05.0');
|
|
450
|
-
assert.equal(full.payload.profile, 'judgment-profile-v1');
|
|
451
|
-
assert.equal(full.payload.core.axioms.length, 1);
|
|
452
|
-
assert.equal(full.payload.scenarios.length, 1);
|
|
453
|
-
assert.equal(full.payload.cases.length, 1);
|
|
454
|
-
assert.equal(full.payload.reasoning.self_checks.length, 1);
|
|
455
|
-
assert.equal(full.payload.reasoning.failure_modes.length, 1);
|
|
456
|
-
assert.ok(full.payload.evolution.stages.length >= 1);
|
|
457
|
-
assert.equal(Object.hasOwn(full.payload, 'source_cards'), false);
|
|
458
|
-
assert.equal(JSON.stringify(full).includes('quality_badge'), false);
|
|
459
|
-
assert.equal(JSON.stringify(full).includes('registry'), false);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
test('export --format v1 exports a Studio project as a valid non-empty v1 asset', (t) => {
|
|
463
|
-
assert.ok(kdnaCore, '@aikdna/kdna-core is required for v1 export verification');
|
|
464
|
-
const { tmp, projectDir } = createLockedProject(t);
|
|
465
|
-
const outFile = path.join(tmp, 'project-v1.kdna');
|
|
466
|
-
|
|
467
|
-
const result = run(['export', projectDir, '--format', 'v1', '--out', outFile], { tmp });
|
|
468
|
-
assert.equal(result.status, 0, result.stderr);
|
|
469
|
-
assert.match(result.stdout, /Exported \(v1\)/);
|
|
470
|
-
|
|
471
|
-
const validation = kdnaCore.validate(outFile);
|
|
472
|
-
assert.equal(validation.overall_valid, true, JSON.stringify(validation.problems));
|
|
473
|
-
assertCanonicalRuntimeContainer(outFile);
|
|
474
|
-
const loaded = kdnaCore.loadV1(outFile, { profile: 'full', as: 'json' }).content;
|
|
475
|
-
assert.equal(loaded.payload.core.axioms.length, 1);
|
|
476
|
-
assert.equal(Object.hasOwn(loaded.payload, 'source_cards'), false);
|
|
477
|
-
assert.equal(loaded.manifest.payload.path, 'payload.kdnab');
|
|
478
|
-
assert.equal(loaded.manifest.payload.encrypted, false);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
test('migrate --format v1 accepts an existing Studio project without dropping cards', (t) => {
|
|
482
|
-
assert.ok(kdnaCore, '@aikdna/kdna-core is required for v1 export verification');
|
|
483
|
-
const { tmp, projectDir } = createLockedProject(t);
|
|
484
|
-
const outFile = path.join(tmp, 'project-migrated-v1.kdna');
|
|
485
|
-
|
|
486
|
-
const result = run([
|
|
487
|
-
'migrate', projectDir,
|
|
488
|
-
'--format', 'v1',
|
|
489
|
-
'--out', outFile,
|
|
490
|
-
'--by', 'tester',
|
|
491
|
-
'--statement', 'I confirm this project v1 migration.',
|
|
492
|
-
], { tmp });
|
|
493
|
-
assert.equal(result.status, 0, result.stderr);
|
|
494
|
-
assert.match(result.stdout, /Cards: 1/);
|
|
495
|
-
|
|
496
|
-
const validation = kdnaCore.validate(outFile);
|
|
497
|
-
assert.equal(validation.overall_valid, true, JSON.stringify(validation.problems));
|
|
498
|
-
assertCanonicalRuntimeContainer(outFile);
|
|
499
|
-
const loaded = kdnaCore.loadV1(outFile, { profile: 'full', as: 'json' }).content;
|
|
500
|
-
assert.equal(loaded.payload.core.axioms.length, 1);
|
|
501
|
-
assert.equal(Object.hasOwn(loaded.payload, 'source_cards'), false);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// ── Create from KDNA ────────────────────────────────────────────────
|
|
505
|
-
|
|
506
|
-
test('create --from-kdna forks an existing .kdna asset', (t) => {
|
|
507
|
-
const tmp = tmpDir();
|
|
508
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
509
|
-
|
|
510
|
-
// First create and export a "parent" project
|
|
511
|
-
const parentTmp = tmpDir();
|
|
512
|
-
t.after(() => fs.rmSync(parentTmp, { recursive: true, force: true }));
|
|
513
|
-
const { projectDir: parentDir } = createLockedProject(t);
|
|
514
|
-
const parentDist = path.join(parentTmp, 'dist');
|
|
515
|
-
fs.mkdirSync(parentDist, { recursive: true });
|
|
516
|
-
const parentKdna = path.join(parentDist, 'parent.kdna');
|
|
517
|
-
run(['export', parentDir, '--out', parentKdna], { tmp: parentTmp });
|
|
518
|
-
|
|
519
|
-
// Now fork from that .kdna
|
|
520
|
-
const projectDir = path.join(tmp, 'forked-project');
|
|
521
|
-
const result = run(['create', projectDir, '--from-kdna', parentKdna, '--name', '@test/fork'], { tmp });
|
|
522
|
-
assert.equal(result.status, 0, result.stderr);
|
|
523
|
-
assert.match(result.stdout, /Created Studio project/);
|
|
524
|
-
assert.match(result.stdout, /kdna_asset/);
|
|
525
|
-
|
|
526
|
-
const projectPath = path.join(projectDir, 'studio.project.json');
|
|
527
|
-
assert.ok(fs.existsSync(projectPath));
|
|
528
|
-
const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
|
|
529
|
-
assert.equal(project.source_mode, 'kdna_asset');
|
|
530
|
-
assert.ok(project.lineage, 'must have lineage');
|
|
531
|
-
assert.equal(project.lineage.type, 'fork');
|
|
532
|
-
assert.ok(project.lineage.parent_name, 'must record parent name');
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
// ── Card approve --sign ─────────────────────────────────────────────
|
|
536
|
-
|
|
537
|
-
test('card approve --sign binds Human Lock to creator identity', (t) => {
|
|
538
|
-
const tmp = tmpDir();
|
|
539
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
540
|
-
const identityDir = path.join(tmp, 'identity');
|
|
541
|
-
|
|
542
|
-
// Initialize creator identity
|
|
543
|
-
const init = run(['identity', 'init', '--name', 'Signer'], {
|
|
544
|
-
tmp,
|
|
545
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
546
|
-
});
|
|
547
|
-
assert.equal(init.status, 0, init.stderr);
|
|
548
|
-
|
|
549
|
-
// Create project with an axiom
|
|
550
|
-
const projectDir = path.join(tmp, 'project');
|
|
551
|
-
let result = run(['create', projectDir, '--name', '@test/signed'], { tmp });
|
|
552
|
-
assert.equal(result.status, 0, result.stderr);
|
|
553
|
-
|
|
554
|
-
result = run([
|
|
555
|
-
'card', 'add', projectDir, 'axiom',
|
|
556
|
-
'--field', 'one_sentence=Prefer specific evidence over broad claims',
|
|
557
|
-
'--field', 'full_statement=When evaluating a claim the agent must weigh the specificity of the supporting evidence. A narrow claim backed by concrete data should be preferred.',
|
|
558
|
-
'--field', 'why=Without this axiom the agent may accept broad plausible-sounding claims as equally credible as narrow evidence-backed ones.',
|
|
559
|
-
'--field', 'applies_when=["reviewing content"]',
|
|
560
|
-
'--field', 'does_not_apply_when=["pure formatting"]',
|
|
561
|
-
'--field', 'failure_risk=generic advice',
|
|
562
|
-
'--field', 'confidence=high',
|
|
563
|
-
'--field', 'evidence_type=practice',
|
|
564
|
-
], { tmp });
|
|
565
|
-
assert.equal(result.status, 0, result.stderr);
|
|
566
|
-
const cardId = result.stdout.match(/Added card: (\S+)/)?.[1];
|
|
567
|
-
|
|
568
|
-
// Approve with --sign
|
|
569
|
-
result = run([
|
|
570
|
-
'card', 'approve', projectDir, cardId,
|
|
571
|
-
'--by', 'signer',
|
|
572
|
-
'--statement', 'I confirm this judgment with my creator identity.',
|
|
573
|
-
'--sign',
|
|
574
|
-
], {
|
|
575
|
-
tmp,
|
|
576
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
577
|
-
});
|
|
578
|
-
assert.equal(result.status, 0, result.stderr);
|
|
579
|
-
assert.match(result.stdout, /signed/);
|
|
580
|
-
|
|
581
|
-
// Verify human_lock has creator_id and signature
|
|
582
|
-
const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
|
|
583
|
-
const lockedCard = project.cards.find(c => c.id === cardId);
|
|
584
|
-
assert.ok(lockedCard.human_lock, 'must have human_lock');
|
|
585
|
-
assert.ok(lockedCard.human_lock.creator_id, 'must have creator_id');
|
|
586
|
-
assert.match(lockedCard.human_lock.creator_id, /^kdna:creator:ed25519:/);
|
|
587
|
-
assert.ok(lockedCard.human_lock.signature, 'must have signature');
|
|
588
|
-
assert.match(lockedCard.human_lock.signature, /^ed25519:/);
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
// ── Blank create with default source_mode ───────────────────────────
|
|
592
|
-
|
|
593
|
-
test('create (blank) sets source_mode=blank by default', (t) => {
|
|
594
|
-
const tmp = tmpDir();
|
|
595
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
596
|
-
const projectDir = path.join(tmp, 'project');
|
|
597
|
-
|
|
598
|
-
const result = run(['create', projectDir, '--name', '@test/blank'], { tmp });
|
|
599
|
-
assert.equal(result.status, 0, result.stderr);
|
|
600
|
-
|
|
601
|
-
const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
|
|
602
|
-
assert.equal(project.source_mode, 'blank');
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// ── export includes creator/lineage in manifest ─────────────────────
|
|
606
|
-
|
|
607
|
-
test('export includes creator and lineage in kdna.json', (t) => {
|
|
608
|
-
const tmp = tmpDir();
|
|
609
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
610
|
-
const identityDir = path.join(tmp, 'identity');
|
|
611
|
-
|
|
612
|
-
// Init identity
|
|
613
|
-
run(['identity', 'init', '--name', 'Exporter'], {
|
|
614
|
-
tmp,
|
|
615
|
-
env: { ...process.env, KDNA_IDENTITY_DIR: identityDir },
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Create, add axiom, approve
|
|
619
|
-
const projectDir = path.join(tmp, 'project');
|
|
620
|
-
run(['create', projectDir, '--name', '@test/exported'], { tmp });
|
|
621
|
-
run([
|
|
622
|
-
'card', 'add', projectDir, 'axiom',
|
|
623
|
-
'--field', 'one_sentence=Test axiom for lineage.',
|
|
624
|
-
'--field', 'full_statement=This is a complete testable explanation for the agent to apply in judgment scenarios.',
|
|
625
|
-
'--field', 'why=Without this axiom the agent would make incorrect judgment calls.',
|
|
626
|
-
'--field', 'applies_when=["testing"]',
|
|
627
|
-
'--field', 'does_not_apply_when=["not testing"]',
|
|
628
|
-
'--field', 'failure_risk=test may pass incorrectly',
|
|
629
|
-
'--field', 'confidence=high',
|
|
630
|
-
'--field', 'evidence_type=practice',
|
|
631
|
-
], { tmp });
|
|
632
|
-
let result = run(['card', 'list', projectDir], { tmp });
|
|
633
|
-
const cardId = result.stdout.match(/(\S+)\s+axiom/)?.[1];
|
|
634
|
-
run(['card', 'approve', projectDir, cardId, '--by', 'tester', '--statement', 'Verified.'], { tmp });
|
|
635
|
-
run(['lock', projectDir], { tmp });
|
|
636
|
-
|
|
637
|
-
const distDir = path.join(tmp, 'dist');
|
|
638
|
-
const outFile = path.join(distDir, 'exported.kdna');
|
|
639
|
-
result = run(['export', projectDir, '--out', outFile], { tmp });
|
|
640
|
-
assert.equal(result.status, 0, result.stderr);
|
|
641
|
-
|
|
642
|
-
// Check exported kdna.json has new fields
|
|
643
|
-
const exportedManifest = JSON.parse(fs.readFileSync(path.join(distDir, 'kdna.json'), 'utf8'));
|
|
644
|
-
assert.equal(exportedManifest.authoring.source_mode, 'blank');
|
|
645
|
-
assert.ok(exportedManifest.lineage, 'must have lineage');
|
|
646
|
-
assert.equal(exportedManifest.lineage.type, 'original');
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
// ── E2E: Blank workflow with runtime content_digest check ──────────
|
|
650
|
-
|
|
651
|
-
test('E2E blank: create → approve → lock → export → runtime digest match', (t) => {
|
|
652
|
-
const tmp = tmpDir();
|
|
653
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
654
|
-
|
|
655
|
-
const projectDir = path.join(tmp, 'project');
|
|
656
|
-
let result = run(['create', projectDir, '--name', '@test/e2e-blank'], { tmp });
|
|
657
|
-
assert.equal(result.status, 0, result.stderr);
|
|
658
|
-
|
|
659
|
-
// Add axiom with full fields
|
|
660
|
-
result = run([
|
|
661
|
-
'card', 'add', projectDir, 'axiom',
|
|
662
|
-
'--field', 'one_sentence=E2E blank axiom.',
|
|
663
|
-
'--field', 'full_statement=A complete testable explanation for the end-to-end blank workflow test.',
|
|
664
|
-
'--field', 'why=Without this axiom the end-to-end test would not validate blank creation.',
|
|
665
|
-
'--field', 'applies_when=["e2e testing"]',
|
|
666
|
-
'--field', 'does_not_apply_when=["production"]',
|
|
667
|
-
'--field', 'failure_risk=e2e test may incorrectly pass',
|
|
668
|
-
'--field', 'confidence=high',
|
|
669
|
-
'--field', 'evidence_type=practice',
|
|
670
|
-
], { tmp });
|
|
671
|
-
assert.equal(result.status, 0, result.stderr);
|
|
672
|
-
const cardId = result.stdout.match(/Added card: (\S+)/)?.[1];
|
|
673
|
-
|
|
674
|
-
// Add a misunderstanding
|
|
675
|
-
result = run([
|
|
676
|
-
'card', 'add', projectDir, 'misunderstanding',
|
|
677
|
-
'--field', 'wrong=E2E tests are unnecessary.',
|
|
678
|
-
'--field', 'correct=E2E tests catch integration issues not visible in unit tests.',
|
|
679
|
-
'--field', 'key_distinction=E2E tests validate the full pipeline, not individual functions.',
|
|
680
|
-
'--field', 'why=E2E test failures are hard to debug without a full integration test running.',
|
|
681
|
-
], { tmp });
|
|
682
|
-
assert.equal(result.status, 0, result.stderr);
|
|
683
|
-
|
|
684
|
-
// Approve all cards
|
|
685
|
-
let cards = run(['card', 'list', projectDir], { tmp });
|
|
686
|
-
for (const line of cards.stdout.trim().split('\n')) {
|
|
687
|
-
const id = line.split(/\s+/)[0];
|
|
688
|
-
result = run(['card', 'approve', projectDir, id, '--by', 'tester', '--statement', 'Confirmed.'], { tmp });
|
|
689
|
-
assert.equal(result.status, 0, result.stderr);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Lock and export
|
|
693
|
-
result = run(['lock', projectDir], { tmp });
|
|
694
|
-
assert.equal(result.status, 0, result.stderr);
|
|
695
|
-
|
|
696
|
-
const outFile = path.join(tmp, 'blank.kdna');
|
|
697
|
-
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
698
|
-
result = run(['export', projectDir, '--out', outFile], { tmp });
|
|
699
|
-
assert.equal(result.status, 0, result.stderr);
|
|
700
|
-
|
|
701
|
-
// ── Verify: source_mode, lineage, digest chain ────────────
|
|
702
|
-
const distDir = path.dirname(outFile);
|
|
703
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(distDir, 'kdna.json'), 'utf8'));
|
|
704
|
-
const receipt = JSON.parse(fs.readFileSync(path.join(distDir, 'build-receipt.json'), 'utf8'));
|
|
705
|
-
const provenance = JSON.parse(fs.readFileSync(path.join(distDir, 'provenance-report.json'), 'utf8'));
|
|
706
|
-
|
|
707
|
-
assert.equal(manifest.authoring.source_mode, 'blank');
|
|
708
|
-
assert.equal(manifest.lineage.type, 'original');
|
|
709
|
-
assert.match(manifest.content_digest, /^sha256:[0-9a-f]{64}$/);
|
|
710
|
-
assert.equal(manifest.content_digest, receipt.content_digest, 'manifest vs receipt digest mismatch');
|
|
711
|
-
assert.equal(manifest.content_digest, provenance.content_digest, 'manifest vs provenance digest mismatch');
|
|
712
|
-
assert.equal(manifest.authoring.compiler, '@aikdna/kdna-studio-core');
|
|
713
|
-
assert.equal(manifest.authoring.human_confirmed, true);
|
|
714
|
-
|
|
715
|
-
// ── Runtime cross-verification: content digest consistency ──
|
|
716
|
-
// Compute content digest from the .kdna ZIP entries and verify it
|
|
717
|
-
// matches the manifest, receipt, and provenance report.
|
|
718
|
-
if (kdnaCore) {
|
|
719
|
-
const reader = kdnaCore.createKdnaAssetReader();
|
|
720
|
-
const asset = reader.openSync(outFile);
|
|
721
|
-
const runtimeResult = reader.verifySync(asset);
|
|
722
|
-
const runtimeDigest = runtimeResult.content_digest;
|
|
723
|
-
|
|
724
|
-
assert.ok(runtimeDigest, 'runtime content_digest must be present');
|
|
725
|
-
assert.match(runtimeDigest, /^sha256:[0-9a-f]{64}$/, 'runtime content_digest must be valid sha256');
|
|
726
|
-
|
|
727
|
-
// Read ZIP-internal manifest (avoid sidecar timing issues)
|
|
728
|
-
const zipManifest = JSON.parse(asset.readEntry('kdna.json').toString());
|
|
729
|
-
assert.equal(zipManifest.content_digest, receipt.content_digest,
|
|
730
|
-
`ZIP manifest digest != receipt digest`);
|
|
731
|
-
assert.equal(zipManifest.content_digest, provenance.content_digest,
|
|
732
|
-
`ZIP manifest digest != provenance digest`);
|
|
733
|
-
|
|
734
|
-
// Verify: runtime computed digest matches ZIP manifest
|
|
735
|
-
assert.equal(zipManifest.content_digest, runtimeDigest,
|
|
736
|
-
`ZIP manifest content_digest (${zipManifest.content_digest.slice(0, 20)}...) != runtime computed (${runtimeDigest.slice(0, 20)}...) — trust chain broken`);
|
|
737
|
-
} else {
|
|
738
|
-
console.log(' (kdna-core not available, skipping runtime assetReader cross-verification)');
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// ── E2E: Fork workflow ────────────────────────────────────────────
|
|
743
|
-
|
|
744
|
-
test('E2E fork: parent → fork → approve → export → lineage check', (t) => {
|
|
745
|
-
const tmp = tmpDir();
|
|
746
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
747
|
-
|
|
748
|
-
// Step 1: Create and export a parent domain
|
|
749
|
-
const parentDir = path.join(tmp, 'parent');
|
|
750
|
-
let result = run(['create', parentDir, '--name', '@test/parent-domain'], { tmp });
|
|
751
|
-
result = run([
|
|
752
|
-
'card', 'add', parentDir, 'axiom',
|
|
753
|
-
'--field', 'one_sentence=Parent axiom for forking test.',
|
|
754
|
-
'--field', 'full_statement=A complete testable explanation from the parent domain for fork lineage verification.',
|
|
755
|
-
'--field', 'why=Without this parent axiom the fork test would have no source to verify lineage against.',
|
|
756
|
-
'--field', 'applies_when=["fork testing"]',
|
|
757
|
-
'--field', 'does_not_apply_when=["not forking"]',
|
|
758
|
-
'--field', 'failure_risk=forked content may lose lineage',
|
|
759
|
-
'--field', 'confidence=high',
|
|
760
|
-
'--field', 'evidence_type=practice',
|
|
761
|
-
], { tmp });
|
|
762
|
-
const pCards = run(['card', 'list', parentDir], { tmp });
|
|
763
|
-
for (const line of pCards.stdout.trim().split('\n')) {
|
|
764
|
-
const id = line.split(/\s+/)[0];
|
|
765
|
-
run(['card', 'approve', parentDir, id, '--by', 'tester', '--statement', 'Parent OK.'], { tmp });
|
|
766
|
-
}
|
|
767
|
-
run(['lock', parentDir], { tmp });
|
|
768
|
-
const parentKdna = path.join(tmp, 'parent.kdna');
|
|
769
|
-
fs.mkdirSync(path.dirname(parentKdna), { recursive: true });
|
|
770
|
-
result = run(['export', parentDir, '--out', parentKdna], { tmp });
|
|
771
|
-
assert.equal(result.status, 0, result.stderr);
|
|
772
|
-
|
|
773
|
-
// Step 2: Fork from parent
|
|
774
|
-
const forkDir = path.join(tmp, 'forked');
|
|
775
|
-
result = run(['create', forkDir, '--from-kdna', parentKdna, '--name', '@test/forked-domain'], { tmp });
|
|
776
|
-
assert.equal(result.status, 0, result.stderr);
|
|
777
|
-
|
|
778
|
-
const fProject = JSON.parse(fs.readFileSync(path.join(forkDir, 'studio.project.json'), 'utf8'));
|
|
779
|
-
assert.equal(fProject.source_mode, 'kdna_asset');
|
|
780
|
-
assert.equal(fProject.lineage.type, 'fork');
|
|
781
|
-
assert.equal(fProject.lineage.parent_name, '@test/parent-domain');
|
|
782
|
-
|
|
783
|
-
// Step 3: Approve and export fork
|
|
784
|
-
const fCards = run(['card', 'list', forkDir], { tmp });
|
|
785
|
-
for (const line of fCards.stdout.trim().split('\n')) {
|
|
786
|
-
const id = line.split(/\s+/)[0];
|
|
787
|
-
run(['card', 'approve', forkDir, id, '--by', 'tester', '--statement', 'Fork confirmed.'], { tmp });
|
|
788
|
-
}
|
|
789
|
-
run(['lock', forkDir], { tmp });
|
|
790
|
-
const forkKdna = path.join(tmp, 'forked.kdna');
|
|
791
|
-
fs.mkdirSync(path.dirname(forkKdna), { recursive: true });
|
|
792
|
-
result = run(['export', forkDir, '--out', forkKdna], { tmp });
|
|
793
|
-
assert.equal(result.status, 0, result.stderr);
|
|
794
|
-
|
|
795
|
-
// Step 4: Verify lineage in exported fork
|
|
796
|
-
const fManifest = JSON.parse(fs.readFileSync(path.join(tmp, 'kdna.json'), 'utf8'));
|
|
797
|
-
assert.equal(fManifest.authoring.source_mode, 'kdna_asset');
|
|
798
|
-
assert.equal(fManifest.lineage.type, 'fork');
|
|
799
|
-
assert.equal(fManifest.lineage.parent_name, '@test/parent-domain');
|
|
800
|
-
assert.match(fManifest.content_digest, /^sha256:/);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
// ── E2E: Migrate workflow ─────────────────────────────────────────
|
|
804
|
-
|
|
805
|
-
test('E2E migrate: source folder → import → approve → export → verify', (t) => {
|
|
806
|
-
const tmp = tmpDir();
|
|
807
|
-
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
808
|
-
|
|
809
|
-
// Step 1: Create legacy source folder
|
|
810
|
-
const sourceDir = path.join(tmp, 'legacy');
|
|
811
|
-
fs.mkdirSync(sourceDir, { recursive: true });
|
|
812
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Core.json'), JSON.stringify({
|
|
813
|
-
meta: { domain: 'legacy_test', version: '0.3.0', created: '2026-01-01', purpose: 'Legacy migrate test', load_condition: 'always' },
|
|
814
|
-
axioms: [{
|
|
815
|
-
id: 'ax_mig', one_sentence: 'Migrated axiom for E2E test.',
|
|
816
|
-
full_statement: 'A complete testable explanation for the migrated legacy axiom in the end-to-end workflow.',
|
|
817
|
-
why: 'Without this migrated axiom the e2e test cannot verify legacy folder migration.',
|
|
818
|
-
applies_when: ['legacy migration testing'], does_not_apply_when: ['not migrating'],
|
|
819
|
-
failure_risk: 'migrated content may not be re-locked',
|
|
820
|
-
}],
|
|
821
|
-
ontology: [],
|
|
822
|
-
stances: [],
|
|
823
|
-
}));
|
|
824
|
-
fs.writeFileSync(path.join(sourceDir, 'KDNA_Patterns.json'), JSON.stringify({
|
|
825
|
-
meta: { domain: 'legacy_test', version: '0.3.0', created: '2026-01-01', purpose: 'legacy patterns', load_condition: 'always' },
|
|
826
|
-
misunderstandings: [{ id: 'ms_mig', wrong: 'Legacy wrong belief.', correct: 'Correct understanding.', key_distinction: 'The key difference between legacy wrong and correct behavior.' }],
|
|
827
|
-
self_check: ['Is the migrated content properly re-locked?'],
|
|
828
|
-
}));
|
|
829
|
-
|
|
830
|
-
// Step 2: Import via --from-folder
|
|
831
|
-
const projectDir = path.join(tmp, 'imported');
|
|
832
|
-
let result = run(['create', projectDir, '--from-folder', sourceDir, '--name', '@test/e2e-migrate'], { tmp });
|
|
833
|
-
assert.equal(result.status, 0, result.stderr);
|
|
834
|
-
const audit = JSON.parse(result.stdout);
|
|
835
|
-
assert.ok(audit.imported > 0, `No cards imported: ${result.stdout}`);
|
|
836
|
-
assert.equal(audit.source_mode, 'source_folder');
|
|
837
|
-
|
|
838
|
-
// Verify project has source_folder mode
|
|
839
|
-
const project = JSON.parse(fs.readFileSync(path.join(projectDir, 'studio.project.json'), 'utf8'));
|
|
840
|
-
assert.equal(project.source_mode, 'source_folder');
|
|
841
|
-
assert.equal(project.lineage.type, 'migrated');
|
|
842
|
-
|
|
843
|
-
// Step 3: Approve and export
|
|
844
|
-
const cards = run(['card', 'list', projectDir], { tmp });
|
|
845
|
-
for (const line of cards.stdout.trim().split('\n')) {
|
|
846
|
-
const id = line.split(/\s+/)[0];
|
|
847
|
-
result = run(['card', 'approve', projectDir, id, '--by', 'tester', '--statement', 'Migrated content confirmed.'], { tmp });
|
|
848
|
-
assert.equal(result.status, 0, result.stderr);
|
|
849
|
-
}
|
|
850
|
-
result = run(['lock', projectDir], { tmp });
|
|
851
|
-
assert.equal(result.status, 0, result.stderr);
|
|
852
|
-
|
|
853
|
-
const outFile = path.join(tmp, 'migrated.kdna');
|
|
854
|
-
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
855
|
-
result = run(['export', projectDir, '--out', outFile], { tmp });
|
|
856
|
-
assert.equal(result.status, 0, result.stderr);
|
|
857
|
-
|
|
858
|
-
// Verify exported manifest
|
|
859
|
-
const distDir = path.dirname(outFile);
|
|
860
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(distDir, 'kdna.json'), 'utf8'));
|
|
861
|
-
assert.equal(manifest.authoring.source_mode, 'source_folder');
|
|
862
|
-
assert.equal(manifest.lineage.type, 'migrated');
|
|
863
|
-
assert.match(manifest.content_digest, /^sha256:/);
|
|
864
|
-
assert.equal(manifest.authoring.human_confirmed, true);
|
|
865
|
-
|
|
866
|
-
// Digest chain
|
|
867
|
-
const receipt = JSON.parse(fs.readFileSync(path.join(distDir, 'build-receipt.json'), 'utf8'));
|
|
868
|
-
assert.equal(manifest.content_digest, receipt.content_digest);
|
|
869
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* e2e-export-completeness.test.js
|
|
3
|
-
* Verifies all card types survive add → approve → export → validate → load.
|
|
4
|
-
* Catches regressions like: stances hardcoded to [], pattern cards dropped during compile.
|
|
5
|
-
*/
|
|
6
|
-
const { test } = require('node:test');
|
|
7
|
-
const assert = require('node:assert/strict');
|
|
8
|
-
const { spawnSync } = require('node:child_process');
|
|
9
|
-
const fs = require('node:fs');
|
|
10
|
-
const path = require('node:path');
|
|
11
|
-
const os = require('node:os');
|
|
12
|
-
|
|
13
|
-
const studioBin = path.join(__dirname, '..', 'bin', 'kdna-studio.js');
|
|
14
|
-
|
|
15
|
-
function run(args, opts = {}) {
|
|
16
|
-
return spawnSync(process.execPath, [studioBin, ...args], {
|
|
17
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
-
env: { ...process.env, ...opts.env },
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function runKdna(args) {
|
|
23
|
-
return spawnSync('kdna', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-e2e-export-')); }
|
|
27
|
-
|
|
28
|
-
function addCard(projectDir, type, fields) {
|
|
29
|
-
const args = ['card', 'add', projectDir, type];
|
|
30
|
-
for (const [k, v] of Object.entries(fields)) args.push('--field', `${k}=${v}`);
|
|
31
|
-
return run(args);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
test('e2e: pattern → approve → export → patterns in payload', () => {
|
|
35
|
-
const tmp = tmpDir();
|
|
36
|
-
try {
|
|
37
|
-
const pDir = path.join(tmp, 'p');
|
|
38
|
-
run(['create', pDir, '--name', '@test/e2e-pattern']);
|
|
39
|
-
addCard(pDir, 'axiom', {
|
|
40
|
-
one_sentence: 'AX', full_statement: 'A complete and testable explanation for the agent.',
|
|
41
|
-
why: 'Without this axiom the agent would fail.',
|
|
42
|
-
applies_when: '["x"]', does_not_apply_when: '["y"]', failure_risk: 'g',
|
|
43
|
-
confidence: 'high', evidence_type: 'practice',
|
|
44
|
-
});
|
|
45
|
-
addCard(pDir, 'pattern', {
|
|
46
|
-
type: 'test_pattern', name: 'Test Pattern', one_sentence: 'A test pattern.',
|
|
47
|
-
what_it_looks_like: 'It looks like X.', how_to_fix: 'Fix by doing Y.', failure_risk: 'low',
|
|
48
|
-
});
|
|
49
|
-
const approveR = run(['card', 'approve', pDir, '--all', '--by', 'me', '--statement', 'ok']);
|
|
50
|
-
assert.equal(approveR.status, 0, approveR.stderr);
|
|
51
|
-
const exportR = run(['export', pDir, '--format', 'v1', '--out', path.join(tmp, 'out.kdna')]);
|
|
52
|
-
assert.equal(exportR.status, 0, exportR.stderr);
|
|
53
|
-
runKdna(['unpack', path.join(tmp, 'out.kdna'), path.join(tmp, 'unpacked')]);
|
|
54
|
-
const payload = JSON.parse(fs.readFileSync(path.join(tmp, 'unpacked', 'payload.kdnab'), 'utf8'));
|
|
55
|
-
const patternCards = (payload.patterns || []).filter(p => p.type === 'test_pattern');
|
|
56
|
-
assert.ok(patternCards.length > 0, `Expected >0 pattern cards in payload patterns, got ${patternCards.length}`);
|
|
57
|
-
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('e2e: stances → approve → export → stances in payload', () => {
|
|
61
|
-
const tmp = tmpDir();
|
|
62
|
-
try {
|
|
63
|
-
const pDir = path.join(tmp, 'p');
|
|
64
|
-
run(['create', pDir, '--name', '@test/e2e-stance']);
|
|
65
|
-
addCard(pDir, 'axiom', {
|
|
66
|
-
one_sentence: 'AX', full_statement: 'A complete and testable explanation for the agent.',
|
|
67
|
-
why: 'Without this axiom the agent would fail.',
|
|
68
|
-
applies_when: '["x"]', does_not_apply_when: '["y"]', failure_risk: 'g',
|
|
69
|
-
confidence: 'high', evidence_type: 'practice',
|
|
70
|
-
});
|
|
71
|
-
addCard(pDir, 'stance', { statement: 'Stance one: Always diagnose before prescribing.' });
|
|
72
|
-
addCard(pDir, 'stance', { statement: 'Stance two: Trust burns slowly and is lost quickly.' });
|
|
73
|
-
run(['card', 'approve', pDir, '--all', '--by', 'me', '--statement', 'ok']);
|
|
74
|
-
run(['export', pDir, '--format', 'v1', '--out', path.join(tmp, 'out.kdna')]);
|
|
75
|
-
runKdna(['unpack', path.join(tmp, 'out.kdna'), path.join(tmp, 'unpacked')]);
|
|
76
|
-
const payload = JSON.parse(fs.readFileSync(path.join(tmp, 'unpacked', 'payload.kdnab'), 'utf8'));
|
|
77
|
-
assert.equal((payload.core?.stances || []).length, 2, 'Expected 2 stances in payload');
|
|
78
|
-
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('e2e: missing required fields reports ALL fields at once', () => {
|
|
82
|
-
const tmp = tmpDir();
|
|
83
|
-
try {
|
|
84
|
-
const pDir = path.join(tmp, 'p');
|
|
85
|
-
run(['create', pDir, '--name', '@test/e2e-completeness']);
|
|
86
|
-
const r = addCard(pDir, 'axiom', { one_sentence: 'test' });
|
|
87
|
-
assert.notEqual(r.status, 0);
|
|
88
|
-
assert.ok(r.stderr.includes('full_statement'), 'must mention full_statement');
|
|
89
|
-
assert.ok(r.stderr.includes('why'), 'must mention why');
|
|
90
|
-
assert.ok(r.stderr.includes('applies_when'), 'must mention applies_when');
|
|
91
|
-
assert.ok(r.stderr.includes('confidence'), 'must mention confidence');
|
|
92
|
-
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
93
|
-
});
|