@aikdna/kdna-cli 0.16.10 → 0.18.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/loader.js CHANGED
@@ -15,8 +15,8 @@ const FILE_MAP = core.FILE_MAP;
15
15
  * Read and parse a KDNA JSON file.
16
16
  * Returns null if the file does not exist.
17
17
  */
18
- function readFile(domainDir, filename) {
19
- const filePath = path.join(domainDir, filename);
18
+ function readFile(sourceDir, filename) {
19
+ const filePath = path.join(sourceDir, filename);
20
20
  if (!fs.existsSync(filePath)) return null;
21
21
  try {
22
22
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -29,32 +29,32 @@ function readFile(domainDir, filename) {
29
29
  * Load the minimum required KDNA files (Core + Patterns).
30
30
  * Always load these. They form the cognition baseline.
31
31
  */
32
- function loadCorePatterns(domainDir) {
33
- const coreData = readFile(domainDir, FILE_MAP.core);
34
- const patternsData = readFile(domainDir, FILE_MAP.patterns);
32
+ function loadCorePatterns(sourceDir) {
33
+ const coreData = readFile(sourceDir, FILE_MAP.core);
34
+ const patternsData = readFile(sourceDir, FILE_MAP.patterns);
35
35
  return core.loadCorePatternsFromData(coreData, patternsData);
36
36
  }
37
37
 
38
38
  /**
39
39
  * Load a complete KDNA domain.
40
40
  *
41
- * @param {string} domainDir — path to the domain folder
41
+ * @param {string} sourceDir — path to a dev source directory
42
42
  * @param {object} [options]
43
43
  * @param {string} [options.input] — user input text for conditional loading
44
44
  * @param {'all'|'minimum'|'auto'} [options.mode='auto'] — loading mode
45
45
  * @returns {object|null} loaded KDNA files keyed by type, or null if minimum files are missing
46
46
  */
47
- function loadDomain(domainDir, options = {}) {
47
+ function loadDomain(sourceDir, options = {}) {
48
48
  const dataMap = { core: null, patterns: null };
49
49
 
50
- dataMap.core = readFile(domainDir, FILE_MAP.core);
51
- dataMap.patterns = readFile(domainDir, FILE_MAP.patterns);
50
+ dataMap.core = readFile(sourceDir, FILE_MAP.core);
51
+ dataMap.patterns = readFile(sourceDir, FILE_MAP.patterns);
52
52
 
53
53
  if (!dataMap.core || !dataMap.patterns) return null;
54
54
 
55
55
  // Also read optional files that might be present
56
56
  for (const key of ['scenarios', 'cases', 'reasoning', 'evolution']) {
57
- const data = readFile(domainDir, FILE_MAP[key]);
57
+ const data = readFile(sourceDir, FILE_MAP[key]);
58
58
  if (data) dataMap[key] = data;
59
59
  }
60
60
 
@@ -0,0 +1,229 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const PATHS = require('./paths');
5
+ const { parseName } = require('./registry');
6
+ const core = require('@aikdna/kdna-core');
7
+
8
+ if (typeof core.createKdnaAssetReader !== 'function') {
9
+ throw new Error('@aikdna/kdna-core >=0.5.0 is required for direct .kdna asset loading');
10
+ }
11
+
12
+ const assetReader = core.createKdnaAssetReader();
13
+
14
+ const INDEX_VERSION = 2;
15
+
16
+ function ensureDir(dir) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ }
19
+
20
+ function readJsonFile(file) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function writeJsonFile(file, data) {
29
+ ensureDir(path.dirname(file));
30
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
31
+ }
32
+
33
+ function readIndex() {
34
+ const data = readJsonFile(PATHS.packageIndex);
35
+ if (data?.packages && typeof data.packages === 'object') return data;
36
+ return { version: INDEX_VERSION, packages: {} };
37
+ }
38
+
39
+ function writeIndex(index) {
40
+ index.version = INDEX_VERSION;
41
+ writeJsonFile(PATHS.packageIndex, index);
42
+ }
43
+
44
+ function sha256File(filePath) {
45
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
46
+ }
47
+
48
+ function assetDigest(filePath) {
49
+ return `sha256:${sha256File(filePath)}`;
50
+ }
51
+
52
+ function contentDigest(kdnaPath) {
53
+ return assetReader.contentDigestSync(assetReader.openSync(kdnaPath));
54
+ }
55
+
56
+ function assetDir(scope, ident, version) {
57
+ return path.join(PATHS.packages, scope, ident, version || 'unknown');
58
+ }
59
+
60
+ function assetFileName(ident, version) {
61
+ return `${ident}-${version || 'unknown'}.kdna`;
62
+ }
63
+
64
+ function readContainerJson(kdnaPath, fileName, options = {}) {
65
+ const asset = assetReader.openSync(kdnaPath);
66
+ return assetReader.readJsonSync(asset, fileName, options);
67
+ }
68
+
69
+ function readContainerEntry(kdnaPath, fileName) {
70
+ const asset = assetReader.openSync(kdnaPath);
71
+ return assetReader.readEntrySync(asset, fileName);
72
+ }
73
+
74
+ function listContainerEntries(kdnaPath) {
75
+ const asset = assetReader.openSync(kdnaPath);
76
+ return assetReader.listEntriesSync(asset);
77
+ }
78
+
79
+ function readContainer(kdnaPath, options = {}) {
80
+ const asset = assetReader.openSync(kdnaPath);
81
+ return {
82
+ manifest: assetReader.readJsonSync(asset, 'kdna.json'),
83
+ core: assetReader.readJsonSync(asset, 'KDNA_Core.json', options),
84
+ patterns: assetReader.readJsonSync(asset, 'KDNA_Patterns.json', options),
85
+ scenarios: assetReader.readJsonSync(asset, 'KDNA_Scenarios.json', options),
86
+ cases: assetReader.readJsonSync(asset, 'KDNA_Cases.json', options),
87
+ reasoning: assetReader.readJsonSync(asset, 'KDNA_Reasoning.json', options),
88
+ evolution: assetReader.readJsonSync(asset, 'KDNA_Evolution.json', options),
89
+ files: assetReader.listEntriesSync(asset),
90
+ };
91
+ }
92
+
93
+ function verifyAsset(kdnaPath, options = {}) {
94
+ const asset = assetReader.openSync(kdnaPath);
95
+ return assetReader.verifySync(asset, options);
96
+ }
97
+
98
+ function getInstalled(input) {
99
+ const parsed = parseName(input);
100
+ if (!parsed) return null;
101
+ const index = readIndex();
102
+ const entry = index.packages[parsed.full];
103
+ if (!entry?.asset_path || !fs.existsSync(entry.asset_path)) return null;
104
+ return { ...entry, parsed };
105
+ }
106
+
107
+ function listInstalled() {
108
+ const index = readIndex();
109
+ return Object.entries(index.packages)
110
+ .map(([full, entry]) => ({ full, ...entry }))
111
+ .filter((entry) => entry.asset_path && fs.existsSync(entry.asset_path))
112
+ .sort((a, b) => a.full.localeCompare(b.full));
113
+ }
114
+
115
+ function readAssetManifest(assetPath) {
116
+ return readContainerJson(assetPath, 'kdna.json') || {};
117
+ }
118
+
119
+ function receiptPathForAsset(assetPath) {
120
+ return path.join(path.dirname(assetPath), 'receipt.json');
121
+ }
122
+
123
+ function installAsset({ sourcePath, name, version, source = {} }) {
124
+ const parsed = parseName(name);
125
+ if (!parsed) throw new Error(`Invalid scoped domain name: ${name}`);
126
+ const finalVersion = version || 'unknown';
127
+ const destDir = assetDir(parsed.scope, parsed.ident, finalVersion);
128
+ const dest = path.join(destDir, assetFileName(parsed.ident, finalVersion));
129
+ ensureDir(destDir);
130
+ fs.copyFileSync(sourcePath, dest);
131
+
132
+ const manifest = readAssetManifest(dest);
133
+ const installedAt = new Date().toISOString();
134
+ const computedAssetDigest = assetDigest(dest);
135
+ const computedContentDigest = contentDigest(dest);
136
+ const receiptPath = receiptPathForAsset(dest);
137
+ const receipt = {
138
+ version: 1,
139
+ name: parsed.full,
140
+ asset_path: dest,
141
+ asset_digest: computedAssetDigest,
142
+ content_digest: computedContentDigest,
143
+ package_version: finalVersion,
144
+ judgment_version: manifest.judgment_version || null,
145
+ access: manifest.access || 'open',
146
+ signature: manifest.signature || null,
147
+ installed_at: installedAt,
148
+ source,
149
+ };
150
+ writeJsonFile(receiptPath, receipt);
151
+
152
+ const index = readIndex();
153
+ index.packages[parsed.full] = {
154
+ name: parsed.full,
155
+ version: finalVersion,
156
+ asset_path: dest,
157
+ receipt_path: receiptPath,
158
+ asset_digest: computedAssetDigest,
159
+ content_digest: computedContentDigest,
160
+ judgment_version: manifest.judgment_version || null,
161
+ access: manifest.access || 'open',
162
+ signature: manifest.signature || null,
163
+ installed_at: installedAt,
164
+ source,
165
+ };
166
+ writeIndex(index);
167
+ return index.packages[parsed.full];
168
+ }
169
+
170
+ function resolveAsset(input) {
171
+ const expanded = input.replace(/^~/, process.env.HOME || '');
172
+ const looksLikeFile =
173
+ input.endsWith('.kdna') &&
174
+ (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/') || fs.existsSync(expanded));
175
+ if (looksLikeFile) {
176
+ const abs = path.resolve(expanded);
177
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
178
+ const manifest = readAssetManifest(abs);
179
+ const full = manifest.name;
180
+ const parsed = full ? parseName(full) : null;
181
+ return {
182
+ name: full || path.basename(abs, '.kdna'),
183
+ parsed,
184
+ asset_path: abs,
185
+ receipt_path: null,
186
+ version: manifest.version || null,
187
+ judgment_version: manifest.judgment_version || null,
188
+ access: manifest.access || 'open',
189
+ asset_digest: assetDigest(abs),
190
+ content_digest: contentDigest(abs),
191
+ source: { type: 'local-file', path: abs },
192
+ local_file: true,
193
+ };
194
+ }
195
+ return getInstalled(input);
196
+ }
197
+
198
+ function removeInstalled(input) {
199
+ const parsed = parseName(input);
200
+ if (!parsed) return false;
201
+ const index = readIndex();
202
+ const entry = index.packages[parsed.full];
203
+ if (!entry) return false;
204
+ delete index.packages[parsed.full];
205
+ writeIndex(index);
206
+ if (entry.asset_path) {
207
+ const versionDir = path.dirname(entry.asset_path);
208
+ fs.rmSync(versionDir, { recursive: true, force: true });
209
+ }
210
+ return true;
211
+ }
212
+
213
+ module.exports = {
214
+ readIndex,
215
+ writeIndex,
216
+ sha256File,
217
+ assetDigest,
218
+ contentDigest,
219
+ readContainer,
220
+ readContainerEntry,
221
+ readContainerJson,
222
+ listContainerEntries,
223
+ verifyAsset,
224
+ getInstalled,
225
+ listInstalled,
226
+ installAsset,
227
+ resolveAsset,
228
+ removeInstalled,
229
+ };
package/src/paths.js ADDED
@@ -0,0 +1,44 @@
1
+ // KDNA shared path configuration — canonical source for ~/.kdna structure
2
+ // Spec: docs/local-kdna-home-spec.md
3
+
4
+ const path = require('path');
5
+
6
+ const KDNA_HOME = process.env.KDNA_HOME
7
+ || path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
8
+
9
+ const PATHS = {
10
+ root: KDNA_HOME,
11
+ config: path.join(KDNA_HOME, 'config.json'),
12
+ identity: path.join(KDNA_HOME, 'identity'),
13
+ domains: {
14
+ root: path.join(KDNA_HOME, 'domains'),
15
+ official: path.join(KDNA_HOME, 'domains', 'official'),
16
+ local: path.join(KDNA_HOME, 'domains', 'local'),
17
+ private: path.join(KDNA_HOME, 'domains', 'private'),
18
+ // Legacy flat path — used for migration only
19
+ legacy: path.join(KDNA_HOME, 'domains'),
20
+ // All three directories for scanning
21
+ all: [
22
+ path.join(KDNA_HOME, 'domains', 'official'),
23
+ path.join(KDNA_HOME, 'domains', 'local'),
24
+ path.join(KDNA_HOME, 'domains', 'private'),
25
+ ],
26
+ },
27
+ clusters: path.join(KDNA_HOME, 'clusters'),
28
+ packages: path.join(KDNA_HOME, 'packages'),
29
+ packageIndex: path.join(KDNA_HOME, 'index.json'),
30
+ registry: path.join(KDNA_HOME, 'registry'),
31
+ registryCache: path.join(KDNA_HOME, 'registry', 'cache.json'),
32
+ traces: path.join(KDNA_HOME, 'traces'),
33
+ feedback: path.join(KDNA_HOME, 'feedback'),
34
+ evals: path.join(KDNA_HOME, 'evals'),
35
+ cache: path.join(KDNA_HOME, 'cache'),
36
+ licenses: path.join(KDNA_HOME, 'licenses'),
37
+ };
38
+
39
+ // Runtime asset store aliases
40
+ PATHS.USER_KDNA_DIR = KDNA_HOME;
41
+ PATHS.INSTALL_DIR = PATHS.packages;
42
+
43
+ module.exports = PATHS;
44
+ module.exports.KDNA_HOME = KDNA_HOME;
package/src/publish.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { EXIT } = require('./cmds/_common');
10
+ const { EXIT, selfCheckText, isYesNoSelfCheck } = require('./cmds/_common');
11
11
 
12
12
  function error(msg, code = EXIT.VALIDATION_FAILED) {
13
13
  console.error(`Error: ${msg}`);
@@ -141,9 +141,69 @@ function isGenericSelfCheck(question) {
141
141
  return false;
142
142
  }
143
143
 
144
+ // ─── Human Lock Gate ──────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Check whether the domain satisfies Human Lock requirements.
148
+ * Returns { passed, issues[] } — publish should be blocked if !passed.
149
+ */
150
+ function checkHumanLock(domainPath) {
151
+ const core = readJson(path.join(domainPath, 'KDNA_Core.json'));
152
+ if (!core) return { passed: false, issues: ['KDNA_Core.json not found'] };
153
+
154
+ const issues = [];
155
+ const cards = [];
156
+
157
+ // Collect judgment-class cards from axioms, boundaries, risks
158
+ if (core.axioms) {
159
+ for (const a of core.axioms) {
160
+ cards.push({ type: 'axiom', id: a.id || '?', status: a.status, human_lock: a.human_lock });
161
+ }
162
+ }
163
+ if (core.boundaries) {
164
+ for (const b of core.boundaries) {
165
+ cards.push({ type: 'boundary', id: b.id || '?', status: b.status, human_lock: b.human_lock });
166
+ }
167
+ }
168
+ if (core.risks || core.risk_model) {
169
+ const risks = core.risks || core.risk_model || [];
170
+ for (const r of risks) {
171
+ cards.push({ type: 'risk', id: r.id || '?', status: r.status, human_lock: r.human_lock });
172
+ }
173
+ }
174
+
175
+ if (cards.length === 0) return { passed: true, issues: [] };
176
+
177
+ for (const card of cards) {
178
+ // Rule 1: Must be locked
179
+ if (!card.status || !['locked', 'tested', 'published'].includes(card.status)) {
180
+ issues.push(`${card.type} "${card.id}" is not locked. Human Lock required before publish.`);
181
+ continue;
182
+ }
183
+ // Rule 2: Must have human_lock record
184
+ if (!card.human_lock || !card.human_lock.by || !card.human_lock.statement) {
185
+ issues.push(`${card.type} "${card.id}" is locked but has no valid Human Lock record.`);
186
+ continue;
187
+ }
188
+ // Rule 3: Lock must confirm judgment fields were reviewed
189
+ const checked = card.human_lock.checked || {};
190
+ if (!checked.applies_when) {
191
+ issues.push(`${card.type} "${card.id}" Human Lock does not confirm applies_when was reviewed.`);
192
+ }
193
+ if (!checked.does_not_apply_when) {
194
+ issues.push(`${card.type} "${card.id}" Human Lock does not confirm does_not_apply_when was reviewed.`);
195
+ }
196
+ if (!checked.failure_risk) {
197
+ issues.push(`${card.type} "${card.id}" Human Lock does not confirm failure_risk was reviewed.`);
198
+ }
199
+ }
200
+
201
+ return { passed: issues.length === 0, issues };
202
+ }
203
+
144
204
  // ─── Main check function ──────────────────────────────────────────────
145
205
 
146
- function cmdPublishCheck(domainPath) {
206
+ function cmdPublishCheck(domainPath, args = []) {
147
207
  const abs = path.resolve(domainPath);
148
208
  if (!fs.existsSync(abs)) error(`Domain not found: ${abs}`);
149
209
 
@@ -152,6 +212,35 @@ function cmdPublishCheck(domainPath) {
152
212
  console.log('═'.repeat(60));
153
213
  console.log('');
154
214
 
215
+ // ─── Human Lock Gate (must pass before any other checks) ──────────
216
+ const hl = checkHumanLock(abs);
217
+ if (!hl.passed) {
218
+ if (args.includes('--force')) {
219
+ console.warn(' ⚠ Human Lock Gate: OVERRIDDEN (--force). Proceeding with checks.');
220
+ console.warn(` ${hl.issues.length} unresolved Human Lock issue(s):`);
221
+ for (const issue of hl.issues) {
222
+ console.warn(` ${issue}`);
223
+ }
224
+ console.warn('');
225
+ } else {
226
+ console.error(' Human Lock Gate: BLOCKED');
227
+ console.error(` ${hl.issues.length} issue(s) found:`);
228
+ for (const issue of hl.issues) {
229
+ console.error(` ✗ ${issue}`);
230
+ }
231
+ console.error('');
232
+ console.error(' Judgment-class cards (axiom, boundary, risk, aesthetic)');
233
+ console.error(' must be locked with a valid Human Lock record before publishing.');
234
+ console.error(' Use kdna-studio or manually add human_lock to each card.');
235
+ console.error(' Use --force for emergency override (audited).');
236
+ console.error('');
237
+ process.exit(EXIT.HUMAN_LOCK_REQUIRED);
238
+ }
239
+ } else {
240
+ console.log(' ✓ Human Lock Gate: passed');
241
+ console.log('');
242
+ }
243
+
155
244
  let errors = 0;
156
245
  let warnings = 0;
157
246
  let passes = 0;
@@ -293,21 +382,22 @@ function cmdPublishCheck(domainPath) {
293
382
  }
294
383
  for (let i = 0; i < core.stances.length; i++) {
295
384
  const s = core.stances[i];
296
- if (typeof s !== 'string') {
385
+ const stanceText = typeof s === 'string' ? s : s && typeof s === 'object' ? s.stance : null;
386
+ if (!stanceText || typeof stanceText !== 'string') {
297
387
  fail(
298
388
  'KDNA_Core.json',
299
389
  `stances[${i}]`,
300
390
  JSON.stringify(s),
301
- 'Must be a string, not an object.',
391
+ 'Must be a string or an object with a stance string.',
302
392
  );
303
- } else if (isSlogan(s)) {
393
+ } else if (isSlogan(stanceText)) {
304
394
  fail(
305
395
  'KDNA_Core.json',
306
396
  `stances[${i}]`,
307
- s,
397
+ stanceText,
308
398
  'Reads like a slogan. Stances must be prescriptive positions that bias agent behavior.',
309
399
  );
310
- } else if (isVague(s)) {
400
+ } else if (isVague(stanceText)) {
311
401
  warn('KDNA_Core.json', `stances[${i}]`, 'Contains vague language.');
312
402
  } else {
313
403
  pass('KDNA_Core.json', `stances[${i}]`);
@@ -351,22 +441,23 @@ function cmdPublishCheck(domainPath) {
351
441
  if (patterns.self_check && Array.isArray(patterns.self_check)) {
352
442
  for (let i = 0; i < patterns.self_check.length; i++) {
353
443
  const sc = patterns.self_check[i];
354
- if (typeof sc !== 'string') {
444
+ const text = selfCheckText(sc);
445
+ if (!text) {
355
446
  fail(
356
447
  'KDNA_Patterns.json',
357
448
  `self_check[${i}]`,
358
449
  JSON.stringify(sc),
359
- 'Must be a string, not an object.',
450
+ 'Must be a string or an object with a question string.',
360
451
  );
361
- } else if (isGenericSelfCheck(sc)) {
452
+ } else if (isGenericSelfCheck(text)) {
362
453
  fail(
363
454
  'KDNA_Patterns.json',
364
455
  `self_check[${i}]`,
365
- sc,
456
+ text,
366
457
  'Generic question. Self-checks must be domain-specific, not "is this helpful?".',
367
458
  );
368
- } else if (!sc.endsWith('?')) {
369
- warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should end with a question mark.');
459
+ } else if (!isYesNoSelfCheck(sc)) {
460
+ warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should be answerable with yes/no.');
370
461
  passes++;
371
462
  } else {
372
463
  pass('KDNA_Patterns.json', `self_check[${i}]`);
@@ -440,9 +531,9 @@ function identityPaths() {
440
531
  * inside the .kdna ZIP, joined as `name:hex\n`. This is what the signature covers.
441
532
  *
442
533
  * Excludes the `signature` field from kdna.json itself (computed by removing it
443
- * before hashing). All other files included as-is.
534
+ * before hashing). Digest self-reference fields are also excluded. All other files included as-is.
444
535
  */
445
- function canonicalPayload(srcDir) {
536
+ function canonicalPayload(srcDir, opts = {}) {
446
537
  const files = fs
447
538
  .readdirSync(srcDir)
448
539
  .filter((f) => f.endsWith('.json'))
@@ -454,6 +545,10 @@ function canonicalPayload(srcDir) {
454
545
  if (f === 'kdna.json') {
455
546
  const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
456
547
  delete obj.signature;
548
+ delete obj.asset_digest;
549
+ delete obj.container_sha256;
550
+ if (!opts.includeContentDigest) delete obj.content_digest;
551
+ delete obj._source;
457
552
  buf = Buffer.from(JSON.stringify(obj));
458
553
  } else {
459
554
  buf = fs.readFileSync(full);
@@ -464,6 +559,46 @@ function canonicalPayload(srcDir) {
464
559
  return parts.join('\n');
465
560
  }
466
561
 
562
+ function stableStringify(value) {
563
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
564
+ if (value && typeof value === 'object') {
565
+ return `{${Object.keys(value)
566
+ .sort()
567
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
568
+ .join(',')}}`;
569
+ }
570
+ return JSON.stringify(value);
571
+ }
572
+
573
+ function manifestForContentDigest(manifest) {
574
+ const copy = { ...(manifest || {}) };
575
+ delete copy.signature;
576
+ delete copy.asset_digest;
577
+ delete copy.container_sha256;
578
+ delete copy.content_digest;
579
+ delete copy._source;
580
+ return copy;
581
+ }
582
+
583
+ function sourceContentDigest(srcDir) {
584
+ const files = fs
585
+ .readdirSync(srcDir)
586
+ .filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE')
587
+ .sort();
588
+ const parts = [];
589
+ for (const f of files) {
590
+ let buf;
591
+ if (f === 'kdna.json') {
592
+ const obj = JSON.parse(fs.readFileSync(path.join(srcDir, f), 'utf8'));
593
+ buf = Buffer.from(stableStringify(manifestForContentDigest(obj)));
594
+ } else {
595
+ buf = fs.readFileSync(path.join(srcDir, f));
596
+ }
597
+ parts.push(`${f}:${crypto.createHash('sha256').update(buf).digest('hex')}`);
598
+ }
599
+ return `sha256:${crypto.createHash('sha256').update(Buffer.from(parts.join('\n'))).digest('hex')}`;
600
+ }
601
+
467
602
  function signPayload(payload, privateKeyPem) {
468
603
  const privateKey = crypto.createPrivateKey(privateKeyPem);
469
604
  const sig = crypto.sign(null, Buffer.from(payload), privateKey);
@@ -491,7 +626,7 @@ function packToFile(domainDir, outPath) {
491
626
  const files = fs
492
627
  .readdirSync(domainDir)
493
628
  .filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE');
494
- if (!files.includes('kdna.json')) error('kdna.json required in domain folder for publish.');
629
+ if (!files.includes('kdna.json')) error('kdna.json required in dev source directory for publish.');
495
630
 
496
631
  const script = `import zipfile, os
497
632
  src = ${JSON.stringify(domainDir)}
@@ -551,6 +686,26 @@ function cmdPublish(domainPath, args = []) {
551
686
  console.log('═'.repeat(60));
552
687
  console.log(` Publishing ${name}@${manifest.version}`);
553
688
  console.log('═'.repeat(60));
689
+
690
+ // ─── Human Lock Gate ──────────────────────────────────────────────
691
+ const hl = checkHumanLock(abs);
692
+ if (!hl.passed) {
693
+ console.error('');
694
+ console.error(' Human Lock Gate: BLOCKED');
695
+ for (const issue of hl.issues) {
696
+ console.error(` ✗ ${issue}`);
697
+ }
698
+ console.error('');
699
+ console.error(' Use kdna publish --check for details, or --force to override.');
700
+ if (!args.includes('--force')) {
701
+ process.exit(EXIT.HUMAN_LOCK_REQUIRED);
702
+ }
703
+ console.warn(' ⚠ --force override: publishing without Human Lock (emergency only)');
704
+ } else {
705
+ console.log(` ✓ Human Lock Gate: passed`);
706
+ }
707
+ console.log('');
708
+
554
709
  console.log(` Identity fingerprint: ${fingerprint(publicKey)}`);
555
710
  console.log(` Scope trust key: ${scopeKey.slice(0, 28)}…`);
556
711
  console.log('');
@@ -568,9 +723,14 @@ function cmdPublish(domainPath, args = []) {
568
723
 
569
724
  // 2. Write signature
570
725
  delete manifest.signature;
726
+ delete manifest.asset_digest;
727
+ delete manifest.container_sha256;
728
+ delete manifest.content_digest;
729
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
730
+ manifest.content_digest = sourceContentDigest(abs);
571
731
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
572
- const payload = canonicalPayload(abs);
573
- const sig = signPayload(payload, privateKey);
732
+ const signedPayload = canonicalPayload(abs, { includeContentDigest: true });
733
+ const sig = signPayload(signedPayload, privateKey);
574
734
  manifest.signature = 'ed25519:' + sig;
575
735
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
576
736
  console.log(
@@ -586,9 +746,10 @@ function cmdPublish(domainPath, args = []) {
586
746
  const outPath = path.join(outDir, fileName);
587
747
  packToFile(abs, outPath);
588
748
  const sha256 = sha256File(outPath);
749
+ const assetDigest = `sha256:${sha256}`;
589
750
  const size = fs.statSync(outPath).size;
590
751
  console.log(` ✓ Packed: ${outPath} (${size} bytes)`);
591
- console.log(` ✓ sha256: ${sha256}`);
752
+ console.log(` ✓ asset_digest: ${assetDigest}`);
592
753
 
593
754
  // 4. Optional upload via gh CLI
594
755
  const tagIdx = args.indexOf('--release-tag');
@@ -615,8 +776,9 @@ function cmdPublish(domainPath, args = []) {
615
776
  name,
616
777
  type: manifest.cluster ? 'cluster' : 'domain',
617
778
  version: manifest.version,
618
- kdna_url: kdnaUrl,
619
- sha256,
779
+ asset_url: kdnaUrl,
780
+ asset_digest: assetDigest,
781
+ content_digest: manifest.content_digest || null,
620
782
  signature: manifest.signature,
621
783
  release_status: kdnaUrl ? 'published_signed' : 'published_signed_local',
622
784
  author: { ...manifest.author },
@@ -633,4 +795,4 @@ function cmdPublish(domainPath, args = []) {
633
795
  );
634
796
  }
635
797
 
636
- module.exports = { cmdPublishCheck, cmdPublish, canonicalPayload, publicKeyToScopeFormat };
798
+ module.exports = { cmdPublishCheck, cmdPublish, checkHumanLock, canonicalPayload, publicKeyToScopeFormat };