@aikdna/kdna-studio-core 1.4.2 → 1.5.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/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
  ```
@@ -162,7 +205,7 @@ KDNA Studio Core is open source. Official KDNA Studio App, hosted collaboration,
162
205
 
163
206
  ## Related
164
207
 
165
- - [KDNA Protocol](https://github.com/aikdna/kdna) — Specification
208
+ - [KDNA Core](https://github.com/aikdna/kdna) — Official format
166
209
  - [kdna-cli](https://github.com/aikdna/kdna-cli) — CLI tools
167
210
  - [kdna-core-swift](https://github.com/aikdna/kdna-core-swift) — Swift runtime for macOS/iOS
168
211
  - [aikdna.com](https://aikdna.com) — Website
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.1",
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.9.1",
28
+ "cbor-x": "^1.6.4"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=18"
@@ -12,7 +12,7 @@
12
12
  const { cardJudgmentFingerprint } = require('../judgment-fields');
13
13
 
14
14
  const VALID_STATES = ['draft', 'revised', 'locked', 'tested', 'published', 'deprecated'];
15
- const CARD_TYPES = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case'];
15
+ const CARD_TYPES = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case', 'stance', 'framework', 'term', 'banned_term', 'reasoning', 'evolution_stage'];
16
16
 
17
17
  const TRANSITIONS = {
18
18
  draft: ['revised', 'deprecated'],
@@ -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:
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,
@@ -405,8 +415,28 @@ function buildReports(project, files, identity, provenance, stats) {
405
415
  };
406
416
  }
407
417
 
408
- function compileDomain(project) {
418
+ function compileDomain(project, options = {}) {
409
419
  const cards = project.cards || [];
420
+
421
+ // ── Empty-domain gate (PR-2) ────────────────────────────────────
422
+ // A KDNA domain with no locked judgment content of any kind is not a
423
+ // domain — it is a content-shaped empty file. Refuse to compile so the
424
+ // downstream Registry / Lab / Studio export never advertises an empty
425
+ // judgment asset as "successfully compiled".
426
+ const lockedCards = cards.filter(c => c.locked);
427
+ const hasJudgmentContent = lockedCards.some(c =>
428
+ ['axiom', 'misunderstanding', 'scenario', 'case', 'self_check', 'boundary', 'risk', 'ontology', 'aesthetic'].includes(c.type)
429
+ );
430
+ if (!hasJudgmentContent) {
431
+ const err = new Error(
432
+ 'refusing to compile empty KDNA domain: no locked judgment content ' +
433
+ `(axiom / misunderstanding / scenario / case / self_check / boundary / risk / ontology / aesthetic). ` +
434
+ `Found ${lockedCards.length} locked card(s) and ${cards.length} total card(s).`
435
+ );
436
+ err.code = 'EMPTY_DOMAIN';
437
+ throw err;
438
+ }
439
+
410
440
  const core = compileCore(cards, project);
411
441
  const patterns = compilePatterns(cards, project);
412
442
  const scenarios = compileScenarios(cards, project);
@@ -414,13 +444,50 @@ function compileDomain(project) {
414
444
  const reasoning = compileReasoning(cards, project);
415
445
  const evolution = compileEvolution(cards, project);
416
446
 
447
+ // ── RFC-0013 §3.1/§3.2 Compile Gates (PR-3) ───────────────────
448
+ // Run the Source Authority Graph gate and the Truth Charter gate
449
+ // BEFORE packaging. PR-2: strictAuthority now defaults to true so
450
+ // missing/unstable SAG/TC are surfaced as gate errors that block
451
+ // compilation. Pass options.strictAuthority = false to opt out
452
+ // (legacy workspaces only).
453
+ const strictAuthority = options.strictAuthority !== false;
454
+ const { runSagGate } = require('./source-authority-gate');
455
+ const { runTcGate } = require('./truth-charter-gate');
456
+ const sag = runSagGate(options.sourceAuthority, { strict: strictAuthority });
457
+ const tc = runTcGate(options.truthCharter, {
458
+ strict: strictAuthority,
459
+ sourceAuthority: options.sourceAuthority || null,
460
+ patterns: patterns || null,
461
+ });
462
+ const gates = { sag, tc, strict_authority: strictAuthority };
463
+ // Gate policy: strictAuthority=true + any gate.status === 'fail' => throw.
464
+ if (strictAuthority && (sag.status === 'fail' || tc.status === 'fail')) {
465
+ const allErrors = [...sag.errors, ...tc.errors];
466
+ const err = new Error(
467
+ `Strict-authority compile failed. ${allErrors.length} gate error(s):\n` +
468
+ allErrors.map((e) => ` - ${e}`).join('\n'),
469
+ );
470
+ err.code = 'GATE_FAIL';
471
+ err.gates = gates;
472
+ throw err;
473
+ }
474
+
417
475
  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);
476
+
477
+ // Encode judgment as CBOR payload
478
+ const payload = {
479
+ kind: 'kdna.payload',
480
+ payload_version: '2.0',
481
+ domain: { name: project.name, version: (project.release && project.release.version) || '0.1.0' },
482
+ judgment: { core, patterns },
483
+ profiles: {},
484
+ integrity: {},
485
+ };
486
+ if (scenarios) payload.judgment.scenarios = scenarios;
487
+ if (cases) payload.judgment.cases = cases;
488
+ if (reasoning) payload.judgment.reasoning = reasoning;
489
+ if (evolution) payload.judgment.evolution = evolution;
490
+ files['payload.kdnab'] = cbor.encode(payload);
424
491
 
425
492
  // ── KDNA Card (governance metadata) ─────────────────────────────
426
493
  // Must be added BEFORE digest computation so it is included in content_digest.
@@ -455,6 +522,7 @@ function compileDomain(project) {
455
522
  files,
456
523
  stats,
457
524
  identity,
525
+ gates,
458
526
  };
459
527
  }
460
528
 
@@ -520,7 +588,7 @@ function generateReadme(project, options = {}) {
520
588
 
521
589
  if (lockedSelfChecks.length > 0) {
522
590
  lines.push('## Eval Score'); lines.push('');
523
- lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 5 ? 'tested' : 'untested'}`);
591
+ lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 3 ? 'tested' : 'untested'}`);
524
592
  lines.push(`- eval cases: ${tests.length}`);
525
593
  lines.push('');
526
594
  }
@@ -537,4 +605,4 @@ function generateReadme(project, options = {}) {
537
605
  return lines.join('\n');
538
606
  }
539
607
 
540
- module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileReasoning, compileEvolution, compileManifest, generateReadme, buildAssetIdentity, computeContentDigest };
608
+ module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileReasoning, compileEvolution, compileManifest, generateReadme, buildAssetIdentity, computeContentDigest, runSagGate: require('./source-authority-gate').runSagGate, runTcGate: require('./truth-charter-gate').runTcGate };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Source Authority Graph (SAG) compile gate for kdna-studio-core.
3
+ *
4
+ * Implements the SAG gate from RFC-0013 §3.1 / §9 #3.
5
+ *
6
+ * Behavior:
7
+ * - Default mode: all rules are reported as WARNINGS (soft).
8
+ * - Strict-authority mode: hard-rule violations become ERRORS.
9
+ * - If no sourceAuthority provided, gate skips silently
10
+ * (backwards-compatible: legacy workspaces without SAG pass through).
11
+ *
12
+ * Rules checked:
13
+ * R1. precedence_order entries must all reference an existing source id.
14
+ * R2. At least one source must have authority: "current_highest"
15
+ * (when SAG is present; soft warning in default mode).
16
+ * R3. authority/status consistency:
17
+ * - authority: "deprecated" requires status: "deprecated"
18
+ * - authority: "current_highest" requires status: "active"
19
+ * - deprecated sources must not appear in precedence_order.
20
+ * R4. In precedence_order (highest precedence first), the first
21
+ * current_highest must not be preceded by any lower-authority
22
+ * source.
23
+ * R5. sources_contain_pii=true without author_consent_on_file is
24
+ * a soft warning (we are not the consent authority).
25
+ *
26
+ * Output contract:
27
+ * {
28
+ * gate: 'source_authority',
29
+ * status: 'skipped' | 'pass' | 'warn' | 'fail',
30
+ * errors: [string, ...], // strict-only; empty in default mode
31
+ * warnings: [string, ...], // always populated for any rule
32
+ * source_authority: object | null,
33
+ * strict_authority: boolean
34
+ * }
35
+ */
36
+
37
+ const AUTHORITY_RANK = {
38
+ current_highest: 4,
39
+ thought_mine: 3,
40
+ historical_baseline: 2,
41
+ exemplar_case: 1,
42
+ deprecated: 0,
43
+ };
44
+
45
+ function isPlainObject(v) {
46
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
47
+ }
48
+
49
+ function runSagGate(sourceAuthority, opts = {}) {
50
+ const strict = !!opts.strict;
51
+ const result = {
52
+ gate: 'source_authority',
53
+ status: 'skipped',
54
+ errors: [],
55
+ warnings: [],
56
+ source_authority: sourceAuthority || null,
57
+ strict_authority: strict,
58
+ };
59
+
60
+ if (!isPlainObject(sourceAuthority)) {
61
+ result.status = 'skipped';
62
+ result.warnings.push('No source_authority.json provided; SAG gate skipped.');
63
+ return result;
64
+ }
65
+
66
+ // R1. precedence_order references must be valid source ids.
67
+ const sources = Array.isArray(sourceAuthority.sources) ? sourceAuthority.sources : [];
68
+ const sourceIds = new Set();
69
+ for (const s of sources) {
70
+ if (isPlainObject(s) && typeof s.id === 'string') {
71
+ sourceIds.add(s.id);
72
+ }
73
+ }
74
+ const order = Array.isArray(sourceAuthority.precedence_order) ? sourceAuthority.precedence_order : [];
75
+ const missing = order.filter((id) => !sourceIds.has(id));
76
+ if (missing.length > 0) {
77
+ const msg = `source_authority.json: precedence_order references unknown source id(s): ${missing.join(', ')}.`;
78
+ if (strict) result.errors.push(msg);
79
+ else result.warnings.push(msg);
80
+ }
81
+
82
+ // R2. At least one current_highest source.
83
+ const highest = sources.filter(
84
+ (s) => isPlainObject(s) && s.authority === 'current_highest',
85
+ );
86
+ if (highest.length === 0) {
87
+ const msg = 'source_authority.json: no source has authority "current_highest"; at least one current_highest source is required to establish current authority.';
88
+ if (strict) result.errors.push(msg);
89
+ else result.warnings.push(msg);
90
+ }
91
+
92
+ // R3. authority/status consistency.
93
+ for (const s of sources) {
94
+ if (!isPlainObject(s)) continue;
95
+ if (s.authority === 'deprecated' && s.status !== 'deprecated') {
96
+ const msg = `source_authority.json: source "${s.id}" has authority "deprecated" but status is "${s.status}"; status MUST be "deprecated" when authority is "deprecated".`;
97
+ if (strict) result.errors.push(msg);
98
+ else result.warnings.push(msg);
99
+ }
100
+ if (s.authority === 'current_highest' && s.status !== 'active') {
101
+ const msg = `source_authority.json: source "${s.id}" has authority "current_highest" but status is "${s.status}"; status MUST be "active" for current_highest sources.`;
102
+ if (strict) result.errors.push(msg);
103
+ else result.warnings.push(msg);
104
+ }
105
+ if (s.authority === 'deprecated' && order.includes(s.id)) {
106
+ const msg = `source_authority.json: deprecated source "${s.id}" appears in precedence_order; deprecated sources cannot be authoritative precursors.`;
107
+ if (strict) result.errors.push(msg);
108
+ else result.warnings.push(msg);
109
+ }
110
+ }
111
+
112
+ // R4. In precedence_order, the first current_highest must not be
113
+ // preceded by any lower-authority source.
114
+ if (order.length > 0 && highest.length > 0) {
115
+ const firstHighestIdx = Math.min(
116
+ ...order
117
+ .map((id, i) => ({ id, i, isHighest: highest.some((h) => h.id === id) }))
118
+ .filter((e) => e.isHighest)
119
+ .map((e) => e.i),
120
+ );
121
+ if (Number.isFinite(firstHighestIdx)) {
122
+ for (let i = 0; i < firstHighestIdx; i++) {
123
+ const id = order[i];
124
+ const src = sources.find((s) => isPlainObject(s) && s.id === id);
125
+ if (!src) continue;
126
+ if (
127
+ AUTHORITY_RANK[src.authority] !== undefined &&
128
+ AUTHORITY_RANK[src.authority] < AUTHORITY_RANK.current_highest
129
+ ) {
130
+ const msg = `source_authority.json: lower-authority source "${src.id}" (${src.authority}) appears before current_highest in precedence_order; current_highest must override.`;
131
+ if (strict) result.errors.push(msg);
132
+ else result.warnings.push(msg);
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // R5. PII without consent is a soft warning only.
139
+ const sensitivity = sourceAuthority.sensitivity || {};
140
+ if (sensitivity.sources_contain_pii === true && sensitivity.author_consent_on_file !== true) {
141
+ result.warnings.push(
142
+ 'source_authority.json: sensitivity.sources_contain_pii is true but author_consent_on_file is not true; recording author consent is recommended before publishing.',
143
+ );
144
+ }
145
+
146
+ if (result.errors.length > 0) {
147
+ result.status = 'fail';
148
+ } else if (result.warnings.length > 0) {
149
+ result.status = 'warn';
150
+ } else {
151
+ result.status = 'pass';
152
+ }
153
+ return result;
154
+ }
155
+
156
+ module.exports = { runSagGate, AUTHORITY_RANK };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Truth Charter (TC) compile gate for kdna-studio-core.
3
+ *
4
+ * Implements the TC gate from RFC-0013 §3.2 / §9 #3.
5
+ *
6
+ * Behavior:
7
+ * - Default mode: WARNING only. Errors are downgraded to warnings.
8
+ * - Strict-authority mode: ERROR for synthesized/draft/deprecated
9
+ * tc_status; PASS only for tc_status: "locked".
10
+ * - If no truthCharter provided, gate skips silently
11
+ * (backwards-compatible: legacy workspaces without TC pass through).
12
+ *
13
+ * Rules checked:
14
+ * R1. tc_status must be one of: draft / synthesized / locked / deprecated.
15
+ * R2. tc_status: "synthesized" + strict-authority -> ERROR
16
+ * (synthesized means no real author-locked truth; cannot be
17
+ * officially published without explicit lock).
18
+ * R3. tc_status: "deprecated" + strict-authority -> ERROR
19
+ * (deprecated charters cannot govern new compilations).
20
+ * R4. tc_status: "locked" requires locked_at and locked_by fields.
21
+ * R5. renamed_terms: if renamed_terms are present and a
22
+ * patterns.terminology is supplied, check that each old term is
23
+ * either in banned_terms or its replacement is in standard_terms.
24
+ * (Soft check; mismatches are warnings, not errors.)
25
+ * R6. forbidden_simplifications: presence is recorded; we do NOT
26
+ * perform LLM-based semantic verification (would require a
27
+ * judgment call outside the gate's scope). Always PASS.
28
+ *
29
+ * Cross-file consistency (when both SAG and TC are supplied):
30
+ * If sourceAuthority has any current_highest source of type
31
+ * "human_locked_charter" and TC exists, TC.judgment_authority_holder
32
+ * must be present and non-empty.
33
+ */
34
+
35
+ const VALID_TC_STATUS = ['draft', 'synthesized', 'locked', 'deprecated'];
36
+
37
+ function isPlainObject(v) {
38
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
39
+ }
40
+
41
+ function runTcGate(truthCharter, opts = {}) {
42
+ const strict = !!opts.strict;
43
+ const sourceAuthority = opts.sourceAuthority || null;
44
+ const patterns = opts.patterns || null; // KDNA_Patterns.json content
45
+ const result = {
46
+ gate: 'truth_charter',
47
+ status: 'skipped',
48
+ errors: [],
49
+ warnings: [],
50
+ truth_charter: truthCharter || null,
51
+ strict_authority: strict,
52
+ };
53
+
54
+ if (!isPlainObject(truthCharter)) {
55
+ result.status = 'skipped';
56
+ result.warnings.push('No truth_charter.json provided; TC gate skipped.');
57
+ return result;
58
+ }
59
+
60
+ // R1. tc_status must be valid.
61
+ const tcStatus = truthCharter.tc_status;
62
+ if (!VALID_TC_STATUS.includes(tcStatus)) {
63
+ result.errors.push(
64
+ `truth_charter.json: tc_status "${tcStatus}" is not one of [${VALID_TC_STATUS.join(', ')}].`,
65
+ );
66
+ result.status = 'fail';
67
+ return result;
68
+ }
69
+
70
+ // R2. synthesized + strict -> ERROR.
71
+ if (tcStatus === 'synthesized') {
72
+ const msg = 'truth_charter.json: tc_status is "synthesized" (no author-locked truth); strict-authority requires "locked".';
73
+ if (strict) result.errors.push(msg);
74
+ else result.warnings.push(msg);
75
+ }
76
+
77
+ // R3. deprecated + strict -> ERROR.
78
+ if (tcStatus === 'deprecated') {
79
+ const msg = 'truth_charter.json: tc_status is "deprecated"; deprecated charters cannot govern new compilations under strict-authority.';
80
+ if (strict) result.errors.push(msg);
81
+ else result.warnings.push(msg);
82
+ }
83
+
84
+ // R4. locked requires locked_at and locked_by.
85
+ if (tcStatus === 'locked') {
86
+ if (!truthCharter.locked_at || !truthCharter.locked_by) {
87
+ result.errors.push(
88
+ 'truth_charter.json: tc_status is "locked" but locked_at or locked_by is missing.',
89
+ );
90
+ }
91
+ }
92
+
93
+ // R5. renamed_terms soft check against patterns.terminology.
94
+ // Only fires when the project's terminology actually has content; an
95
+ // empty terminology (from a card-only project) is treated as
96
+ // "not yet declared" and the soft check is skipped.
97
+ if (
98
+ Array.isArray(truthCharter.renamed_terms) &&
99
+ isPlainObject(patterns) &&
100
+ isPlainObject(patterns.terminology) &&
101
+ (Array.isArray(patterns.terminology.standard_terms) && patterns.terminology.standard_terms.length > 0 ||
102
+ Array.isArray(patterns.terminology.banned_terms) && patterns.terminology.banned_terms.length > 0)
103
+ ) {
104
+ const term = patterns.terminology;
105
+ const banned = new Set(
106
+ Array.isArray(term.banned_terms) ? term.banned_terms.map((t) => t && t.term).filter(Boolean) : [],
107
+ );
108
+ const standard = new Set(
109
+ Array.isArray(term.standard_terms) ? term.standard_terms.map((t) => t && t.term).filter(Boolean) : [],
110
+ );
111
+ for (const r of truthCharter.renamed_terms) {
112
+ if (!isPlainObject(r)) continue;
113
+ const oldName = r.old;
114
+ const newName = r.new;
115
+ const oldBanned = oldName && banned.has(oldName);
116
+ const newStandard = newName && standard.has(newName);
117
+ if (oldName && !oldBanned) {
118
+ result.warnings.push(
119
+ `truth_charter.json renamed_terms: old term "${oldName}" is not in KDNA_Patterns.json.terminology.banned_terms; consider adding it to make the rename enforceable.`,
120
+ );
121
+ }
122
+ if (newName && !newStandard) {
123
+ result.warnings.push(
124
+ `truth_charter.json renamed_terms: new term "${newName}" is not in KDNA_Patterns.json.terminology.standard_terms; consider adding it.`,
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ // Cross-file consistency: SAG has human_locked_charter current_highest
131
+ // => TC.judgment_authority_holder must be present and non-empty.
132
+ if (sourceAuthority && isPlainObject(sourceAuthority)) {
133
+ const sources = Array.isArray(sourceAuthority.sources) ? sourceAuthority.sources : [];
134
+ const hasHumanLockedCharter = sources.some(
135
+ (s) => isPlainObject(s) && s.authority === 'current_highest' && s.type === 'human_locked_charter',
136
+ );
137
+ if (hasHumanLockedCharter) {
138
+ const holder = truthCharter.judgment_authority_holder;
139
+ if (!holder || (typeof holder === 'string' && holder.trim() === '')) {
140
+ const msg = 'truth_charter.json: SAG has a current_highest source of type human_locked_charter, but TC.judgment_authority_holder is missing or empty; cross-file consistency requires both.';
141
+ if (strict) result.errors.push(msg);
142
+ else result.warnings.push(msg);
143
+ }
144
+ }
145
+ }
146
+
147
+ if (result.errors.length > 0) {
148
+ result.status = 'fail';
149
+ } else if (result.warnings.length > 0) {
150
+ result.status = 'warn';
151
+ } else {
152
+ result.status = 'pass';
153
+ }
154
+ return result;
155
+ }
156
+
157
+ module.exports = { runTcGate, VALID_TC_STATUS };
@@ -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,