@aikdna/kdna-studio-core 1.4.2 → 1.5.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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # KDNA Studio Core
2
2
 
3
- **KDNA Studio turns raw human experience into AI-loadable cognitive kernels.**
3
+ **KDNA Studio Core is the judgment asset refinery.** It turns scattered notes, documents, works, and feedback into loadable .kdna judgment assets — not by compressing content, but by distilling the stable judgment patterns embedded within it.
4
4
 
5
- AI may propose judgment candidates. Humans must confirm judgment. Only human-locked judgment can compile into KDNA.
5
+ AI may propose judgment candidates from content analysis. Humans must confirm judgment. Only human-locked judgment can compile into KDNA.
6
6
 
7
- Open-source Studio-compatible authoring kernel for creating trusted `.kdna` assets — JS/npm package. AI can propose. Humans confirm. Only locked judgment compiles.
7
+ Open-source Studio-compatible authoring kernel for creating trusted `.kdna` assets — JS/npm package. Supports two authoring paths: interview-first (expert self-expression) and distillation-first (pattern extraction from existing content). Both end with Human Judgment Lock.
8
8
 
9
9
  **KDNA Studio Core is the JS authoring kernel.** It is not a UI tool and not a CLI package. It is a pure-logic engine for creating KDNA judgment cards, Human Locks, compiler output, and authoring provenance from JavaScript applications and Studio-compatible tools.
10
10
 
@@ -13,6 +13,11 @@ Studio-compatible authoring pipeline that performs human confirmation,
13
13
  validation, canonicalization, identity generation, digest computation, signing,
14
14
  optional encryption, and provenance recording.
15
15
 
16
+ **Hard boundary:** Optional encryption, when supported, MUST be represented as
17
+ protected entries inside the `.kdna` container (RFC-0008). App-private encrypted
18
+ envelopes or transfer wrappers that cannot be opened by KDNA Core are NOT
19
+ conforming KDNA runtime assets.
20
+
16
21
  | Library | Language | Role |
17
22
  |---------|----------|------|
18
23
  | `@aikdna/kdna-cli` | JS/npm | **Operate** KDNA — install, verify, load, compare, publish |
@@ -24,6 +29,9 @@ optional encryption, and provenance recording.
24
29
 
25
30
  - **Project Model** — `studio.project.json` with full metadata, provenance tracking
26
31
  - **Evidence Room** — import raw material (text, markdown, interviews, cases)
32
+ - **Distillation Target** — declare domain category, owner scope, granularity, task scope, include/exclude areas, and load condition before extraction
33
+ - **Evidence Relevance** — classify source material as relevant, weakly relevant, out-of-scope, or split-domain before distillation
34
+ - **Scope Gate** — mark candidates with `scope_fit`, relevance score, and suggested split domain before they can become cards
27
35
  - **Judgment Cards** — 8 card types: axiom, ontology, stance, framework, misunderstanding, self_check, banned_term, terminology
28
36
  - **Human Lock** — AI proposes, human confirms. Only locked cards compile.
29
37
  - **Authoring Provenance** — every compiled manifest records Studio-compatible
@@ -49,6 +57,15 @@ optional encryption, and provenance recording.
49
57
  Evidence Room → Judgment Cards → Human Lock → Quality Gate → Compile → Validate → Export
50
58
  ```
51
59
 
60
+ For distillation-first authoring, the flow starts with an explicit target:
61
+
62
+ ```
63
+ Declare Domain + Scope → Import Evidence → Classify Relevance → Distill Candidates
64
+ → Scope Gate → Human Review → Promote to Cards → Human Lock → Compile → Export
65
+ ```
66
+
67
+ A single `.kdna` asset should stay scoped to one domain and loading condition. If a task needs several judgment domains, create multiple domain assets and compose them through a KDNA Cluster rather than making one broad file.
68
+
52
69
  ## Install
53
70
 
54
71
  ```bash
@@ -63,6 +80,17 @@ The command-line authoring entry is a separate package:
63
80
  npm install -g @aikdna/kdna-studio-cli
64
81
  kdna-studio create my_domain --name @yourscope/my_domain
65
82
  kdna-studio import my_domain ./notes.md
