@aikdna/kdna-cli 0.9.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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +73 -0
  4. package/package.json +58 -0
  5. package/skills/kdna-loader/SKILL.md +257 -0
  6. package/src/agent.js +434 -0
  7. package/src/cli.js +260 -0
  8. package/src/cluster.js +235 -0
  9. package/src/cmds/_common.js +100 -0
  10. package/src/cmds/cluster.js +235 -0
  11. package/src/cmds/domain.js +638 -0
  12. package/src/cmds/identity.js +31 -0
  13. package/src/cmds/legacy.js +83 -0
  14. package/src/cmds/quality.js +87 -0
  15. package/src/cmds/registry.js +114 -0
  16. package/src/cmds/setup.js +8 -0
  17. package/src/compare.js +324 -0
  18. package/src/diff.js +288 -0
  19. package/src/identity.js +211 -0
  20. package/src/init.js +168 -0
  21. package/src/install.js +849 -0
  22. package/src/loader.js +70 -0
  23. package/src/publish.js +600 -0
  24. package/src/registry.js +258 -0
  25. package/src/search.js +73 -0
  26. package/src/setup.js +197 -0
  27. package/src/verify.js +423 -0
  28. package/src/version.js +112 -0
  29. package/templates/cluster/KDNA_Cluster.json +25 -0
  30. package/templates/cluster/README.md +32 -0
  31. package/templates/minimal-domain/KDNA_Core.json +54 -0
  32. package/templates/minimal-domain/KDNA_Patterns.json +37 -0
  33. package/templates/minimal-domain/kdna.json +31 -0
  34. package/templates/minimal-domain/tests/before-after.json +16 -0
  35. package/templates/standard-domain/KDNA_Core.json +76 -0
  36. package/templates/standard-domain/KDNA_Patterns.json +44 -0
  37. package/templates/standard-domain/README.md +74 -0
  38. package/templates/standard-domain/USAGE.md +59 -0
  39. package/templates/standard-domain/evals/1_excluded_case.json +16 -0
  40. package/templates/standard-domain/evals/3_boundary_cases.json +38 -0
  41. package/templates/standard-domain/evals/3_core_cases.json +35 -0
  42. package/templates/standard-domain/evals/3_failure_cases.json +35 -0
  43. package/templates/standard-domain/evals/scoring.json +60 -0
  44. package/templates/standard-domain/kdna.json +28 -0
  45. package/validators/kdna-lint.js +53 -0
  46. package/validators/kdna-validate.js +92 -0
