@aikdna/kdna-core 0.2.3 → 0.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/src/compose.js CHANGED
@@ -120,4 +120,258 @@ function loadAndCompose(dataMaps, options = {}) {
120
120
  return { domains, context, activeIndices };
121
121
  }
122
122
 
123
- module.exports = { composeContext, classifySignals, composeChecks, loadAndCompose };
123
+ /**
124
+ * Compose context with source attribution — every axiom, misunderstanding,
125
+ * banned term, and self-check is prefixed with its origin domain.
126
+ *
127
+ * @param {Array<{id:string, core:object, patterns:object}>} domains
128
+ * @param {object} [options]
129
+ * @param {string} [options.separator] — section separator
130
+ * @returns {{context: string, attributionMap: object}}
131
+ */
132
+ function composeContextWithAttribution(domains, options = {}) {
133
+ if (!domains || !domains.length) return { context: '', attributionMap: {} };
134
+
135
+ const separator = options.separator || '\n\n---\n\n';
136
+ const lines = [];
137
+ const attributionMap = {};
138
+ let axiomIndex = 0;
139
+ let misreadingIndex = 0;
140
+ let termIndex = 0;
141
+ let checkIndex = 0;
142
+
143
+ for (const domain of domains) {
144
+ if (!domain || !domain.core) continue;
145
+ const name = domain.id || domain.core.meta?.domain || 'unknown';
146
+ const core = domain.core;
147
+ const patterns = domain.patterns || {};
148
+
149
+ lines.push(`## [${name}] Domain cognition`);
150
+
151
+ if (core.axioms?.length) {
152
+ lines.push(`### Axioms`);
153
+ for (const a of core.axioms) {
154
+ const tag = `[${name}:axiom.${a.id}]`;
155
+ lines.push(`- ${tag} ${a.one_sentence}`);
156
+ if (a.applies_when?.length) {
157
+ lines.push(` APPLIES WHEN: ${a.applies_when.join('; ')}`);
158
+ }
159
+ if (a.does_not_apply_when?.length) {
160
+ lines.push(` DOES NOT APPLY WHEN: ${a.does_not_apply_when.join('; ')}`);
161
+ }
162
+ if (a.failure_risk) lines.push(` RISK IF MISAPPLIED: ${a.failure_risk}`);
163
+ attributionMap[tag] = { domain: name, type: 'axiom', id: a.id, index: axiomIndex++ };
164
+ }
165
+ }
166
+
167
+ if (patterns.misunderstandings?.length) {
168
+ lines.push(`### Misunderstandings`);
169
+ for (const m of patterns.misunderstandings) {
170
+ const tag = `[${name}:misunderstanding.${m.id}]`;
171
+ lines.push(`- ${tag} WRONG: ${m.wrong}`);
172
+ lines.push(` CORRECT: ${m.correct}`);
173
+ if (m.failure_risk) lines.push(` RISK: ${m.failure_risk}`);
174
+ attributionMap[tag] = { domain: name, type: 'misunderstanding', id: m.id, index: misreadingIndex++ };
175
+ }
176
+ }
177
+
178
+ if (patterns.terminology?.banned_terms?.length) {
179
+ lines.push(`### Banned terms`);
180
+ for (const t of patterns.terminology.banned_terms) {
181
+ const term = typeof t === 'string' ? t : t.term;
182
+ const tag = `[${name}:banned_term.${term}]`;
183
+ const replace = typeof t === 'object' ? t.replace_with : null;
184
+ lines.push(`- ${tag} "${term}"${replace ? ` → use: ${replace}` : ''}`);
185
+ attributionMap[tag] = { domain: name, type: 'banned_term', term, index: termIndex++ };
186
+ }
187
+ }
188
+
189
+ if (patterns.self_check?.length) {
190
+ lines.push(`### Self-checks`);
191
+ for (const q of patterns.self_check) {
192
+ const text = typeof q === 'string' ? q : q.question;
193
+ const tag = `[${name}:self_check.${checkIndex}]`;
194
+ if (text) lines.push(`- ${tag} ${text}`);
195
+ attributionMap[tag] = { domain: name, type: 'self_check', index: checkIndex++ };
196
+ }
197
+ }
198
+
199
+ lines.push(separator.trimEnd());
200
+ }
201
+
202
+ return { context: lines.join('\n'), attributionMap };
203
+ }
204
+
205
+ /**
206
+ * Classify signals across a cluster of domains. Returns which domains
207
+ * matched and which were excluded. Unlike classifySignals (which just
208
+ * returns indices), this provides full diagnostic info.
209
+ *
210
+ * @param {string} input
211
+ * @param {Array<{id:string, name:string, role:string, core:object}>} domainEntries
212
+ * @returns {{selected: Array, excluded: Array}}
213
+ */
214
+ function classifySignalsAcrossDomains(input, domainEntries) {
215
+ if (!input || !domainEntries?.length) return { selected: [], excluded: [] };
216
+
217
+ const lower = input.toLowerCase();
218
+ const selected = [];
219
+ const excluded = [];
220
+
221
+ for (const entry of domainEntries) {
222
+ const signals = entry.core?.trigger_signals || [];
223
+ const negativeSignals = entry.core?.negative_signals || [];
224
+ const doesNotApply = [];
225
+ for (const a of entry.core?.axioms || []) {
226
+ if (Array.isArray(a.does_not_apply_when)) doesNotApply.push(...a.does_not_apply_when);
227
+ }
228
+
229
+ // Check negative signals
230
+ const blocked = negativeSignals.some((s) => lower.includes(s.toLowerCase())) ||
231
+ doesNotApply.some((s) => lower.includes(s.toLowerCase()));
232
+
233
+ if (blocked) {
234
+ excluded.push({ id: entry.id, name: entry.name, reason: 'blocked by does_not_apply_when', role: entry.role });
235
+ continue;
236
+ }
237
+
238
+ // No signals defined → required domain (always active)
239
+ if (!signals.length) {
240
+ selected.push({ id: entry.id, name: entry.name, role: entry.role, reason: 'required' });
241
+ continue;
242
+ }
243
+
244
+ // Check positive signals
245
+ const matched = signals.some((s) => lower.includes(s.toLowerCase()));
246
+ if (matched) {
247
+ selected.push({ id: entry.id, name: entry.name, role: entry.role, reason: 'signal_match' });
248
+ } else {
249
+ excluded.push({ id: entry.id, name: entry.name, reason: 'no signal match', role: entry.role });
250
+ }
251
+ }
252
+
253
+ return { selected, excluded };
254
+ }
255
+
256
+ /**
257
+ * Load a cluster from its manifest.
258
+ *
259
+ * @param {string} clusterManifestPath — path to kdna.cluster.json
260
+ * @param {function} domainLoader — fn(domainId) → {core, patterns} or null
261
+ * @returns {{manifest:object, domains:Array, errors:Array}}
262
+ */
263
+ function loadCluster(clusterManifestPath, domainLoader) {
264
+ const fs = require('fs');
265
+ const manifest = JSON.parse(fs.readFileSync(clusterManifestPath, 'utf8'));
266
+ const domains = [];
267
+ const errors = [];
268
+
269
+ if (!manifest.domains || !Array.isArray(manifest.domains)) {
270
+ return { manifest, domains, errors: ['No domains defined in cluster manifest'] };
271
+ }
272
+
273
+ for (const entry of manifest.domains) {
274
+ try {
275
+ const loaded = domainLoader(entry.id);
276
+ if (loaded) {
277
+ domains.push({ id: entry.id, name: loaded.core?.meta?.domain || entry.id, role: entry.role || 'advisor', required: entry.required !== false, core: loaded.core, patterns: loaded.patterns });
278
+ } else {
279
+ errors.push(`Domain ${entry.id}: not found or failed to load`);
280
+ }
281
+ } catch (e) {
282
+ errors.push(`Domain ${entry.id}: ${e.message}`);
283
+ }
284
+ }
285
+
286
+ return { manifest, domains, errors };
287
+ }
288
+
289
+ /**
290
+ * Detect conflicts between loaded domains in a cluster.
291
+ *
292
+ * @param {Array<{id:string, core:object, patterns:object}>} domains
293
+ * @returns {Array<{type:string, domains:string[], description:string}>}
294
+ */
295
+ function detectDomainConflicts(domains) {
296
+ if (!domains || domains.length < 2) return [];
297
+
298
+ const conflicts = [];
299
+ const bannedTermMap = new Map();
300
+
301
+ // Check banned term conflicts — same term banned by one, preferred by another
302
+ for (const d of domains) {
303
+ const terms = d.patterns?.terminology?.banned_terms || [];
304
+ for (const t of terms) {
305
+ const term = typeof t === 'string' ? t : t.term;
306
+ if (bannedTermMap.has(term)) {
307
+ conflicts.push({
308
+ type: 'term_conflict',
309
+ domains: [bannedTermMap.get(term), d.id],
310
+ description: `Banned term "${term}" appears in multiple domains`,
311
+ });
312
+ }
313
+ bannedTermMap.set(term, d.id);
314
+ }
315
+ }
316
+
317
+ // Check stance conflicts — contradictory stances
318
+ for (let i = 0; i < domains.length; i++) {
319
+ for (let j = i + 1; j < domains.length; j++) {
320
+ const sA = (domains[i].core?.stances || []).map((s) => (typeof s === 'string' ? s : s.stance));
321
+ const sB = (domains[j].core?.stances || []).map((s) => (typeof s === 'string' ? s : s.stance));
322
+ for (const sa of sA) {
323
+ for (const sb of sB) {
324
+ // Simple negation check — can be extended
325
+ if (sa && sb && (/\bnot\b/.test(sa) && sb.toLowerCase().includes(sa.toLowerCase().replace('not ', ''))) ||
326
+ (/\bnot\b/.test(sb) && sa.toLowerCase().includes(sb.toLowerCase().replace('not ', '')))) {
327
+ conflicts.push({
328
+ type: 'stance_conflict',
329
+ domains: [domains[i].id, domains[j].id],
330
+ description: `Potential stance conflict: "${sa.slice(0, 60)}" vs "${sb.slice(0, 60)}"`,
331
+ });
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ return conflicts;
339
+ }
340
+
341
+ /**
342
+ * Generate a judgment trace for a cluster operation.
343
+ *
344
+ * @param {object} params
345
+ * @param {string} params.input — the original user input
346
+ * @param {Array} params.loadedDomains — all domains in the cluster
347
+ * @param {Array} params.activeDomains — domains that were activated
348
+ * @param {Array} params.conflicts — detected conflicts
349
+ * @returns {object}
350
+ */
351
+ function generateClusterTrace({ input, loadedDomains, activeDomains, conflicts }) {
352
+ return {
353
+ input: (input || '').slice(0, 200),
354
+ timestamp: new Date().toISOString(),
355
+ loaded_domains: (loadedDomains || []).map((d) => d.id || d.name || '?'),
356
+ active_domains: (activeDomains || []).map((d) => d.id || d.name || '?'),
357
+ active_count: (activeDomains || []).length,
358
+ domains_excluded: (loadedDomains || []).length - (activeDomains || []).length,
359
+ conflicts: (conflicts || []).map((c) => ({
360
+ type: c.type,
361
+ domains: c.domains,
362
+ description: c.description,
363
+ })),
364
+ };
365
+ }
366
+
367
+ module.exports = {
368
+ composeContext,
369
+ composeContextWithAttribution,
370
+ classifySignals,
371
+ classifySignalsAcrossDomains,
372
+ composeChecks,
373
+ loadAndCompose,
374
+ loadCluster,
375
+ detectDomainConflicts,
376
+ generateClusterTrace,
377
+ };
package/src/index.mjs CHANGED
@@ -16,4 +16,4 @@ export { validateDomainSchema, validateCrossFile } from './validate-pure.js';
16
16
 
17
17
  export { renderPreviewHTML, escHtml, renderCard } from './render.js';
18
18
 
19
- export { composeContext, classifySignals, composeChecks, loadAndCompose } from './compose.js';
19
+ export { composeContext, composeContextWithAttribution, classifySignals, classifySignalsAcrossDomains, composeChecks, loadAndCompose, loadCluster, detectDomainConflicts, generateClusterTrace } from './compose.js';
package/src/lint-pure.js CHANGED
@@ -6,6 +6,38 @@
6
6
  * Output: { errors: string[], warnings: string[] }
7
7
  */
8
8
 
9
+ /**
10
+ * Map of old/informal field names → correct v0.4 spec field names.
11
+ * Used to give helpful error messages when users write from scratch without the template.
12
+ */
13
+ const OLD_FIELD_HINTS = {
14
+ statement: 'one_sentence or full_statement',
15
+ description: 'one_sentence',
16
+ summary: 'one_sentence',
17
+ claim: 'wrong',
18
+ misreading: 'wrong',
19
+ reality: 'correct',
20
+ definition: 'essence or one_sentence (on ontology entries)',
21
+ brief: 'title or context',
22
+ bad_pattern: 'what_happened',
23
+ master_pattern: 'structural_pattern',
24
+ conclusion: 'one_sentence',
25
+ capability_layers: 'stages',
26
+ name: 'id (on ontology entries — use id instead of name)',
27
+ input: 'from',
28
+ output: 'to',
29
+ judgment: 'via',
30
+ };
31
+
32
+ const KDNA_DOMAIN_FILES = new Set([
33
+ 'KDNA_Core.json',
34
+ 'KDNA_Patterns.json',
35
+ 'KDNA_Scenarios.json',
36
+ 'KDNA_Cases.json',
37
+ 'KDNA_Reasoning.json',
38
+ 'KDNA_Evolution.json',
39
+ ]);
40
+
9
41
  /**
10
42
  * Lint a KDNA domain from a map of parsed JSON objects.
11
43
  *
@@ -23,6 +55,15 @@ function lintDomain(dataMap) {
23
55
  function req(o, k, loc, hint) {
24
56
  if (!has(o, k) || o[k] === '' || o[k] == null) {
25
57
  let msg = `${loc}: missing required field "${k}"`;
58
+ // Check for common old field name and suggest the correct one
59
+ if (o && typeof o === 'object') {
60
+ for (const [oldName, newName] of Object.entries(OLD_FIELD_HINTS)) {
61
+ if (has(o, oldName)) {
62
+ msg += `\n → Found field "${oldName}" — this looks like an old/informal field name. Use "${newName}" instead.`;
63
+ break;
64
+ }
65
+ }
66
+ }
26
67
  if (hint) msg += `\n → ${hint}`;
27
68
  errors.push(msg);
28
69
  }
@@ -72,9 +113,7 @@ function lintDomain(dataMap) {
72
113
  }
73
114
 
74
115
  // Check file count
75
- const kdnaFiles = Object.keys(dataMap).filter(
76
- (f) => f.endsWith('.json') && f !== 'kdna.json',
77
- );
116
+ const kdnaFiles = Object.keys(dataMap).filter((f) => KDNA_DOMAIN_FILES.has(f));
78
117
  if (kdnaFiles.length > 6) errors.push(`Domain has ${kdnaFiles.length} JSON files; KDNA allows at most 6.`);
79
118
 
80
119
  // Validate meta on all files
@@ -213,4 +252,152 @@ function lintDomain(dataMap) {
213
252
  return { errors, warnings };
214
253
  }
215
254
 
216
- module.exports = { lintDomain };
255
+ /**
256
+ * Canonical enum tables for manifest validation.
257
+ * Single source of truth — keep in sync with schema/kdna-manifest-v1rc.json and specs/enum-tables.md.
258
+ */
259
+ const VALID_STATUS = new Set(['draft', 'experimental', 'stable', 'deprecated', 'staging']);
260
+ const VALID_BADGE = new Set(['untested', 'tested', 'validated', 'expert_reviewed', 'production_ready']);
261
+ const VALID_ACCESS = new Set(['open', 'licensed', 'runtime']);
262
+ const VALID_RISK = new Set(['R0', 'R1', 'R2', 'R3']);
263
+ const VALID_I18N = new Set(['L0', 'L1', 'L2', 'L3']);
264
+
265
+ const MANIFEST_REQUIRED = [
266
+ 'kdna_spec', 'name', 'version', 'judgment_version',
267
+ 'description', 'author', 'license', 'status',
268
+ 'quality_badge', 'access', 'language',
269
+ ];
270
+
271
+ /**
272
+ * Validate a kdna.json manifest against the canonical v1.0-rc schema.
273
+ *
274
+ * @param {Object} manifest — parsed kdna.json
275
+ * @returns {{ errors: string[], warnings: string[] }}
276
+ */
277
+ function validateManifest(manifest) {
278
+ const errors = [];
279
+ const warnings = [];
280
+
281
+ if (!manifest || typeof manifest !== 'object') {
282
+ errors.push('kdna.json: missing or empty manifest');
283
+ return { errors, warnings };
284
+ }
285
+
286
+ // 1. Check spec_version is NOT in domain manifest (use kdna_spec only)
287
+ if ('spec_version' in manifest) {
288
+ errors.push(
289
+ 'kdna.json: spec_version is deprecated in domain manifests. Use kdna_spec. ' +
290
+ '(spec_version is reserved for .kdna container manifests only.)',
291
+ );
292
+ }
293
+
294
+ // 2. Check required fields
295
+ for (const field of MANIFEST_REQUIRED) {
296
+ if (!(field in manifest) || manifest[field] === undefined || manifest[field] === '') {
297
+ errors.push(`kdna.json: missing required field "${field}"`);
298
+ }
299
+ }
300
+
301
+ // 3. Validate name format
302
+ if (manifest.name && !/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9_]*$/.test(manifest.name)) {
303
+ errors.push(`kdna.json.name: invalid format "${manifest.name}". Expected @scope/name.`);
304
+ }
305
+
306
+ // 4. Validate enum fields
307
+ if (manifest.status && !VALID_STATUS.has(manifest.status)) {
308
+ errors.push(
309
+ `kdna.json.status: invalid value "${manifest.status}". ` +
310
+ `Valid: ${[...VALID_STATUS].join(', ')}`,
311
+ );
312
+ }
313
+ if (manifest.quality_badge && !VALID_BADGE.has(manifest.quality_badge)) {
314
+ errors.push(
315
+ `kdna.json.quality_badge: invalid value "${manifest.quality_badge}". ` +
316
+ `Valid: ${[...VALID_BADGE].join(', ')}`,
317
+ );
318
+ }
319
+ if (manifest.access && !VALID_ACCESS.has(manifest.access)) {
320
+ errors.push(
321
+ `kdna.json.access: invalid value "${manifest.access}". ` +
322
+ `Valid: ${[...VALID_ACCESS].join(', ')}`,
323
+ );
324
+ }
325
+ if (manifest.risk_level && !VALID_RISK.has(manifest.risk_level)) {
326
+ errors.push(
327
+ `kdna.json.risk_level: invalid value "${manifest.risk_level}". ` +
328
+ `Valid: ${[...VALID_RISK].join(', ')}`,
329
+ );
330
+ }
331
+ if (manifest.i18n_level && !VALID_I18N.has(manifest.i18n_level)) {
332
+ warnings.push(
333
+ `kdna.json.i18n_level: non-standard value "${manifest.i18n_level}". ` +
334
+ `Valid: ${[...VALID_I18N].join(', ')}`,
335
+ );
336
+ }
337
+
338
+ // 5. Deprecated status must have replaced_by
339
+ if (manifest.status === 'deprecated' && !manifest.replaced_by) {
340
+ errors.push('kdna.json: status is "deprecated" but replaced_by is missing');
341
+ }
342
+
343
+ // 6. Tested+ badge must have signature
344
+ const needsSig = ['tested', 'validated', 'expert_reviewed', 'production_ready'];
345
+ if (needsSig.includes(manifest.quality_badge) && !manifest.signature) {
346
+ warnings.push(
347
+ `kdna.json: quality_badge "${manifest.quality_badge}" should have a signature`,
348
+ );
349
+ }
350
+
351
+ // 7. Validate author
352
+ if (manifest.author) {
353
+ if (!manifest.author.name) errors.push('kdna.json.author: missing "name"');
354
+ if (!manifest.author.id) errors.push('kdna.json.author: missing "id"');
355
+ if (manifest.author.pubkey && !/^ed25519:[0-9a-f]{64}$/.test(manifest.author.pubkey)) {
356
+ warnings.push('kdna.json.author.pubkey: non-standard format. Expected ed25519:<64 hex chars>.');
357
+ }
358
+ }
359
+
360
+ // 8. Validate license
361
+ if (manifest.license && !manifest.license.type) {
362
+ errors.push('kdna.json.license: missing "type"');
363
+ }
364
+
365
+ // 9. Validate kdna_spec value
366
+ if (manifest.kdna_spec && manifest.kdna_spec !== '1.0-rc') {
367
+ warnings.push(
368
+ `kdna.json.kdna_spec: non-standard value "${manifest.kdna_spec}". Expected "1.0-rc".`,
369
+ );
370
+ }
371
+
372
+ // 10. Validate version format
373
+ if (manifest.version && !/^\d+\.\d+\.\d+/.test(manifest.version)) {
374
+ warnings.push(
375
+ `kdna.json.version: non-semver format "${manifest.version}". Expected MAJOR.MINOR.PATCH.`,
376
+ );
377
+ }
378
+
379
+ // 11. Check for removed fields
380
+ const removedFields = ['release_status', 'domain_field', 'judgment_patterns', 'files', 'registry'];
381
+ for (const field of removedFields) {
382
+ if (field in manifest) {
383
+ warnings.push(
384
+ `kdna.json: field "${field}" is not in the canonical domain manifest and should be removed`,
385
+ );
386
+ }
387
+ }
388
+
389
+ // 12. Check removed license sub-fields
390
+ if (manifest.license && typeof manifest.license === 'object') {
391
+ for (const field of ['commercial', 'allow_agent_use', 'allow_redistribution', 'allow_training']) {
392
+ if (field in manifest.license) {
393
+ warnings.push(
394
+ `kdna.json.license.${field}: license-type-specific field, not universal. Consider removing.`,
395
+ );
396
+ }
397
+ }
398
+ }
399
+
400
+ return { errors, warnings };
401
+ }
402
+
403
+ module.exports = { lintDomain, validateManifest };
package/src/loader.js CHANGED
@@ -118,6 +118,55 @@ function classifyInput(text) {
118
118
  return [...new Set(optional)];
119
119
  }
120
120
 
121
+ /**
122
+ * Known field name mapping for detecting old/incorrect field names.
123
+ * When a common old name is found, log a warning and suggest the correct name.
124
+ */
125
+ const FIELD_ALIASES = {
126
+ statement: 'one_sentence or full_statement',
127
+ description: 'one_sentence',
128
+ summary: 'one_sentence',
129
+ claim: 'wrong',
130
+ misreading: 'wrong',
131
+ reality: 'correct',
132
+ definition: 'essence or one_sentence (on ontology)',
133
+ brief: 'title or context',
134
+ bad_pattern: 'what_happened',
135
+ master_pattern: 'structural_pattern',
136
+ conclusion: 'one_sentence',
137
+ capability_layers: 'stages',
138
+ name: 'id (on ontology entries)',
139
+ input: 'from',
140
+ output: 'to',
141
+ judgment: 'via',
142
+ };
143
+
144
+ /**
145
+ * Deep-scan an object tree for known old field names and return warnings.
146
+ * @param {object} obj
147
+ * @param {string} path
148
+ * @param {string[]} [warnings]
149
+ * @returns {string[]}
150
+ */
151
+ function detectOldFieldNames(obj, path = '', warnings = []) {
152
+ if (!obj || typeof obj !== 'object') return warnings;
153
+ if (Array.isArray(obj)) {
154
+ obj.forEach((item, i) => detectOldFieldNames(item, `${path}[${i}]`, warnings));
155
+ return warnings;
156
+ }
157
+ for (const key of Object.keys(obj)) {
158
+ if (FIELD_ALIASES[key]) {
159
+ warnings.push(
160
+ `[KDNA LOADER] ${path}.${key}: field "${key}" is not in spec v0.4. Use "${FIELD_ALIASES[key]}" instead. See docs/authoring-guide.md §0.`,
161
+ );
162
+ }
163
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
164
+ detectOldFieldNames(obj[key], `${path}.${key}`, warnings);
165
+ }
166
+ }
167
+ return warnings;
168
+ }
169
+
121
170
  /**
122
171
  * Format a loaded KDNA domain into a context string suitable for
123
172
  * inclusion in an agent's system prompt.
@@ -125,6 +174,16 @@ function classifyInput(text) {
125
174
  * @param {object} domain — result from loadDomainFromData() or loadDomainFromFiles()
126
175
  * @returns {string}
127
176
  */
177
+ function sanitize(str) {
178
+ if (typeof str !== 'string') return str;
179
+ return str
180
+ .replace(/^#{1,6}\s/gm, '\\# ') // Escape leading # to prevent fake headers
181
+ .replace(/```/g, '\\`\\`\\`') // Escape code blocks
182
+ .replace(/<\|/g, '&lt;|') // Escape special tokens
183
+ .replace(/\b(ignore|forget|disregard)\s+(all\s+)?(previous|prior|above)\s+(instructions?|directives?|rules?|constraints?)\b/gi,
184
+ '[filtered: $&]'); // Filter prompt injection patterns
185
+ }
186
+
128
187
  function formatContext(domain) {
129
188
  if (!domain || !domain.core || !domain.patterns) return '';
130
189
 
@@ -132,14 +191,25 @@ function formatContext(domain) {
132
191
  const core = domain.core;
133
192
  const pat = domain.patterns;
134
193
 
194
+ // Scan for old field names and warn
195
+ const warnings = detectOldFieldNames(domain, domain.core?.meta?.domain || 'domain');
196
+ if (warnings.length) {
197
+ parts.push('<!-- KDNA FIELD NAME WARNINGS:');
198
+ for (const w of warnings) parts.push(` ${w}`);
199
+ parts.push(' These fields will be SILENTLY IGNORED by the loader.');
200
+ parts.push(' See: docs/authoring-guide.md §0 (Field Name Reference)');
201
+ parts.push('-->');
202
+ parts.push('');
203
+ }
204
+
135
205
  parts.push('## Domain Cognition (KDNA)');
136
- parts.push(`Domain: ${core.meta.domain}`);
206
+ parts.push(`Domain: ${sanitize(core.meta.domain)}`);
137
207
  parts.push('');
138
208
 
139
209
  if (core.stances && core.stances.length) {
140
210
  parts.push('### Stances');
141
211
  for (const s of core.stances) {
142
- parts.push(`- ${s}`);
212
+ parts.push(`- ${sanitize(s)}`);
143
213
  }
144
214
  parts.push('');
145
215
  }
@@ -147,8 +217,8 @@ function formatContext(domain) {
147
217
  if (core.axioms && core.axioms.length) {
148
218
  parts.push('### Axioms');
149
219
  for (const a of core.axioms) {
150
- parts.push(`- **${a.one_sentence}** ${a.full_statement}`);
151
- parts.push(` *Why:* ${a.why}`);
220
+ parts.push(`- **${sanitize(a.one_sentence)}** ${sanitize(a.full_statement)}`);
221
+ parts.push(` *Why:* ${sanitize(a.why)}`);
152
222
  }
153
223
  parts.push('');
154
224
  }
@@ -156,8 +226,8 @@ function formatContext(domain) {
156
226
  if (core.ontology && core.ontology.length) {
157
227
  parts.push('### Key Concepts');
158
228
  for (const c of core.ontology) {
159
- parts.push(`- **${c.id.replace(/_/g, ' ')}** — ${c.one_sentence}`);
160
- parts.push(` Boundary: ${c.boundary}`);
229
+ parts.push(`- **${sanitize(c.id.replace(/_/g, ' '))}** — ${sanitize(c.one_sentence)}`);
230
+ parts.push(` Boundary: ${sanitize(c.boundary)}`);
161
231
  }
162
232
  parts.push('');
163
233
  }
@@ -165,7 +235,7 @@ function formatContext(domain) {
165
235
  if (core.frameworks && core.frameworks.length) {
166
236
  parts.push('### Frameworks');
167
237
  for (const fw of core.frameworks) {
168
- parts.push(`- **${fw.name}**: ${fw.when_to_use}`);
238
+ parts.push(`- **${sanitize(fw.name)}**: ${sanitize(fw.when_to_use)}`);
169
239
  }
170
240
  parts.push('');
171
241
  }
@@ -173,7 +243,7 @@ function formatContext(domain) {
173
243
  if (pat.terminology && pat.terminology.banned_terms && pat.terminology.banned_terms.length) {
174
244
  parts.push('### Avoid These Terms');
175
245
  for (const b of pat.terminology.banned_terms) {
176
- parts.push(`- Avoid "${b.term}". ${b.why} Use "${b.replace_with}" instead.`);
246
+ parts.push(`- Avoid "${sanitize(b.term)}". ${sanitize(b.why)} Use "${sanitize(b.replace_with)}" instead.`);
177
247
  }
178
248
  parts.push('');
179
249
  }
@@ -181,8 +251,8 @@ function formatContext(domain) {
181
251
  if (pat.misunderstandings && pat.misunderstandings.length) {
182
252
  parts.push('### Watch For These Misunderstandings');
183
253
  for (const m of pat.misunderstandings) {
184
- parts.push(`- **Wrong:** ${m.wrong}`);
185
- parts.push(` **Correct:** ${m.correct}`);
254
+ parts.push(`- **Wrong:** ${sanitize(m.wrong)}`);
255
+ parts.push(` **Correct:** ${sanitize(m.correct)}`);
186
256
  }
187
257
  parts.push('');
188
258
  }
@@ -190,7 +260,7 @@ function formatContext(domain) {
190
260
  if (pat.self_check && pat.self_check.length) {
191
261
  parts.push('### Before Responding, Check');
192
262
  for (const s of pat.self_check) {
193
- parts.push(`- [ ] ${s}`);
263
+ parts.push(`- [ ] ${sanitize(s)}`);
194
264
  }
195
265
  parts.push('');
196
266
  }
@@ -198,7 +268,7 @@ function formatContext(domain) {
198
268
  if (domain.scenarios && domain.scenarios.scenes) {
199
269
  parts.push('### Relevant Scenarios');
200
270
  for (const scene of domain.scenarios.scenes) {
201
- parts.push(`- **${scene.name}**: ${scene.trigger_signal}`);
271
+ parts.push(`- **${sanitize(scene.name)}**: ${sanitize(scene.trigger_signal)}`);
202
272
  }
203
273
  parts.push('');
204
274
  }
@@ -206,7 +276,7 @@ function formatContext(domain) {
206
276
  if (domain.reasoning && domain.reasoning.reasoning_chains) {
207
277
  parts.push('### Reasoning Chains');
208
278
  for (const r of domain.reasoning.reasoning_chains) {
209
- parts.push(`- **${r.one_sentence}** → ${r.so_what}`);
279
+ parts.push(`- **${sanitize(r.one_sentence)}** → ${sanitize(r.so_what)}`);
210
280
  }
211
281
  parts.push('');
212
282
  }
@@ -214,11 +284,11 @@ function formatContext(domain) {
214
284
  if (domain.cases && domain.cases.cases && domain.cases.cases.length) {
215
285
  parts.push('### Cases');
216
286
  for (const c of domain.cases.cases) {
217
- parts.push(`- **${c.title}**`);
218
- parts.push(` Context: ${c.context}`);
219
- parts.push(` What happened: ${c.what_happened}`);
220
- parts.push(` Learned: ${c.what_was_learned}`);
221
- parts.push(` Pattern: ${c.structural_pattern}`);
287
+ parts.push(`- **${sanitize(c.title)}**`);
288
+ parts.push(` Context: ${sanitize(c.context)}`);
289
+ parts.push(` What happened: ${sanitize(c.what_happened)}`);
290
+ parts.push(` Learned: ${sanitize(c.what_was_learned)}`);
291
+ parts.push(` Pattern: ${sanitize(c.structural_pattern)}`);
222
292
  }
223
293
  parts.push('');
224
294
  }
@@ -228,7 +298,7 @@ function formatContext(domain) {
228
298
  if (evo.stages && evo.stages.length) {
229
299
  parts.push('### Growth Stages');
230
300
  for (const stage of evo.stages) {
231
- parts.push(`- **${stage.name}**: ${stage.description}`);
301
+ parts.push(`- **${sanitize(stage.name)}**: ${sanitize(stage.description)}`);
232
302
  }
233
303
  parts.push('');
234
304
  }
@@ -236,7 +306,7 @@ function formatContext(domain) {
236
306
  parts.push('### Capability Layers');
237
307
  for (const layer of evo.evolution_layers) {
238
308
  parts.push(
239
- `- **${layer.name}**: ${layer.capability} (${layer.from_stage} → ${layer.to_stage})`,
309
+ `- **${sanitize(layer.name)}**: ${sanitize(layer.capability)} (${sanitize(layer.from_stage)} → ${sanitize(layer.to_stage)})`,
240
310
  );
241
311
  }
242
312
  parts.push('');