83
+ kdna-studio target declare my_domain \
84
+ --category expression_writing \
85
+ --scope personal \
86
+ --granularity core_principles \
87
+ --task "longform article review" \
88
+ --include "argument structure,tone,revision" \
89
+ --exclude "life habits,food preference"
90
+ kdna-studio source classify my_domain
91
+ kdna-studio distill my_domain --candidates candidates.json
92
+ kdna-studio candidate accept my_domain <candidate-id>
93
+ kdna-studio candidate promote my_domain
66
94
  kdna-studio card add my_domain axiom \
67
95
  --field one_sentence="Judgment principle" \
68
96
  --field full_statement="What the agent should do differently" \
@@ -79,15 +107,31 @@ kdna publish dist/my_domain.kdna
79
107
  ## Quick Start
80
108
 
81
109
  ```js
82
- const { createProject, createCard, lockCard, compileDomain } = require('@aikdna/kdna-studio-core');
110
+ const {
111
+ project: projectApi,
112
+ cards: cardApi,
113
+ distillation
114
+ } = require('@aikdna/kdna-studio-core');
83
115
 
84
116
  // 1. Create a project
85
- const project = createProject('writing_judgment', 'domain', {
117
+ const project = projectApi.createProject('writing_judgment', 'domain', {
86
118
  author: { name: 'Writing Expert', id: 'writer_001' }
87
119
  });
88
120
 
121
+ // Optional: declare a distillation target before extracting from evidence.
122
+ const target = distillation.createDistillationTarget({
123
+ domainName: 'writing_judgment',
124
+ domainCategory: 'expression_writing',
125
+ ownerScope: 'personal',
126
+ granularity: 'core_principles',
127
+ taskScope: 'longform article diagnosis and revision',
128
+ includeAreas: ['argument structure', 'reader framing', 'evidence density'],
129
+ excludeAreas: ['life habits', 'food preference']
130
+ });
131
+ project.distillation_target = target;
132
+
89
133
  // 2. Create judgment cards
90
- const card = createCard('axiom', {
134
+ const card = cardApi.createCard('axiom', {
91
135
  one_sentence: 'Most writing problems are structural, not language-level.',
92
136
  full_statement: 'When reviewing content, diagnose structure before language.',
93
137
  why: 'Surface polishing on structurally weak content wastes effort.',
@@ -98,18 +142,17 @@ const card = createCard('axiom', {
98
142
  project.cards.push(card);
99
143
 
100
144
  // 3. Human Lock
101
- const locked = lockCard(card, {
145
+ const locked = cardApi.lockCard(card, {
102
146
  by: 'writer_001',
103
147
  statement: 'This represents my professional writing judgment.',
104
148
  checked: { applies_when: true, does_not_apply_when: true, failure_risk: true }
105
149
  });
106
150
 
107
151
  // 4. Check readiness
108
- const { checkHumanLockGate, exportProject } = require('@aikdna/kdna-studio-core');
109
- const gate = checkHumanLockGate(project);
152
+ const gate = projectApi.checkHumanLockGate(project);
110
153
  if (!gate.blocked) {
111
154
  // 5. Export
112
- const json = exportProject(project);
155
+ const json = projectApi.exportProject(project);
113
156
  console.log('Ready to publish');
114
157
  }
115
158
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-core",
3
- "version": "1.4.2",
3
+ "version": "1.5.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",
@@ -24,7 +24,8 @@
24
24
  "validate"
25
25
  ],
26
26
  "dependencies": {
27
- "@aikdna/kdna-core": "^0.6.0"
27
+ "@aikdna/kdna-core": "^0.8.0",
28
+ "cbor-x": "^1.6.4"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=18"
@@ -66,7 +66,7 @@ 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
69
+ // Schema gate per KDNA SPEC
70
70
  if (card.type === 'axiom') {
71
71
  if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
72
72
  throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short full_statement. SPEC requires a complete, testable explanation.`);
