@aikdna/kdna-studio-core 1.3.3 → 1.4.1
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/package.json +1 -1
- package/src/cards/index.js +13 -0
- package/src/compile/index.js +40 -14
- package/src/creator-identity.js +164 -0
- package/src/index.js +2 -0
- package/src/project/index.js +4 -0
- package/src/provenance/index.js +3 -0
- package/src/quality/index.js +15 -0
- package/src/quality/validate-cards.js +9 -0
package/package.json
CHANGED
package/src/cards/index.js
CHANGED
|
@@ -66,12 +66,24 @@ function lockCard(card, lockPayload) {
|
|
|
66
66
|
if (!lockPayload.checked?.does_not_apply_when) throw new Error('Must confirm does_not_apply_when reviewed');
|
|
67
67
|
if (!lockPayload.checked?.failure_risk) throw new Error('Must confirm failure_risk reviewed');
|
|
68
68
|
|
|
69
|
+
// Schema gate: axiom cards MUST have full_statement and why per KDNA SPEC
|
|
70
|
+
if (card.type === 'axiom') {
|
|
71
|
+
if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
|
|
72
|
+
throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short full_statement. SPEC requires a complete, testable explanation.`);
|
|
73
|
+
}
|
|
74
|
+
if (!card.fields?.why || card.fields.why.length < 20) {
|
|
75
|
+
throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short why. SPEC requires an explanation of failure mode.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const lockedCard = { ...card, fields: { ...card.fields } };
|
|
70
80
|
lockedCard.human_lock = {
|
|
71
81
|
by: lockPayload.by,
|
|
72
82
|
at: new Date().toISOString(),
|
|
73
83
|
statement: lockPayload.statement,
|
|
74
84
|
checked: lockPayload.checked,
|
|
85
|
+
creator_id: lockPayload.creator_id || null,
|
|
86
|
+
signature: lockPayload.signature || null,
|
|
75
87
|
judgment_fingerprint: cardJudgmentFingerprint(lockedCard),
|
|
76
88
|
};
|
|
77
89
|
|
|
@@ -106,6 +118,7 @@ module.exports = {
|
|
|
106
118
|
unlockCard,
|
|
107
119
|
getLockedCards,
|
|
108
120
|
getPublishableCards,
|
|
121
|
+
cardJudgmentFingerprint,
|
|
109
122
|
// Feynman restatement (re-exported from feynman.js)
|
|
110
123
|
createFeynmanRestatement: require('./feynman').createFeynmanRestatement,
|
|
111
124
|
evaluateRestatementQuality: require('./feynman').evaluateRestatementQuality,
|
package/src/compile/index.js
CHANGED
|
@@ -34,25 +34,38 @@ function stableStringify(value) {
|
|
|
34
34
|
return JSON.stringify(value);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
function canonicalizeJson(name, content) {
|
|
38
|
+
const obj = JSON.parse(content);
|
|
39
|
+
if (name === 'kdna.json') {
|
|
40
|
+
const copy = { ...obj };
|
|
41
|
+
delete copy.signature;
|
|
42
|
+
delete copy.asset_digest;
|
|
43
|
+
delete copy.container_sha256;
|
|
44
|
+
delete copy.content_digest;
|
|
45
|
+
return stableStringify(copy);
|
|
44
46
|
}
|
|
45
|
-
return
|
|
47
|
+
return stableStringify(obj);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function computeContentDigest(files) {
|
|
49
|
-
|
|
51
|
+
// Content digest covers canonical judgment content + public asset metadata.
|
|
52
|
+
// Reports and build-receipt are build evidence, not content — they change with
|
|
53
|
+
// every build and would cause self-referencing if included.
|
|
54
|
+
const excluded = new Set(['signature.json', '.DS_Store', 'build-receipt.json']);
|
|
50
55
|
const payload = Object.keys(files)
|
|
51
56
|
.filter(name => !excluded.has(name))
|
|
52
|
-
.filter(name => !name.startsWith('reports/')
|
|
57
|
+
.filter(name => !name.startsWith('reports/'))
|
|
53
58
|
.sort()
|
|
54
|
-
.map(name =>
|
|
55
|
-
|
|
59
|
+
.map(name => {
|
|
60
|
+
let content = files[name];
|
|
61
|
+
if (name === 'mimetype') content = 'application/vnd.aikdna.kdna+zip';
|
|
62
|
+
const buf = name.endsWith('.json')
|
|
63
|
+
? Buffer.from(canonicalizeJson(name, content))
|
|
64
|
+
: Buffer.from(content);
|
|
65
|
+
const hash = crypto.createHash('sha256').update(buf).digest('hex');
|
|
66
|
+
return `${name}:${hash}`;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
56
69
|
return `sha256:${crypto.createHash('sha256').update(payload).digest('hex')}`;
|
|
57
70
|
}
|
|
58
71
|
|
|
@@ -241,12 +254,19 @@ function compileManifest(project, files, identity = null) {
|
|
|
241
254
|
license: project.license || { type: 'CC-BY-4.0' },
|
|
242
255
|
description: project.release?.description || project.name,
|
|
243
256
|
file_count: kdnaFileCount,
|
|
257
|
+
creator: project.creator_identity ? {
|
|
258
|
+
creator_id: project.creator_identity.creator_id,
|
|
259
|
+
display_name: project.creator_identity.display_name,
|
|
260
|
+
public_key: project.creator_identity.public_key,
|
|
261
|
+
verified: project.creator_identity.verified || false,
|
|
262
|
+
} : null,
|
|
244
263
|
authoring: {
|
|
245
264
|
created_by: 'kdna-studio-sdk',
|
|
246
265
|
authoring_tool: 'KDNA Studio Core',
|
|
247
266
|
authoring_tool_version: version,
|
|
248
267
|
compiler: '@aikdna/kdna-studio-core',
|
|
249
268
|
compiler_version: version,
|
|
269
|
+
source_mode: project.source_mode || 'blank',
|
|
250
270
|
asset_uid: assetIdentity.asset_uid,
|
|
251
271
|
project_uid: assetIdentity.project_uid,
|
|
252
272
|
build_id: assetIdentity.build_id,
|
|
@@ -259,6 +279,7 @@ function compileManifest(project, files, identity = null) {
|
|
|
259
279
|
human_confirmed: lockedCards.length > 0,
|
|
260
280
|
compiled_at: assetIdentity.compiled_at,
|
|
261
281
|
},
|
|
282
|
+
lineage: project.lineage || { type: 'original' },
|
|
262
283
|
created: project.created || new Date().toISOString().slice(0, 10),
|
|
263
284
|
updated: project.updated || new Date().toISOString().slice(0, 10),
|
|
264
285
|
};
|
|
@@ -395,9 +416,10 @@ function compileDomain(project) {
|
|
|
395
416
|
if (cases) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
|
|
396
417
|
if (reasoning) files['KDNA_Reasoning.json'] = JSON.stringify(reasoning, null, 2);
|
|
397
418
|
if (evolution) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
|
|
398
|
-
const identity = buildAssetIdentity(project, files);
|
|
399
419
|
|
|
400
420
|
// ── KDNA Card (governance metadata) ─────────────────────────────
|
|
421
|
+
// Must be added BEFORE digest computation so it is included in content_digest.
|
|
422
|
+
const identity = buildAssetIdentity(project, files);
|
|
401
423
|
const provenance = require('../provenance').buildProvenance(project, files, identity);
|
|
402
424
|
const { generateKdnaCard } = require('../governance');
|
|
403
425
|
const kdnaCard = generateKdnaCard(project, {}, provenance);
|
|
@@ -412,10 +434,14 @@ function compileDomain(project) {
|
|
|
412
434
|
kdna_files: Object.keys(files).filter(f => f.startsWith('KDNA_')).length,
|
|
413
435
|
total_files: Object.keys(files).length,
|
|
414
436
|
};
|
|
415
|
-
|
|
437
|
+
|
|
438
|
+
// Compute content_digest once with all files present, BEFORE building reports.
|
|
416
439
|
identity.content_digest = computeContentDigest(files);
|
|
417
440
|
provenance.content_digest = identity.content_digest;
|
|
418
441
|
provenance.content_fingerprint = identity.content_digest;
|
|
442
|
+
|
|
443
|
+
// Now build reports/receipt — they will all see the same digest.
|
|
444
|
+
Object.assign(files, buildReports(project, files, identity, provenance, stats));
|
|
419
445
|
files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
|
|
420
446
|
files['kdna.json'] = JSON.stringify(compileManifest(project, files, identity), null, 2);
|
|
421
447
|
stats.total_files = Object.keys(files).length;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creator Identity — local Ed25519 keypair generation, loading, and signing.
|
|
3
|
+
*
|
|
4
|
+
* Creator IDs use the format: "kdna:creator:ed25519:<sha256-of-public-key>"
|
|
5
|
+
* Private keys are stored in ~/.kdna/identity/ by default.
|
|
6
|
+
*
|
|
7
|
+
* This is local-first — no registration, no upload, no cloud dependency.
|
|
8
|
+
* The private key is the root of creator identity; the public key fingerprint
|
|
9
|
+
* is the creator_id that appears in studio.project.json and exported .kdna assets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const CREATOR_ID_PREFIX = 'kdna:creator:ed25519:';
|
|
18
|
+
|
|
19
|
+
function defaultIdentityDir() {
|
|
20
|
+
return process.env.KDNA_IDENTITY_DIR || path.join(os.homedir(), '.kdna', 'identity');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PRIVATE_KEY_FILE = 'kdna.key';
|
|
24
|
+
const PUBLIC_KEY_FILE = 'kdna.pub';
|
|
25
|
+
const IDENTITY_JSON_FILE = 'creator.json';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute the creator_id fingerprint from an Ed25519 public key PEM.
|
|
29
|
+
*/
|
|
30
|
+
function creatorFingerprint(publicKeyPem) {
|
|
31
|
+
const hash = crypto.createHash('sha256').update(publicKeyPem).digest('hex');
|
|
32
|
+
return `${CREATOR_ID_PREFIX}${hash}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a new Ed25519 keypair and persist to identityDir.
|
|
37
|
+
* Returns the creator identity object. Does NOT overwrite existing keys.
|
|
38
|
+
*/
|
|
39
|
+
function initIdentity(displayName, identityDir = null) {
|
|
40
|
+
const dir = identityDir || defaultIdentityDir();
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
|
|
44
|
+
const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
|
|
45
|
+
const identityJsonPath = path.join(dir, IDENTITY_JSON_FILE);
|
|
46
|
+
|
|
47
|
+
if (fs.existsSync(privateKeyPath) || fs.existsSync(publicKeyPath)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Identity keys already exist in ${dir}. Use loadIdentity() to access them, or remove the files to regenerate.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
|
|
54
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
55
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
fs.writeFileSync(privateKeyPath, privateKey, { mode: 0o600 });
|
|
59
|
+
fs.writeFileSync(publicKeyPath, publicKey, { mode: 0o644 });
|
|
60
|
+
|
|
61
|
+
const creatorId = creatorFingerprint(publicKey);
|
|
62
|
+
const identity = {
|
|
63
|
+
creator_id: creatorId,
|
|
64
|
+
display_name: displayName || '',
|
|
65
|
+
public_key: publicKey,
|
|
66
|
+
public_key_path: publicKeyPath,
|
|
67
|
+
identity_dir: dir,
|
|
68
|
+
created_at: new Date().toISOString(),
|
|
69
|
+
verified: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
fs.writeFileSync(identityJsonPath, JSON.stringify(identity, null, 2), { mode: 0o644 });
|
|
73
|
+
|
|
74
|
+
return identity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load an existing creator identity from disk.
|
|
79
|
+
* Returns null if no identity has been initialized.
|
|
80
|
+
*/
|
|
81
|
+
function loadIdentity(identityDir = null) {
|
|
82
|
+
const dir = identityDir || defaultIdentityDir();
|
|
83
|
+
const identityJsonPath = path.join(dir, IDENTITY_JSON_FILE);
|
|
84
|
+
const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
|
|
85
|
+
|
|
86
|
+
if (!fs.existsSync(identityJsonPath)) return null;
|
|
87
|
+
|
|
88
|
+
let identity;
|
|
89
|
+
try {
|
|
90
|
+
identity = JSON.parse(fs.readFileSync(identityJsonPath, 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Ensure public key is loaded (may have been deleted from JSON for security)
|
|
96
|
+
if (!identity.public_key && fs.existsSync(publicKeyPath)) {
|
|
97
|
+
identity.public_key = fs.readFileSync(publicKeyPath, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
identity.identity_dir = dir;
|
|
101
|
+
identity.public_key_path = publicKeyPath;
|
|
102
|
+
|
|
103
|
+
return identity;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sign a payload with the creator's Ed25519 private key.
|
|
108
|
+
* Returns the signature in "ed25519:<hex>" format.
|
|
109
|
+
*/
|
|
110
|
+
function signPayload(payload, identityDir = null) {
|
|
111
|
+
const dir = identityDir || defaultIdentityDir();
|
|
112
|
+
const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`No private key found at ${privateKeyPath}. Run identity init first.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
|
|
121
|
+
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
|
|
122
|
+
const sig = crypto.sign(null, data, privateKeyPem);
|
|
123
|
+
return `ed25519:${sig.toString('hex')}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sign a Human Lock payload — creates a deterministic signable string from
|
|
128
|
+
* the lock context and returns the signature.
|
|
129
|
+
*
|
|
130
|
+
* Payload: `${cardId}\n${statement}\n${judgmentFingerprint}`
|
|
131
|
+
*/
|
|
132
|
+
function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null) {
|
|
133
|
+
const lockPayload = [cardId, statement, judgmentFingerprint].join('\n');
|
|
134
|
+
return signPayload(lockPayload, identityDir);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the public key PEM for the creator identity.
|
|
139
|
+
*/
|
|
140
|
+
function loadPublicKey(identityDir = null) {
|
|
141
|
+
const dir = identityDir || defaultIdentityDir();
|
|
142
|
+
const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
|
|
143
|
+
if (!fs.existsSync(publicKeyPath)) return null;
|
|
144
|
+
return fs.readFileSync(publicKeyPath, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get the private key path. Used by consumers that need direct key access.
|
|
149
|
+
*/
|
|
150
|
+
function privateKeyPath(identityDir = null) {
|
|
151
|
+
return path.join(identityDir || defaultIdentityDir(), PRIVATE_KEY_FILE);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
initIdentity,
|
|
156
|
+
loadIdentity,
|
|
157
|
+
signPayload,
|
|
158
|
+
signHumanLock,
|
|
159
|
+
creatorFingerprint,
|
|
160
|
+
loadPublicKey,
|
|
161
|
+
privateKeyPath,
|
|
162
|
+
defaultIdentityDir,
|
|
163
|
+
CREATOR_ID_PREFIX,
|
|
164
|
+
};
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const cards = require('./cards');
|
|
21
21
|
const compile = require('./compile');
|
|
22
|
+
const creatorIdentity = require('./creator-identity');
|
|
22
23
|
const evidence = require('./evidence');
|
|
23
24
|
const governance = require('./governance');
|
|
24
25
|
const i18n = require('./i18n');
|
|
@@ -44,6 +45,7 @@ module.exports = {
|
|
|
44
45
|
pipeline,
|
|
45
46
|
governance,
|
|
46
47
|
i18n,
|
|
48
|
+
creator: creatorIdentity,
|
|
47
49
|
|
|
48
50
|
// Experimental
|
|
49
51
|
evidence,
|
package/src/project/index.js
CHANGED
|
@@ -22,6 +22,10 @@ function createProject(name, type = 'domain', options = {}) {
|
|
|
22
22
|
updated: new Date().toISOString().slice(0, 10),
|
|
23
23
|
author: options.author || { name: '', id: '' },
|
|
24
24
|
status: 'drafting',
|
|
25
|
+
source_mode: options.sourceMode || 'blank',
|
|
26
|
+
creator_identity: options.creatorIdentity || null,
|
|
27
|
+
lineage: options.lineage || null,
|
|
28
|
+
imported_source_folder: options.sourcePath || null,
|
|
25
29
|
cards: [],
|
|
26
30
|
evidence: [],
|
|
27
31
|
tests: [],
|
package/src/provenance/index.js
CHANGED
|
@@ -32,6 +32,9 @@ function buildProvenance(project, compiledFiles, identity = {}) {
|
|
|
32
32
|
domain_id: identity.domain_id || null,
|
|
33
33
|
registry_name: identity.registry_name || project.name || null,
|
|
34
34
|
author_id: project.author?.id || '',
|
|
35
|
+
creator_id: project.creator_identity?.creator_id || null,
|
|
36
|
+
source_mode: project.source_mode || 'blank',
|
|
37
|
+
lineage: project.lineage || { type: 'original' },
|
|
35
38
|
locked_card_count: lockedCards.length,
|
|
36
39
|
test_case_count: tests.length,
|
|
37
40
|
built_at: identity.compiled_at || new Date().toISOString(),
|
package/src/quality/index.js
CHANGED
|
@@ -30,6 +30,21 @@ function computeReadiness(project) {
|
|
|
30
30
|
// ── Governance check (v0.6.1) ───────────────────────────────────
|
|
31
31
|
const govResult = validateGovernance(project);
|
|
32
32
|
|
|
33
|
+
// ── Source mode trust checks (v1.4.0) ───────────────────────────
|
|
34
|
+
const sourceMode = project.source_mode || 'blank';
|
|
35
|
+
if (sourceMode === 'source_folder') {
|
|
36
|
+
blocking.push('source_folder: all imported cards must be re-locked — legacy trust is not inherited');
|
|
37
|
+
blocking.push('source_folder: schema audit required; verify all required fields before Human Lock');
|
|
38
|
+
}
|
|
39
|
+
if (sourceMode === 'kdna_asset') {
|
|
40
|
+
const hasRelevantLineage = project.lineage &&
|
|
41
|
+
(project.lineage.parent_name || project.lineage.parent_asset_uid);
|
|
42
|
+
if (!hasRelevantLineage) {
|
|
43
|
+
blocking.push('kdna_asset: lineage missing — must record parent KDNA identity');
|
|
44
|
+
}
|
|
45
|
+
warnings.push('kdna_asset: cards imported from existing KDNA must be re-locked; parent trust is not inherited');
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
// ── I18N check (v1.2.0) ─────────────────────────────────────────
|
|
34
49
|
const isOfficial = project.name?.startsWith('@aikdna/') || project.release?.official === true;
|
|
35
50
|
const i18nCoverage = computeI18nCoverage(project);
|
|
@@ -77,6 +77,15 @@ function validateAxiom(card, issues) {
|
|
|
77
77
|
issues.push({ type: 'too_short', severity: 'blocking', message: `${card.id}: one_sentence too short (${oneLiner.length} chars)`, fix: 'Make it a complete, specific judgment statement.' });
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// SPEC requirement: axiom MUST have full_statement and why
|
|
81
|
+
if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
|
|
82
|
+
issues.push({ type: 'missing_full_statement', severity: 'blocking', message: `${card.id}: full_statement missing or too short — SPEC requires a complete, testable explanation`, fix: 'Add a full_statement that is at least 20 characters and explains the principle in full.' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!card.fields?.why || card.fields.why.length < 20) {
|
|
86
|
+
issues.push({ type: 'missing_why', severity: 'blocking', message: `${card.id}: why missing or too short — SPEC requires an explanation of what the agent would get wrong without this axiom`, fix: 'Add a why field that explains the failure mode this axiom prevents.' });
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
// Check for dictionary-definition style (axiom should not start with "X is")
|
|
81
90
|
if (/^\w+\s+is\s/.test(oneLiner) && oneLiner.length < 50) {
|
|
82
91
|
issues.push({ type: 'definition_like', severity: 'warning', message: `${card.id}: one_sentence reads like a definition, not a judgment — rephrase as a principle` });
|