@aikdna/kdna-studio-core 1.3.2 → 1.4.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/package.json +13 -2
- package/src/cards/index.js +13 -0
- package/src/compile/index.js +36 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-studio-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Studio-compatible authoring kernel for Human Lock, compile, and export of trusted .kdna assets.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git+https://github.com/aikdna/kdna-studio-core.git"
|
|
16
16
|
},
|
|
17
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kdna",
|
|
19
|
+
"domain-judgment",
|
|
20
|
+
"authoring",
|
|
21
|
+
"judgment-cards",
|
|
22
|
+
"human-lock",
|
|
23
|
+
"compile",
|
|
24
|
+
"validate"
|
|
25
|
+
],
|
|
18
26
|
"dependencies": {
|
|
19
27
|
"@aikdna/kdna-core": "^0.6.0"
|
|
20
28
|
},
|
|
@@ -25,5 +33,8 @@
|
|
|
25
33
|
"lint": "find src tests -name '*.js' -print0 | xargs -0 -n1 node --check",
|
|
26
34
|
"test": "node --test tests/*.test.js",
|
|
27
35
|
"test:all": "npm run lint && npm test"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@aikdna/kdna-cli": "^0.19.3"
|
|
28
39
|
}
|
|
29
40
|
}
|
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,34 @@ 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
|
-
const excluded = new Set(['
|
|
51
|
+
const excluded = new Set(['signature.json', '.DS_Store']);
|
|
50
52
|
const payload = Object.keys(files)
|
|
51
53
|
.filter(name => !excluded.has(name))
|
|
52
|
-
.filter(name => !name.startsWith('reports/') && name !== 'build-receipt.json')
|
|
53
54
|
.sort()
|
|
54
|
-
.map(name =>
|
|
55
|
-
|
|
55
|
+
.map(name => {
|
|
56
|
+
let content = files[name];
|
|
57
|
+
if (name === 'mimetype') content = 'application/vnd.aikdna.kdna+zip';
|
|
58
|
+
const buf = name.endsWith('.json')
|
|
59
|
+
? Buffer.from(canonicalizeJson(name, content))
|
|
60
|
+
: Buffer.from(content);
|
|
61
|
+
const hash = crypto.createHash('sha256').update(buf).digest('hex');
|
|
62
|
+
return `${name}:${hash}`;
|
|
63
|
+
})
|
|
64
|
+
.join('\n');
|
|
56
65
|
return `sha256:${crypto.createHash('sha256').update(payload).digest('hex')}`;
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -241,12 +250,19 @@ function compileManifest(project, files, identity = null) {
|
|
|
241
250
|
license: project.license || { type: 'CC-BY-4.0' },
|
|
242
251
|
description: project.release?.description || project.name,
|
|
243
252
|
file_count: kdnaFileCount,
|
|
253
|
+
creator: project.creator_identity ? {
|
|
254
|
+
creator_id: project.creator_identity.creator_id,
|
|
255
|
+
display_name: project.creator_identity.display_name,
|
|
256
|
+
public_key: project.creator_identity.public_key,
|
|
257
|
+
verified: project.creator_identity.verified || false,
|
|
258
|
+
} : null,
|
|
244
259
|
authoring: {
|
|
245
260
|
created_by: 'kdna-studio-sdk',
|
|
246
261
|
authoring_tool: 'KDNA Studio Core',
|
|
247
262
|
authoring_tool_version: version,
|
|
248
263
|
compiler: '@aikdna/kdna-studio-core',
|
|
249
264
|
compiler_version: version,
|
|
265
|
+
source_mode: project.source_mode || 'blank',
|
|
250
266
|
asset_uid: assetIdentity.asset_uid,
|
|
251
267
|
project_uid: assetIdentity.project_uid,
|
|
252
268
|
build_id: assetIdentity.build_id,
|
|
@@ -259,6 +275,7 @@ function compileManifest(project, files, identity = null) {
|
|
|
259
275
|
human_confirmed: lockedCards.length > 0,
|
|
260
276
|
compiled_at: assetIdentity.compiled_at,
|
|
261
277
|
},
|
|
278
|
+
lineage: project.lineage || { type: 'original' },
|
|
262
279
|
created: project.created || new Date().toISOString().slice(0, 10),
|
|
263
280
|
updated: project.updated || new Date().toISOString().slice(0, 10),
|
|
264
281
|
};
|
|
@@ -395,9 +412,10 @@ function compileDomain(project) {
|
|
|
395
412
|
if (cases) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
|
|
396
413
|
if (reasoning) files['KDNA_Reasoning.json'] = JSON.stringify(reasoning, null, 2);
|
|
397
414
|
if (evolution) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
|
|
398
|
-
const identity = buildAssetIdentity(project, files);
|
|
399
415
|
|
|
400
416
|
// ── KDNA Card (governance metadata) ─────────────────────────────
|
|
417
|
+
// Must be added BEFORE digest computation so it is included in content_digest.
|
|
418
|
+
const identity = buildAssetIdentity(project, files);
|
|
401
419
|
const provenance = require('../provenance').buildProvenance(project, files, identity);
|
|
402
420
|
const { generateKdnaCard } = require('../governance');
|
|
403
421
|
const kdnaCard = generateKdnaCard(project, {}, provenance);
|
|
@@ -412,10 +430,14 @@ function compileDomain(project) {
|
|
|
412
430
|
kdna_files: Object.keys(files).filter(f => f.startsWith('KDNA_')).length,
|
|
413
431
|
total_files: Object.keys(files).length,
|
|
414
432
|
};
|
|
415
|
-
|
|
433
|
+
|
|
434
|
+
// Compute content_digest once with all files present, BEFORE building reports.
|
|
416
435
|
identity.content_digest = computeContentDigest(files);
|
|
417
436
|
provenance.content_digest = identity.content_digest;
|
|
418
437
|
provenance.content_fingerprint = identity.content_digest;
|
|
438
|
+
|
|
439
|
+
// Now build reports/receipt — they will all see the same digest.
|
|
440
|
+
Object.assign(files, buildReports(project, files, identity, provenance, stats));
|
|
419
441
|
files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
|
|
420
442
|
files['kdna.json'] = JSON.stringify(compileManifest(project, files, identity), null, 2);
|
|
421
443
|
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` });
|