package/src/loader.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * KDNA Loader — Runtime library for loading KDNA domain cognition into agent context.
3
+ *
4
+ * This module provides the Node.js file-system-backed API.
5
+ * Pure logic lives in @aikdna/kdna-core; this module handles I/O.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const core = require('@aikdna/kdna-core');
11
+
12
+ const FILE_MAP = core.FILE_MAP;
13
+
14
+ /**
15
+ * Read and parse a KDNA JSON file.
16
+ * Returns null if the file does not exist.
17
+ */
18
+ function readFile(domainDir, filename) {
19
+ const filePath = path.join(domainDir, filename);
20
+ if (!fs.existsSync(filePath)) return null;
21
+ try {
22
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Load the minimum required KDNA files (Core + Patterns).
30
+ * Always load these. They form the cognition baseline.
31
+ */
32
+ function loadCorePatterns(domainDir) {
33
+ const coreData = readFile(domainDir, FILE_MAP.core);
34
+ const patternsData = readFile(domainDir, FILE_MAP.patterns);
35
+ return core.loadCorePatternsFromData(coreData, patternsData);
36
+ }
37
+
38
+ /**
39
+ * Load a complete KDNA domain.
40
+ *
41
+ * @param {string} domainDir — path to the domain folder
42
+ * @param {object} [options]
43
+ * @param {string} [options.input] — user input text for conditional loading
44
+ * @param {'all'|'minimum'|'auto'} [options.mode='auto'] — loading mode
45
+ * @returns {object|null} loaded KDNA files keyed by type, or null if minimum files are missing
46
+ */
47
+ function loadDomain(domainDir, options = {}) {
48
+ const dataMap = { core: null, patterns: null };
49
+
50
+ dataMap.core = readFile(domainDir, FILE_MAP.core);
51
+ dataMap.patterns = readFile(domainDir, FILE_MAP.patterns);
52
+
53
+ if (!dataMap.core || !dataMap.patterns) return null;
54
+
55
+ // Also read optional files that might be present
56
+ for (const key of ['scenarios', 'cases', 'reasoning', 'evolution']) {
57
+ const data = readFile(domainDir, FILE_MAP[key]);
58
+ if (data) dataMap[key] = data;
59
+ }
60
+
61
+ return core.loadDomainFromData(dataMap, options);
62
+ }
63
+
64
+ module.exports = {
65
+ loadDomain,
66
+ loadCorePatterns,
67
+ classifyInput: core.classifyInput,
68
+ formatContext: core.formatContext,
69
+ FILE_MAP,
70
+ };
package/src/publish.js ADDED
@@ -0,0 +1,600 @@
1
+ /**
2
+ * kdna publish --check <path> — Quality gate for domain publication.
3
+ *
4
+ * Checks beyond structural validity: anti-vagueness, content completeness,
5
+ * and registry readiness.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ function error(msg) {
12
+ console.error(`Error: ${msg}`);
13
+ process.exit(1);
14
+ }
15
+
16
+ function readJson(filePath) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // ─── Anti-vagueness checks ────────────────────────────────────────────
25
+
26
+ const VAGUE_PHRASES = [
27
+ 'is important',
28
+ 'is key',
29
+ 'matters',
30
+ 'is crucial',
31
+ 'is essential',
32
+ 'is critical',
33
+ 'be helpful',
34
+ 'be user-centered',
35
+ 'be customer-focused',
36
+ 'communicate effectively',
37
+ 'think strategically',
38
+ 'is vital',
39
+ 'plays a role',
40
+ 'is fundamental',
41
+ ];
42
+
43
+ const SLOGAN_PATTERNS = [
44
+ /^[A-Z][a-z]+ is [a-z]+\.?$/, // "Trust is important."
45
+ /^Be [a-z]+\.?$/, // "Be helpful."
46
+ /^[A-Z][a-z]+ matters\.?$/, // "Quality matters."
47
+ ];
48
+
49
+ function isVague(text) {
50
+ if (!text || typeof text !== 'string') return false;
51
+ const lower = text.toLowerCase();
52
+ for (const phrase of VAGUE_PHRASES) {
53
+ if (lower.includes(phrase)) return { phrase, text };
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function isSlogan(text) {
59
+ if (!text || typeof text !== 'string') return false;
60
+ for (const pattern of SLOGAN_PATTERNS) {
61
+ if (pattern.test(text.trim())) return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ function isNegationOnly(boundary) {
67
+ if (!boundary || typeof boundary !== 'string') return false;
68
+ const trimmed = boundary.toLowerCase().trim();
69
+ return /^not\s/.test(trimmed) && trimmed.split(/\s+/).length <= 3;
70
+ }
71
+
72
+ function isDictionaryDefinition(essence) {
73
+ if (!essence || typeof essence !== 'string') return false;
74
+ // Dictionary-style: starts with "the", follows with "is" or "of"
75
+ return /^the\s+(quality|state|act|process|ability|condition|fact|practice|use)\s+(of|in|to|that)/i.test(
76
+ essence,
77
+ );
78
+ }
79
+
80
+ function isStrawMan(wrong) {
81
+ if (!wrong || typeof wrong !== 'string') return false;
82
+ const lower = wrong.toLowerCase();
83
+ const strawPatterns = [
84
+ /doesn['']t matter/,
85
+ /isn['']t important/,
86
+ /is useless/,
87
+ /never works/,
88
+ /is a waste/,
89
+ /should never/,
90
+ ];
91
+ for (const p of strawPatterns) {
92
+ if (p.test(lower)) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function isGenericSelfCheck(question) {
98
+ if (!question || typeof question !== 'string') return false;
99
+ const lower = question.toLowerCase();
100
+ const generic = [
101
+ 'is this helpful',
102
+ 'is this response good',
103
+ 'is this clear',
104
+ 'did i do a good job',
105
+ 'is this useful',
106
+ 'is this correct',
107
+ 'is this accurate',
108
+ 'did i follow best practices',
109
+ ];
110
+ for (const g of generic) {
111
+ if (lower.includes(g)) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // ─── Main check function ──────────────────────────────────────────────
117
+
118
+ function cmdPublishCheck(domainPath) {
119
+ const abs = path.resolve(domainPath);
120
+ if (!fs.existsSync(abs)) error(`Domain not found: ${abs}`);
121
+
122
+ console.log('═'.repeat(60));
123
+ console.log(` Publish Check: ${path.basename(abs)}`);
124
+ console.log('═'.repeat(60));
125
+ console.log('');
126
+
127
+ let errors = 0;
128
+ let warnings = 0;
129
+ let passes = 0;
130
+
131
+ function fail(file, field, item, reason) {
132
+ console.error(` ✗ ${file} > ${field}: ${reason}`);
133
+ if (item) console.error(` "${item.slice(0, 100)}${item.length > 100 ? '...' : ''}"`);
134
+ errors++;
135
+ }
136
+
137
+ function warn(file, field, msg) {
138
+ console.warn(` ⚠ ${file} > ${field}: ${msg}`);
139
+ warnings++;
140
+ }
141
+
142
+ function pass(file, field) {
143
+ console.log(` ✓ ${file} > ${field}`);
144
+ passes++;
145
+ }
146
+
147
+ // Load Core
148
+ const core = readJson(path.join(abs, 'KDNA_Core.json'));
149
+ if (!core) error('KDNA_Core.json not found or invalid JSON');
150
+
151
+ // Check axioms
152
+ if (core.axioms && Array.isArray(core.axioms)) {
153
+ for (const ax of core.axioms) {
154
+ const label = ax.id || '?';
155
+
156
+ if (!ax.one_sentence || ax.one_sentence.length < 20) {
157
+ fail(
158
+ 'KDNA_Core.json',
159
+ `axioms.${label}.one_sentence`,
160
+ ax.one_sentence,
161
+ 'Too short (min 20 chars). Axioms must be specific claims, not labels.',
162
+ );
163
+ } else if (isSlogan(ax.one_sentence)) {
164
+ fail(
165
+ 'KDNA_Core.json',
166
+ `axioms.${label}.one_sentence`,
167
+ ax.one_sentence,
168
+ 'Reads like a slogan. Axioms must be specific judgment principles.',
169
+ );
170
+ } else if (isVague(ax.one_sentence)) {
171
+ const v = isVague(ax.one_sentence);
172
+ fail(
173
+ 'KDNA_Core.json',
174
+ `axioms.${label}.one_sentence`,
175
+ ax.one_sentence,
176
+ `Vague phrase "${v.phrase}". Be specific about what the agent should judge.`,
177
+ );
178
+ } else {
179
+ pass('KDNA_Core.json', `axioms.${label}.one_sentence`);
180
+ }
181
+
182
+ if (!ax.full_statement || ax.full_statement.length < 40) {
183
+ fail(
184
+ 'KDNA_Core.json',
185
+ `axioms.${label}.full_statement`,
186
+ ax.full_statement,
187
+ 'Too short (min 40 chars). Full statement must be testable and domain-specific.',
188
+ );
189
+ } else if (isVague(ax.full_statement)) {
190
+ warn(
191
+ 'KDNA_Core.json',
192
+ `axioms.${label}.full_statement`,
193
+ 'Contains vague language. Consider making it more operational.',
194
+ );
195
+ } else {
196
+ pass('KDNA_Core.json', `axioms.${label}.full_statement`);
197
+ }
198
+
199
+ if (!ax.why || ax.why.length < 20) {
200
+ fail(
201
+ 'KDNA_Core.json',
202
+ `axioms.${label}.why`,
203
+ ax.why,
204
+ 'Too short. Must explain what the agent would get wrong without this axiom.',
205
+ );
206
+ } else {
207
+ pass('KDNA_Core.json', `axioms.${label}.why`);
208
+ }
209
+ }
210
+ }
211
+
212
+ // Check ontology
213
+ if (core.ontology && Array.isArray(core.ontology)) {
214
+ for (const con of core.ontology) {
215
+ const label = con.id || '?';
216
+
217
+ if (!con.essence || isDictionaryDefinition(con.essence)) {
218
+ fail(
219
+ 'KDNA_Core.json',
220
+ `ontology.${label}.essence`,
221
+ con.essence,
222
+ 'Reads like a dictionary definition. Essence must be operational — what the agent needs to check, not what a dictionary says.',
223
+ );
224
+ } else if (isVague(con.essence)) {
225
+ warn('KDNA_Core.json', `ontology.${label}.essence`, 'Contains vague language.');
226
+ } else {
227
+ pass('KDNA_Core.json', `ontology.${label}.essence`);
228
+ }
229
+
230
+ if (!con.boundary || isNegationOnly(con.boundary)) {
231
+ fail(
232
+ 'KDNA_Core.json',
233
+ `ontology.${label}.boundary`,
234
+ con.boundary,
235
+ 'Negation-only boundary. Must name a specific concept this is often confused with, not just "not X".',
236
+ );
237
+ } else {
238
+ pass('KDNA_Core.json', `ontology.${label}.boundary`);
239
+ }
240
+
241
+ if (!con.trigger_signal || con.trigger_signal.length < 15) {
242
+ warn(
243
+ 'KDNA_Core.json',
244
+ `ontology.${label}.trigger_signal`,
245
+ 'Trigger signal too short. Should be observable words or patterns the agent can detect.',
246
+ );
247
+ } else {
248
+ pass('KDNA_Core.json', `ontology.${label}.trigger_signal`);
249
+ }
250
+ }
251
+ }
252
+
253
+ // Check stances
254
+ if (core.stances && Array.isArray(core.stances)) {
255
+ if (core.stances.length < 2) {
256
+ warn('KDNA_Core.json', 'stances', `Only ${core.stances.length} stance(s). Recommended: 2-5.`);
257
+ }
258
+ for (let i = 0; i < core.stances.length; i++) {
259
+ const s = core.stances[i];
260
+ if (typeof s !== 'string') {
261
+ fail(
262
+ 'KDNA_Core.json',
263
+ `stances[${i}]`,
264
+ JSON.stringify(s),
265
+ 'Must be a string, not an object.',
266
+ );
267
+ } else if (isSlogan(s)) {
268
+ fail(
269
+ 'KDNA_Core.json',
270
+ `stances[${i}]`,
271
+ s,
272
+ 'Reads like a slogan. Stances must be prescriptive positions that bias agent behavior.',
273
+ );
274
+ } else if (isVague(s)) {
275
+ warn('KDNA_Core.json', `stances[${i}]`, 'Contains vague language.');
276
+ } else {
277
+ pass('KDNA_Core.json', `stances[${i}]`);
278
+ }
279
+ }
280
+ }
281
+
282
+ // Load Patterns
283
+ const patterns = readJson(path.join(abs, 'KDNA_Patterns.json'));
284
+ if (!patterns) error('KDNA_Patterns.json not found or invalid JSON');
285
+
286
+ // Check misunderstandings
287
+ if (patterns.misunderstandings && Array.isArray(patterns.misunderstandings)) {
288
+ for (const ms of patterns.misunderstandings) {
289
+ const label = ms.id || '?';
290
+
291
+ if (!ms.wrong || isStrawMan(ms.wrong)) {
292
+ fail(
293
+ 'KDNA_Patterns.json',
294
+ `misunderstandings.${label}.wrong`,
295
+ ms.wrong,
296
+ 'Straw-man argument. Must describe a belief a real agent might actually hold, not an absurd position.',
297
+ );
298
+ } else {
299
+ pass('KDNA_Patterns.json', `misunderstandings.${label}.wrong`);
300
+ }
301
+
302
+ if (!ms.key_distinction || ms.key_distinction.length < 15) {
303
+ warn(
304
+ 'KDNA_Patterns.json',
305
+ `misunderstandings.${label}.key_distinction`,
306
+ 'Key distinction too short. Must name the conceptual boundary.',
307
+ );
308
+ } else {
309
+ pass('KDNA_Patterns.json', `misunderstandings.${label}.key_distinction`);
310
+ }
311
+ }
312
+ }
313
+
314
+ // Check self-checks
315
+ if (patterns.self_check && Array.isArray(patterns.self_check)) {
316
+ for (let i = 0; i < patterns.self_check.length; i++) {
317
+ const sc = patterns.self_check[i];
318
+ if (typeof sc !== 'string') {
319
+ fail(
320
+ 'KDNA_Patterns.json',
321
+ `self_check[${i}]`,
322
+ JSON.stringify(sc),
323
+ 'Must be a string, not an object.',
324
+ );
325
+ } else if (isGenericSelfCheck(sc)) {
326
+ fail(
327
+ 'KDNA_Patterns.json',
328
+ `self_check[${i}]`,
329
+ sc,
330
+ 'Generic question. Self-checks must be domain-specific, not "is this helpful?".',
331
+ );
332
+ } else if (!sc.endsWith('?')) {
333
+ warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should end with a question mark.');
334
+ passes++;
335
+ } else {
336
+ pass('KDNA_Patterns.json', `self_check[${i}]`);
337
+ }
338
+ }
339
+ }
340
+
341
+ // Check kdna.json completeness
342
+ const manifest = readJson(path.join(abs, 'kdna.json'));
343
+ if (manifest) {
344
+ const emptyFields = [];
345
+ if (!manifest.description || manifest.description.length < 10) emptyFields.push('description');
346
+ if (!manifest.keywords || manifest.keywords.length === 0) emptyFields.push('keywords');
347
+ if (!manifest.author?.name) emptyFields.push('author.name');
348
+ if (!manifest.author?.id) emptyFields.push('author.id');
349
+ if (!manifest.registry?.repo) emptyFields.push('registry.repo');
350
+
351
+ if (emptyFields.length > 0) {
352
+ warn('kdna.json', 'manifest', `Empty fields: ${emptyFields.join(', ')}`);
353
+ } else {
354
+ pass('kdna.json', 'manifest');
355
+ }
356
+ } else {
357
+ warn(
358
+ 'kdna.json',
359
+ 'manifest',
360
+ 'Not found. A kdna.json manifest is recommended for registry publication.',
361
+ );
362
+ }
363
+
364
+ // Summary
365
+ console.log('');
366
+ console.log('═'.repeat(60));
367
+ const total = errors + warnings + passes;
368
+ console.log(` ${passes} passed, ${warnings} warnings, ${errors} errors out of ${total} checks`);
369
+ if (errors === 0) {
370
+ console.log(` ✓ Ready to publish`);
371
+ } else {
372
+ console.log(` ✗ ${errors} issue(s) must be fixed before publishing`);
373
+ }
374
+ console.log('═'.repeat(60));
375
+
376
+ if (errors > 0) process.exit(1);
377
+ }
378
+
379
+ // ═══════════════════════════════════════════════════════════════════════
380
+ // v0.7: full publish pipeline (validate + pack + sign + upload + patch)
381
+ // ═══════════════════════════════════════════════════════════════════════
382
+
383
+ const crypto = require('crypto');
384
+ const { execSync, execFileSync } = require('child_process');
385
+ const identity = require('./identity');
386
+ const { fingerprint } = identity;
387
+
388
+ const NAME_RE = /^@([a-z][a-z0-9-]*)\/([a-z][a-z0-9_]*)$/;
389
+
390
+ function identityPaths() {
391
+ // Recompute each call so KDNA_IDENTITY_DIR env var can be changed at runtime
392
+ const dir =
393
+ process.env.KDNA_IDENTITY_DIR ||
394
+ path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna', 'identity');
395
+ return {
396
+ privateKeyPath: path.join(dir, 'kdna.key'),
397
+ publicKeyPath: path.join(dir, 'kdna.pub'),
398
+ dir,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Canonical signing payload: sorted (filename, sha256) pairs of all .json files
404
+ * inside the .kdna ZIP, joined as `name:hex\n`. This is what the signature covers.
405
+ *
406
+ * Excludes the `signature` field from kdna.json itself (computed by removing it
407
+ * before hashing). All other files included as-is.
408
+ */
409
+ function canonicalPayload(srcDir) {
410
+ const files = fs
411
+ .readdirSync(srcDir)
412
+ .filter((f) => f.endsWith('.json'))
413
+ .sort();
414
+ const parts = [];
415
+ for (const f of files) {
416
+ const full = path.join(srcDir, f);
417
+ let buf;
418
+ if (f === 'kdna.json') {
419
+ const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
420
+ delete obj.signature;
421
+ buf = Buffer.from(JSON.stringify(obj));
422
+ } else {
423
+ buf = fs.readFileSync(full);
424
+ }
425
+ const hash = crypto.createHash('sha256').update(buf).digest('hex');
426
+ parts.push(`${f}:${hash}`);
427
+ }
428
+ return parts.join('\n');
429
+ }
430
+
431
+ function signPayload(payload, privateKeyPem) {
432
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
433
+ const sig = crypto.sign(null, Buffer.from(payload), privateKey);
434
+ return sig.toString('hex');
435
+ }
436
+
437
+ function loadIdentity() {
438
+ const { privateKeyPath, publicKeyPath, dir } = identityPaths();
439
+ if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
440
+ error(`No identity found at ${dir}. Run: kdna identity init (or set KDNA_IDENTITY_DIR)`);
441
+ }
442
+ return {
443
+ privateKey: fs.readFileSync(privateKeyPath, 'utf8'),
444
+ publicKey: fs.readFileSync(publicKeyPath, 'utf8'),
445
+ };
446
+ }
447
+
448
+ function publicKeyToScopeFormat(publicKeyPem) {
449
+ // The trust_pubkey in registry is stored as "ed25519:<sha256-of-PEM-hex>"
450
+ // because Ed25519 PEM is multi-line; the scope key is a stable fingerprint.
451
+ return 'ed25519:' + crypto.createHash('sha256').update(publicKeyPem).digest('hex');
452
+ }
453
+
454
+ function packToFile(domainDir, outPath) {
455
+ const files = fs
456
+ .readdirSync(domainDir)
457
+ .filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE');
458
+ if (!files.includes('kdna.json')) error('kdna.json required in domain folder for publish.');
459
+
460
+ const script = `import zipfile, os
461
+ src = ${JSON.stringify(domainDir)}
462
+ out = ${JSON.stringify(outPath)}
463
+ files = ${JSON.stringify(files)}
464
+ with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zf:
465
+ for f in sorted(files):
466
+ zf.write(os.path.join(src, f), f)
467
+ `;
468
+ const tmpPy = `/tmp/kdna-publish-pack-${Date.now()}.py`;
469
+ try {
470
+ fs.writeFileSync(tmpPy, script);
471
+ execSync(`python3 ${tmpPy}`, { stdio: 'pipe' });
472
+ } finally {
473
+ try {
474
+ fs.unlinkSync(tmpPy);
475
+ } catch {
476
+ /* ignore */
477
+ }
478
+ }
479
+ }
480
+
481
+ function sha256File(p) {
482
+ return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
483
+ }
484
+
485
+ /**
486
+ * kdna publish <path> — Full publish pipeline.
487
+ *
488
+ * Steps:
489
+ * 1. Validate name = @scope/name; load identity; validate author.pubkey
490
+ * 2. Quality gate (cmdPublishCheck, soft)
491
+ * 3. Write signature into kdna.json (canonical payload signed with identity)
492
+ * 4. Pack into .kdna
493
+ * 5. Compute sha256
494
+ * 6. If --release-tag <tag> and --repo <owner/name>: upload via gh CLI
495
+ * 7. Print registry patch JSON
496
+ */
497
+ function cmdPublish(domainPath, args = []) {
498
+ const abs = path.resolve(domainPath);
499
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) error(`Not a directory: ${abs}`);
500
+
501
+ const manifestPath = path.join(abs, 'kdna.json');
502
+ if (!fs.existsSync(manifestPath)) error('kdna.json required at domain root.');
503
+
504
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
505
+ const name = manifest.name;
506
+ const m = name && name.match(NAME_RE);
507
+ if (!m) {
508
+ error(`kdna.json.name "${name || '?'}" must be @scope/name format (e.g. @aikdna/writing).`);
509
+ }
510
+ if (!manifest.version) error('kdna.json.version required.');
511
+
512
+ const { privateKey, publicKey } = loadIdentity();
513
+ const scopeKey = publicKeyToScopeFormat(publicKey);
514
+
515
+ console.log('═'.repeat(60));
516
+ console.log(` Publishing ${name}@${manifest.version}`);
517
+ console.log('═'.repeat(60));
518
+ console.log(` Identity fingerprint: ${fingerprint(publicKey)}`);
519
+ console.log(` Scope trust key: ${scopeKey.slice(0, 28)}…`);
520
+ console.log('');
521
+
522
+ // 1. Update author.pubkey if missing/mismatch
523
+ if (!manifest.author) manifest.author = {};
524
+ if (manifest.author.pubkey && manifest.author.pubkey !== scopeKey) {
525
+ error(
526
+ `kdna.json.author.pubkey (${manifest.author.pubkey.slice(0, 20)}…) does not match your identity (${scopeKey.slice(0, 20)}…). Refusing to overwrite. Either remove the field, or use the matching identity.`,
527
+ );
528
+ }
529
+ manifest.author.pubkey = scopeKey;
530
+ // Embed full PEM so consumers can verify the signature against author.pubkey fingerprint
531
+ manifest.author.public_key_pem = publicKey;
532
+
533
+ // 2. Write signature
534
+ delete manifest.signature;
535
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
536
+ const payload = canonicalPayload(abs);
537
+ const sig = signPayload(payload, privateKey);
538
+ manifest.signature = 'ed25519:' + sig;
539
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
540
+ console.log(
541
+ ` ✓ Signed (payload covers ${fs.readdirSync(abs).filter((f) => f.endsWith('.json')).length} json files)`,
542
+ );
543
+
544
+ // 3. Pack
545
+ const fileName = `${m[2]}-${manifest.version}.kdna`;
546
+ const outDir = args.includes('--output')
547
+ ? args[args.indexOf('--output') + 1]
548
+ : path.join(abs, 'dist');
549
+ fs.mkdirSync(outDir, { recursive: true });
550
+ const outPath = path.join(outDir, fileName);
551
+ packToFile(abs, outPath);
552
+ const sha256 = sha256File(outPath);
553
+ const size = fs.statSync(outPath).size;
554
+ console.log(` ✓ Packed: ${outPath} (${size} bytes)`);
555
+ console.log(` ✓ sha256: ${sha256}`);
556
+
557
+ // 4. Optional upload via gh CLI
558
+ const tagIdx = args.indexOf('--release-tag');
559
+ const repoIdx = args.indexOf('--repo');
560
+ let kdnaUrl = null;
561
+ if (tagIdx >= 0 && repoIdx >= 0) {
562
+ const tag = args[tagIdx + 1];
563
+ const repo = args[repoIdx + 1];
564
+ console.log('');
565
+ console.log(` Uploading to ${repo} release ${tag}...`);
566
+ try {
567
+ execFileSync('gh', ['release', 'upload', tag, outPath, '--repo', repo, '--clobber'], {
568
+ stdio: 'inherit',
569
+ });
570
+ kdnaUrl = `https://github.com/${repo}/releases/download/${tag}/${fileName}`;
571
+ console.log(` ✓ Uploaded: ${kdnaUrl}`);
572
+ } catch {
573
+ console.warn(` ⚠ Upload failed. You can manually upload ${outPath}.`);
574
+ }
575
+ }
576
+
577
+ // 5. Registry patch
578
+ const patch = {
579
+ name,
580
+ type: manifest.cluster ? 'cluster' : 'domain',
581
+ version: manifest.version,
582
+ kdna_url: kdnaUrl,
583
+ sha256,
584
+ signature: manifest.signature,
585
+ release_status: kdnaUrl ? 'published_signed' : 'published_signed_local',
586
+ author: { ...manifest.author },
587
+ };
588
+
589
+ console.log('');
590
+ console.log('─'.repeat(60));
591
+ console.log('Registry patch (apply to kdna-registry/domains.json):');
592
+ console.log('─'.repeat(60));
593
+ console.log(JSON.stringify(patch, null, 2));
594
+ console.log('');
595
+ console.log(
596
+ `Next: open a PR to kdna-registry merging this patch into the matching entry by "name".`,
597
+ );
598
+ }
599
+
600
+ module.exports = { cmdPublishCheck, cmdPublish, canonicalPayload, publicKeyToScopeFormat };