@@ -75,6 +75,11 @@ function lockCard(card, lockPayload) {
75
75
  throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short why. SPEC requires an explanation of failure mode.`);
76
76
  }
77
77
  }
78
+ if (card.type === 'misunderstanding') {
79
+ if (!card.fields?.key_distinction || card.fields.key_distinction.length < 20) {
80
+ throw new Error(`Misunderstanding ${card.id} cannot be locked: missing or too-short key_distinction. SPEC requires a clear conceptual boundary.`);
81
+ }
82
+ }
78
83
 
79
84
  const lockedCard = { ...card, fields: { ...card.fields } };
80
85
  lockedCard.human_lock = {
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * Compile locked cards into KDNA domain JSON files — SPEC-compatible output.
3
3
  *
4
- * KDNA SPEC v1.0-rc requirements:
5
- * - Every file MUST have meta: { version, domain, created, purpose, load_condition }
6
- * - Minimum output: KDNA_Core.json + KDNA_Patterns.json
7
- * - Maximum 6 KDNA JSON files per domain
8
- * - KDNA_Scenarios.json: { meta, scenes[] }
9
- * - KDNA_Reasoning.json: { meta, reasoning_chains[] }
10
- * - KDNA_Evolution.json: { meta, stages[], capability_layers[], measurements[] }
4
+ * KDNA Container v2:
5
+ * - Judgment content is encoded as CBOR payload (payload.kdnab)
6
+ * - Individual KDNA_Core.json etc. are NOT exposed as ZIP entries
7
+ * - kdna.json manifest contains metadata only, no judgment content
11
8
  *
12
9
  * Only locked cards enter compilation. Draft/Revised excluded silently.
13
10
  */
11
+
12
+ const cbor = require('cbor-x');
14
13
  const crypto = require('crypto');
15
14
 
16
15
  function uuidv7() {
@@ -240,8 +239,8 @@ function compileManifest(project, files, identity = null) {
240
239
  .digest('hex');
241
240
  const manifest = {
242
241
  format: 'kdna',
243
- format_version: '1.0',
244
- spec_version: '1.0-rc',
242
+ format_version: '2.0',
243
+ spec_version: '2.0',
245
244
  name: project.name,
246
245
  domain_id: assetIdentity.domain_id,
247
246
  asset_uid: assetIdentity.asset_uid,
@@ -259,6 +258,17 @@ function compileManifest(project, files, identity = null) {
259
258
  license: project.license || { type: 'CC-BY-4.0' },
260
259
  description: project.release?.description || project.name,
261
260
  file_count: kdnaFileCount,
261
+ container: {
262
+ type: 'kdna-container-v2',
263
+ payload: 'payload.kdnab',
264
+ payload_encoding: 'cbor',
265
+ payload_schema: 'kdna-payload-v2',
266
+ payload_digest: `sha256:${crypto.createHash('sha256').update(files['payload.kdnab']).digest('hex')}`,
267
+ },
268
+ runtime: {
269
+ min_runtime_version: '0.3.0',
270
+ load_contract: 'context-capsule-v1',
271
+ },
262
272
  creator: project.creator_identity ? {
263
273
  creator_id: project.creator_identity.creator_id,
264
274
  display_name: project.creator_identity.display_name,
@@ -415,12 +425,21 @@ function compileDomain(project) {
415
425
  const evolution = compileEvolution(cards, project);
416
426
 
417
427
  const files = {};
418
- files['KDNA_Core.json'] = JSON.stringify(core, null, 2);
419
- files['KDNA_Patterns.json'] = JSON.stringify(patterns, null, 2);
420
- if (scenarios) files['KDNA_Scenarios.json'] = JSON.stringify(scenarios, null, 2);
421
- if (cases) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
422
- if (reasoning) files['KDNA_Reasoning.json'] = JSON.stringify(reasoning, null, 2);
423
- if (evolution) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
428
+
429
+ // ── KDNA Container v2: encode judgment as single CBOR payload ──
430
+ const payload = {
431
+ kind: 'kdna.payload',
432
+ payload_version: '2.0',
433
+ domain: { name: project.name, version: (project.release && project.release.version) || '0.1.0' },
434
+ judgment: { core, patterns },
435
+ profiles: {},
436
+ integrity: {},
437
+ };
438
+ if (scenarios) payload.judgment.scenarios = scenarios;
439
+ if (cases) payload.judgment.cases = cases;
440
+ if (reasoning) payload.judgment.reasoning = reasoning;
441
+ if (evolution) payload.judgment.evolution = evolution;
442
+ files['payload.kdnab'] = cbor.encode(payload);
424
443
 
425
444
  // ── KDNA Card (governance metadata) ─────────────────────────────
426
445
  // Must be added BEFORE digest computation so it is included in content_digest.
@@ -520,7 +539,7 @@ function generateReadme(project, options = {}) {
520
539
 
521
540
  if (lockedSelfChecks.length > 0) {
522
541
  lines.push('## Eval Score'); lines.push('');
523
- lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 5 ? 'tested' : 'untested'}`);
542
+ lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 3 ? 'tested' : 'untested'}`);
524
543
  lines.push(`- eval cases: ${tests.length}`);
525
544
  lines.push('');
526
545
  }
@@ -34,9 +34,10 @@ function creatorFingerprint(publicKeyPem) {
34
34
 
35
35
  /**
36
36
  * Generate a new Ed25519 keypair and persist to identityDir.
37
- * Returns the creator identity object. Does NOT overwrite existing keys.
37
+ * If passphrase is provided, the private key is encrypted with AES-256-GCM
38
+ * (key derived via PBKDF2-SHA256). Without passphrase, plaintext with 0o600.
38
39
  */
39
- function initIdentity(displayName, identityDir = null) {
40
+ function initIdentity(displayName, identityDir = null, passphrase = null) {
40
41
  const dir = identityDir || defaultIdentityDir();
41
42
  fs.mkdirSync(dir, { recursive: true });
42
43
 
@@ -55,7 +56,11 @@ function initIdentity(displayName, identityDir = null) {
55
56
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
56
57
  });
57
58
 
58
- fs.writeFileSync(privateKeyPath, privateKey, { mode: 0o600 });
59
+ const privateKeyData = passphrase
60
+ ? encryptPrivateKey(privateKey, passphrase)
61
+ : privateKey;
62
+
63
+ fs.writeFileSync(privateKeyPath, privateKeyData, { mode: 0o600 });
59
64
  fs.writeFileSync(publicKeyPath, publicKey, { mode: 0o644 });
60
65
 
61
66
  const creatorId = creatorFingerprint(publicKey);
@@ -67,6 +72,7 @@ function initIdentity(displayName, identityDir = null) {
67
72
  identity_dir: dir,
68
73
  created_at: new Date().toISOString(),
69
74
  verified: false,
75
+ encrypted: !!passphrase,
70
76
  };
71
77
 
72
78
  fs.writeFileSync(identityJsonPath, JSON.stringify(identity, null, 2), { mode: 0o644 });
@@ -106,8 +112,9 @@ function loadIdentity(identityDir = null) {
106
112
  /**
107
113
  * Sign a payload with the creator's Ed25519 private key.
108
114
  * Returns the signature in "ed25519:<hex>" format.
115
+ * If the key is encrypted, passphrase is required.
109
116
  */
110
- function signPayload(payload, identityDir = null) {
117
+ function signPayload(payload, identityDir = null, passphrase = null) {
111
118
  const dir = identityDir || defaultIdentityDir();
112
119
  const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
113
120
 
@@ -117,21 +124,134 @@ function signPayload(payload, identityDir = null) {
117
124
  );
118
125
  }
119
126
 
120
- const privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
127
+ let privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
128
+ if (isEncryptedKey(privateKeyPem)) {
129
+ if (!passphrase) throw new Error('Private key is encrypted. Provide --passphrase to sign.');
130
+ privateKeyPem = decryptPrivateKey(privateKeyPem, passphrase);
131
+ }
132
+
121
133
  const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
122
134
  const sig = crypto.sign(null, data, privateKeyPem);
123
135
  return `ed25519:${sig.toString('hex')}`;
124
136
  }
125
137
 
126
138
  /**
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}`
139
+ * Sign a Human Lock payload.
140
+ * If the key is encrypted, passphrase is required.
131
141
  */
132
- function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null) {
142
+ function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null, passphrase = null) {
133
143
  const lockPayload = [cardId, statement, judgmentFingerprint].join('\n');
134
- return signPayload(lockPayload, identityDir);
144
+ return signPayload(lockPayload, identityDir, passphrase);
145
+ }
146
+
147
+ // ── Private Key Encryption ────────────────────────────────────────
148
+
149
+ function encryptPrivateKey(pem, passphrase) {
150
+ const salt = crypto.randomBytes(16);
151
+ const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
152
+ const iv = crypto.randomBytes(12);
153
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
154
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(pem)), cipher.final()]);
155
+ return JSON.stringify({
156
+ encrypted: true,
157
+ kdf: 'pbkdf2-sha256',
158
+ iterations: 100000,
159
+ salt: salt.toString('base64'),
160
+ iv: iv.toString('base64'),
161
+ tag: cipher.getAuthTag().toString('base64'),
162
+ ciphertext: ciphertext.toString('base64'),
163
+ });
164
+ }
165
+
166
+ function decryptPrivateKey(envelope, passphrase) {
167
+ const env = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
168
+ if (!env.encrypted) throw new Error('Private key is not encrypted');
169
+ const key = crypto.pbkdf2Sync(passphrase, Buffer.from(env.salt, 'base64'), env.iterations, 32, 'sha256');
170
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
171
+ decipher.setAuthTag(Buffer.from(env.tag, 'base64'));
172
+ try {
173
+ return Buffer.concat([decipher.update(Buffer.from(env.ciphertext, 'base64')), decipher.final()]).toString('utf8');
174
+ } catch (e) {
175
+ if (e.code === 'ERR_OSSL_EVP_BAD_DECRYPT' || e.message.includes('bad decrypt')) {
176
+ throw new Error('Wrong passphrase — cannot decrypt private key.');
177
+ }
178
+ throw e;
179
+ }
180
+ }
181
+
182
+ function isEncryptedKey(content) {
183
+ try { const o = JSON.parse(content); return o.encrypted === true; }
184
+ catch { return false; }
185
+ }
186
+
187
+ // ── Key Rotation ────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Rotate the creator's private key. Generates a new Ed25519 keypair,
191
+ * signs the rotation event with the old key, and saves both.
192
+ * The old public key is recorded in creator.json under previous_keys.
193
+ */
194
+ function rotateIdentity(passphrase = null, identityDir = null) {
195
+ const identity = loadIdentity(identityDir);
196
+ if (!identity) throw new Error('No identity found. Run identity init first.');
197
+
198
+ const dir = identityDir || defaultIdentityDir();
199
+ const oldPrivPath = path.join(dir, PRIVATE_KEY_FILE);
200
+ const oldPubPath = path.join(dir, PUBLIC_KEY_FILE);
201
+
202
+ if (!fs.existsSync(oldPrivPath) || !fs.existsSync(oldPubPath)) {
203
+ throw new Error('Key files not found for rotation.');
204
+ }
205
+
206
+ // Decrypt old key if needed
207
+ let oldPrivatePem = fs.readFileSync(oldPrivPath, 'utf8');
208
+ if (isEncryptedKey(oldPrivatePem)) {
209
+ if (!passphrase) throw new Error('Private key is encrypted. Provide passphrase to rotate.');
210
+ oldPrivatePem = decryptPrivateKey(oldPrivatePem, passphrase);
211
+ }
212
+
213
+ // Generate new keypair
214
+ const { publicKey: newPub, privateKey: newPriv } = crypto.generateKeyPairSync('ed25519', {
215
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
216
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
217
+ });
218
+
219
+ // Sign rotation event with old key
220
+ const rotationPayload = [
221
+ identity.creator_id,
222
+ creatorFingerprint(newPub),
223
+ new Date().toISOString(),
224
+ ].join('\n');
225
+ const rotationSig = `ed25519:${crypto.sign(null, Buffer.from(rotationPayload), oldPrivatePem).toString('hex')}`;
226
+
227
+ // Save new keypair
228
+ const newPrivData = passphrase ? encryptPrivateKey(newPriv, passphrase) : newPriv;
229
+ fs.writeFileSync(oldPrivPath, newPrivData, { mode: 0o600 });
230
+ fs.writeFileSync(oldPubPath, newPub, { mode: 0o644 });
231
+
232
+ // Update identity record
233
+ const newCreatorId = creatorFingerprint(newPub);
234
+ const identityJson = path.join(dir, IDENTITY_JSON_FILE);
235
+ let idData = {};
236
+ try { idData = JSON.parse(fs.readFileSync(identityJson, 'utf8')); } catch {}
237
+
238
+ const previousKeys = idData.previous_keys || [];
239
+ previousKeys.push({
240
+ creator_id: identity.creator_id,
241
+ public_key: identity.public_key,
242
+ rotated_at: new Date().toISOString(),
243
+ rotation_signature: rotationSig,
244
+ });
245
+
246
+ idData.creator_id = newCreatorId;
247
+ idData.public_key = newPub;
248
+ idData.rotated_at = new Date().toISOString();
249
+ idData.encrypted = !!passphrase;
250
+ idData.previous_keys = previousKeys;
251
+
252
+ fs.writeFileSync(identityJson, JSON.stringify(idData, null, 2), { mode: 0o644 });
253
+
254
+ return { ...idData, creator_id: newCreatorId };
135
255
  }
136
256
 
137
257
  /**
@@ -160,5 +280,8 @@ module.exports = {
160
280
  loadPublicKey,
161
281
  privateKeyPath,
162
282
  defaultIdentityDir,
283
+ encryptPrivateKey,
284
+ decryptPrivateKey,
285
+ isEncryptedKey,
163
286
  CREATOR_ID_PREFIX,
164
287
  };
@@ -0,0 +1,268 @@
1
+ // Domain-First Distillation — Core API
2
+ // Aligned with SOURCE_DISTILLATION_CONTRACT.md v0.2
3
+
4
+ // ─── Domain Taxonomy ───────────────────────────────────────────────
5
+
6
+ const DOMAIN_CATEGORIES = Object.freeze({
7
+ expression_writing: {
8
+ id: 'expression_writing',
9
+ displayName: 'Expression & Writing',
10
+ examples: ['writing_style', 'blog_voice', 'social_media_tone', 'article_structure'],
11
+ },
12
+ aesthetic_creation: {
13
+ id: 'aesthetic_creation',
14
+ displayName: 'Aesthetic & Creation',
15
+ examples: ['visual_design', 'video_rhythm', 'cover_art', 'brand_aesthetics'],
16
+ },
17
+ professional_field: {
18
+ id: 'professional_field',
19
+ displayName: 'Professional Field',
20
+ examples: ['legal_judgment', 'medical_diagnosis', 'education_standards'],
21
+ },
22
+ decision_preference: {
23
+ id: 'decision_preference',
24
+ displayName: 'Decision Preference',
25
+ examples: ['product_decisions', 'investment_criteria', 'prioritization_methods'],
26
+ },
27
+ communication_style: {
28
+ id: 'communication_style',
29
+ displayName: 'Communication Style',
30
+ examples: ['client_communication', 'team_management', 'conflict_handling'],
31
+ },
32
+ workflow_process: {
33
+ id: 'workflow_process',
34
+ displayName: 'Workflow & Process',
35
+ examples: ['project_reviews', 'meeting_standards', 'sales_followups'],
36
+ },
37
+ life_preference: {
38
+ id: 'life_preference',
39
+ displayName: 'Life Preference',
40
+ examples: ['schedule_preferences', 'learning_habits', 'consumption_choices'],
41
+ },
42
+ team_organization: {
43
+ id: 'team_organization',
44
+ displayName: 'Team & Organization',
45
+ examples: ['team_brand', 'hiring_criteria', 'service_standards'],
46
+ },
47
+ });
48
+
49
+ const OWNER_SCOPES = Object.freeze({
50
+ personal: {
51
+ id: 'personal',
52
+ displayName: 'Personal',
53
+ description: 'One person\'s individual standards. Extracts personal preferences, boundaries, taste.',
54
+ },
55
+ team: {
56
+ id: 'team',
57
+ displayName: 'Team',
58
+ description: 'Shared team conventions. Extracts team-wide standards, agreed practices.',
59
+ },
60
+ organization: {
61
+ id: 'organization',
62
+ displayName: 'Organization',
63
+ description: 'Company/organization policies. Extracts organizational values, compliance boundaries.',
64
+ },
65
+ field: {
66
+ id: 'field',
67
+ displayName: 'Industry / Field',
68
+ description: 'Industry/profession-wide. Extracts domain expertise beyond any single practitioner.',
69
+ },
70
+ });
71
+
72
+ const GRANULARITY_LEVELS = Object.freeze({
73
+ core_principles: {
74
+ id: 'core_principles',
75
+ displayName: 'Core Principles',
76
+ description: 'High-level axioms and boundaries. Foundational beliefs, what the person consistently prioritizes and rejects.',
77
+ },
78
+ concrete_patterns: {
79
+ id: 'concrete_patterns',
80
+ displayName: 'Concrete Patterns',
81
+ description: 'Recurring decision patterns. Specific rules and detectable habits.',
82
+ },
83
+ specific_scenarios: {
84
+ id: 'specific_scenarios',
85
+ displayName: 'Specific Scenarios',
86
+ description: 'Scenario-level triggers. Context-specific judgment triggers and responses.',
87
+ },
88
+ });
89
+
90
+ const EVIDENCE_RELEVANCE = Object.freeze({
91
+ relevant: { id: 'relevant', label: 'Relevant' },
92
+ weakly_relevant: { id: 'weakly_relevant', label: 'Weakly Relevant' },
93
+ out_of_scope: { id: 'out_of_scope', label: 'Out of Scope' },
94
+ split_domain: { id: 'split_domain', label: 'Split Domain' },
95
+ });
96
+
97
+ // ─── Distillation Target ───────────────────────────────────────────
98
+
99
+ function createDistillationTarget({
100
+ domainName,
101
+ domainCategory = 'expression_writing',
102
+ ownerScope = 'personal',
103
+ granularity = 'core_principles',
104
+ taskScope = '',
105
+ includeAreas = [],
106
+ excludeAreas = [],
107
+ loadCondition = '',
108
+ }) {
109
+ if (!domainName || typeof domainName !== 'string') {
110
+ throw new Error('domainName is required');
111
+ }
112
+ if (!DOMAIN_CATEGORIES[domainCategory]) {
113
+ throw new Error(`Invalid domainCategory: ${domainCategory}. Must be one of: ${Object.keys(DOMAIN_CATEGORIES).join(', ')}`);
114
+ }
115
+ if (!OWNER_SCOPES[ownerScope]) {
116
+ throw new Error(`Invalid ownerScope: ${ownerScope}`);
117
+ }
118
+ if (!GRANULARITY_LEVELS[granularity]) {
119
+ throw new Error(`Invalid granularity: ${granularity}`);
120
+ }
121
+
122
+ return {
123
+ id: `tgt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
124
+ domain_name: domainName,
125
+ domain_category: domainCategory,
126
+ owner_scope: ownerScope,
127
+ granularity,
128
+ task_scope: taskScope,
129
+ include_areas: includeAreas,
130
+ exclude_areas: excludeAreas,
131
+ load_condition: loadCondition || `Load when the task involves ${DOMAIN_CATEGORIES[domainCategory].displayName.toLowerCase()} judgment.`,
132
+ declared_at: new Date().toISOString(),
133
+ };
134
+ }
135
+
136
+ function validateDistillationTarget(target) {
137
+ const errors = [];
138
+ if (!target || !target.domain_name) errors.push('domain_name is required');
139
+ if (!DOMAIN_CATEGORIES[target.domain_category]) errors.push(`invalid domain_category: ${target.domain_category}`);
140
+ if (!OWNER_SCOPES[target.owner_scope]) errors.push(`invalid owner_scope: ${target.owner_scope}`);
141
+ if (!GRANULARITY_LEVELS[target.granularity]) errors.push(`invalid granularity: ${target.granularity}`);
142
+ if (!target.task_scope || target.task_scope.trim().length === 0) errors.push('task_scope is required');
143
+ return { valid: errors.length === 0, errors };
144
+ }
145
+
146
+ function targetScopeDescription(target) {
147
+ const cat = DOMAIN_CATEGORIES[target.domain_category];
148
+ const scope = OWNER_SCOPES[target.owner_scope];
149
+ const gran = GRANULARITY_LEVELS[target.granularity];
150
+ return `Domain: ${cat.displayName}. Scope: ${scope.displayName}. Granularity: ${gran.displayName}. Task: ${target.task_scope}. ${target.load_condition}`;
151
+ }
152
+
153
+ // ─── Scope Gate ────────────────────────────────────────────────────
154
+
155
+ function applyScopeGate(candidate, target) {
156
+ const text = `${candidate.one_sentence || ''} ${candidate.full_statement || ''}`.toLowerCase();
157
+ const domainWords = [target.domain_category, ...(target.include_areas || [])];
158
+
159
+ let scopeFit = true;
160
+ let relevanceScore = 50;
161
+ let relevanceEvidence = null;
162
+ let suggestedSplitDomain = null;
163
+
164
+ const domainMatch = domainWords.some(w => text.includes(w.toLowerCase()));
165
+ const excludeMatch = (target.exclude_areas || []).some(w => text.includes(w.toLowerCase()));
166
+
167
+ if (domainMatch && !excludeMatch) {
168
+ scopeFit = true;
169
+ relevanceScore = 80;
170
+ } else if (!domainMatch && (target.include_areas || []).length > 0) {
171
+ scopeFit = false;
172
+ relevanceScore = 20;
173
+ relevanceEvidence = 'No match with declared include areas';
174
+ } else if (excludeMatch) {
175
+ scopeFit = false;
176
+ relevanceScore = 10;
177
+ relevanceEvidence = 'Matched exclude area';
178
+ }
179
+
180
+ if (!scopeFit) {
181
+ for (const [catId, cat] of Object.entries(DOMAIN_CATEGORIES)) {
182
+ if (catId !== target.domain_category) {
183
+ if (text.includes(catId) || text.includes(cat.displayName.toLowerCase())) {
184
+ suggestedSplitDomain = cat.displayName;
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ return {
192
+ ...candidate,
193
+ scope_fit: scopeFit,
194
+ domain_relevance_score: relevanceScore,
195
+ relevance_evidence: relevanceEvidence,
196
+ suggested_split_domain: suggestedSplitDomain,
197
+ };
198
+ }
199
+
200
+ function candidateStatusSummary(candidates) {
201
+ let proposed = 0, accepted = 0, rejected = 0, modified = 0, blocked = 0, outOfScope = 0;
202
+ for (const c of candidates) {
203
+ if (c.sensitive_content_flag) { blocked++; continue; }
204
+ if (!c.scope_fit) { outOfScope++; }
205
+ switch (c.status || c.candidate_status) {
206
+ case 'proposed': proposed++; break;
207
+ case 'accepted': accepted++; break;
208
+ case 'rejected': rejected++; break;
209
+ case 'modified': modified++; break;
210
+ }
211
+ }
212
+ return { proposed, accepted, rejected, modified, blocked, outOfScope };
213
+ }
214
+
215
+ // ─── Sensitive Inference Filter ────────────────────────────────────
216
+
217
+ const SENSITIVE_KEYWORDS = {
218
+ identity: [
219
+ 'gender identity', 'sexual orientation', 'ethnicity', 'race', 'racial',
220
+ '性别认同', '性取向', '种族', '民族',
221
+ ],
222
+ health: [
223
+ 'medical condition', 'mental health', 'disability', 'diagnosis', 'medication', 'therapy', 'treatment',
224
+ '疾病', '病史', '诊断', '药物', '治疗', '心理疾病', '精神健康', '残疾', '残障',
225
+ ],
226
+ political: [
227
+ 'political affiliation', 'voting', 'activist', 'party member',
228
+ '政治立场', '党派', '党员', '政治倾向',
229
+ ],
230
+ religious: [
231
+ 'religious belief', 'faith', 'church', 'prayer', 'worship', 'spiritual practice',
232
+ '宗教信仰', '教会', '祈祷', '礼拜', '信教',
233
+ ],
234
+ financial: [
235
+ 'income', 'net worth', 'salary', 'debt', 'bank account', 'savings amount',
236
+ '收入', '工资', '存款', '负债', '资产净值', '银行卡号', '账户余额',
237
+ ],
238
+ intimate: [
239
+ 'relationship status', 'marriage', 'divorce', 'family structure',
240
+ '婚姻状况', '离婚', '家庭结构', '感情状况', '亲密关系',
241
+ ],
242
+ };
243
+
244
+ function checkSensitiveContent(text) {
245
+ const lower = text.toLowerCase();
246
+ for (const [domain, keywords] of Object.entries(SENSITIVE_KEYWORDS)) {
247
+ for (const keyword of keywords) {
248
+ if (lower.includes(keyword.toLowerCase())) {
249
+ return { flagged: true, reason: `May involve sensitive domain: ${domain}` };
250
+ }
251
+ }
252
+ }
253
+ return { flagged: false, reason: null };
254
+ }
255
+
256
+ module.exports = {
257
+ DOMAIN_CATEGORIES,
258
+ OWNER_SCOPES,
259
+ GRANULARITY_LEVELS,
260
+ EVIDENCE_RELEVANCE,
261
+ SENSITIVE_KEYWORDS,
262
+ createDistillationTarget,
263
+ validateDistillationTarget,
264
+ targetScopeDescription,
265
+ applyScopeGate,
266
+ candidateStatusSummary,
267
+ checkSensitiveContent,
268
+ };
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ const provenance = require('./provenance');
30
30
  const quality = require('./quality');
31
31
  const testlab = require('./testlab');
32
32
  const versioning = require('./versioning');
33
+ const distillation = require('./distillation');
33
34
  const feynman = require('./cards/feynman');
34
35
  const contradiction = require('./quality/contradiction');
35
36
  const validateCards = require('./quality/validate-cards');
@@ -46,6 +47,7 @@ module.exports = {
46
47
  governance,
47
48
  i18n,
48
49
  creator: creatorIdentity,
50
+ distillation,
49
51
 
50
52
  // Experimental
51
53
  evidence,