@dreamforgestudiollc/emet 0.2.4 → 0.2.6

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 (101) hide show
  1. package/.env.example +53 -0
  2. package/CHANGELOG.md +67 -3
  3. package/CHARTER.md +19 -7
  4. package/README.md +106 -21
  5. package/docs/AUTHORSHIP.md +3 -3
  6. package/docs/BOTS.md +4 -0
  7. package/docs/COVERAGE.md +2 -2
  8. package/docs/DEPLOY.md +9 -4
  9. package/docs/GUARDRAILS-AND-MONORAILS.md +2 -1
  10. package/docs/GUIDED-SETUP.md +8 -3
  11. package/docs/PERMISSIONS.md +6 -3
  12. package/docs/ROADMAP.md +25 -22
  13. package/docs/SETUP.md +8 -2
  14. package/docs/SUBKEYS.md +59 -0
  15. package/gems/artifact-index.json +12 -0
  16. package/gems/bot-team-backup/floor.json +15 -0
  17. package/gems/bot-team-backup/gem.json +43 -0
  18. package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
  19. package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
  20. package/gems/fieldnote/floor.json +14 -0
  21. package/gems/fieldnote/gem.json +70 -0
  22. package/gems/fieldnote/runes/fieldnote.js +4 -0
  23. package/gems/lean-six-sigma/CHANGELOG.md +10 -0
  24. package/gems/lean-six-sigma/README.md +92 -0
  25. package/gems/lean-six-sigma/floor.json +31 -0
  26. package/gems/lean-six-sigma/gem.json +1296 -0
  27. package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
  28. package/gems/lean-six-sigma/guides/BELTS.md +52 -0
  29. package/gems/lean-six-sigma/guides/DMADV.md +56 -0
  30. package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
  31. package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
  32. package/gems/lean-six-sigma/guides/LEAN.md +97 -0
  33. package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
  34. package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
  35. package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
  36. package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
  37. package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
  38. package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
  39. package/gems/neurendipity/floor.json +65 -0
  40. package/gems/neurendipity/gem.json +42 -0
  41. package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
  42. package/{guides → gems/neurendipity/guides}/NEURODIVERGENT_SUPPORT.md +17 -8
  43. package/guides/README.md +3 -0
  44. package/package.json +11 -5
  45. package/server.json +44 -4
  46. package/src/aleph.js +4 -4
  47. package/src/annotations.js +38 -3
  48. package/src/atlas-role.js +1 -1
  49. package/src/attribution.js +555 -0
  50. package/src/conformance.js +2 -2
  51. package/src/database.js +6 -5
  52. package/src/dispatch.js +116 -13
  53. package/src/env.js +54 -9
  54. package/src/floor.js +82 -41
  55. package/src/gaps.js +39 -6
  56. package/src/gem-check.js +1359 -0
  57. package/src/guides.js +31 -4
  58. package/src/host-reach.js +23 -0
  59. package/src/http-remote.js +46 -2
  60. package/src/index.js +9 -2
  61. package/src/layers.js +1 -1
  62. package/src/lessons-to-checks.js +3 -3
  63. package/src/logger.js +2 -1
  64. package/src/mongo-connect.js +45 -2
  65. package/src/oauth-provider.js +2 -2
  66. package/src/owner-approvals.js +281 -0
  67. package/src/persons.js +20 -0
  68. package/src/redact.js +10 -4
  69. package/src/request-log.js +1 -1
  70. package/src/rune-dispatch.js +360 -0
  71. package/src/rune-registry.js +71 -0
  72. package/src/rune-scope.js +42 -0
  73. package/src/scopes.js +3 -0
  74. package/src/session-graph.js +25 -23
  75. package/src/session.js +9 -9
  76. package/src/setup.js +297 -30
  77. package/src/socket.js +545 -0
  78. package/src/startup-pages.js +23 -6
  79. package/src/startup-payload.js +9 -6
  80. package/src/subkey-store.js +357 -0
  81. package/src/subkeys.js +947 -0
  82. package/src/tools.js +299 -62
  83. package/src/transcript-session.js +1 -1
  84. package/src/validation.js +26 -2
  85. package/src/version.js +1 -1
  86. package/templates/BOARD.md +2 -2
  87. package/templates/BOTHANDOFF.md +2 -2
  88. package/templates/CHARACTER.md +1 -1
  89. package/templates/HANDOFF.md +1 -1
  90. package/templates/IDENTITY.md +1 -1
  91. package/templates/INDEX.md +19 -19
  92. package/templates/INSTALL.md +1 -1
  93. package/templates/PROCEDURE.md +1 -1
  94. package/templates/ROSTER.md +2 -2
  95. package/templates/SEED.md +2 -2
  96. package/templates/STAGING.md +2 -2
  97. package/templates/STANDARD.md +5 -5
  98. package/templates/THREAD.md +1 -1
  99. package/templates/USER.md +4 -1
  100. package/verification/load-gate/corpus.json +1765 -0
  101. package/verification/load-gate/load-gate.mjs +1246 -0
package/src/socket.js ADDED
@@ -0,0 +1,545 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Licensed under the MIT License - see LICENSE
6
+ *
7
+ * The GEM socket. It seats bundled GEMs from the release gems/ directory
8
+ * and nothing else. gems/neurendipity and gems/fieldnote ship with this
9
+ * release and are off unless the install lists the id in EMET_ACTIVE_GEMS.
10
+ *
11
+ * gem-library/examples/neurendipity is a shape, not an installed GEM.
12
+ * It is not on this path and it is not loaded.
13
+ *
14
+ * The socket does not run RUNE code. While a GEM is active, each RUNE is
15
+ * one read-only tool that serves the code. A refused GEM is one warning.
16
+ * It does not throw, and it does not stop emet_initialize. An empty list
17
+ * logs nothing.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { checkGem, satisfiesEmetRange } from './gem-check.js';
23
+ import { bindRunes, runeToolName } from './rune-registry.js';
24
+ import { noteArtifact } from './rune-dispatch.js';
25
+ import { resolveModulePath } from './module-path.js';
26
+ import { setting } from './env.js';
27
+ import { EMET_VERSION } from './version.js';
28
+
29
+ export const MAX_INSTALLED = 16;
30
+ export const MAX_ACTIVE = 4;
31
+ export const MAX_ACTIVE_FLOOR_BYTES = 16384;
32
+
33
+ const ID_RE = /^[a-z][a-z0-9-]{1,63}$/;
34
+
35
+ /** The release gems/ directory, next to src/. Bundled GEMs live here. */
36
+ export function releaseGemsRoot() {
37
+ return resolveModulePath(import.meta.url, ['..', 'gems']);
38
+ }
39
+
40
+ function inside(rootReal, candidateReal) {
41
+ const rel = path.relative(rootReal, candidateReal);
42
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
43
+ }
44
+
45
+ function fallbackId(name, used) {
46
+ const cleaned = String(name).toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/^-+/, '').slice(0, 60);
47
+ let id = cleaned.length >= 2 && /^[a-z]/.test(cleaned) ? cleaned : 'invalid';
48
+ if (!used.has(id)) return id;
49
+ let n = 2;
50
+ while (used.has(`${id}-${n}`)) n++;
51
+ const next = `${id}-${n}`;
52
+ return next.length <= 64 ? next : next.slice(0, 64);
53
+ }
54
+
55
+ function cmpId(a, b) {
56
+ if (a.id < b.id) return -1;
57
+ if (a.id > b.id) return 1;
58
+ return 0;
59
+ }
60
+
61
+ function sameDigestSet(a, b) {
62
+ const left = [...a].map(String).sort();
63
+ const right = [...b].map(String).sort();
64
+ if (left.length !== right.length) return false;
65
+ for (let i = 0; i < left.length; i++) if (left[i] !== right[i]) return false;
66
+ return true;
67
+ }
68
+
69
+ function hasConsent(consent, id, digests) {
70
+ if (!Array.isArray(consent)) return false;
71
+ return consent.some((row) => row && row.id === id && Array.isArray(row.digests) && sameDigestSet(row.digests, digests));
72
+ }
73
+
74
+ function onAcceptedList(accepted, id, digests) {
75
+ if (!Array.isArray(accepted) || accepted.length === 0) return false;
76
+ return accepted.some((row) => row && row.id === id && Array.isArray(row.digests) && sameDigestSet(row.digests, digests));
77
+ }
78
+
79
+ function emit(log, level, message, context) {
80
+ if (log && typeof log[level] === 'function') {
81
+ log[level](message, context);
82
+ return;
83
+ }
84
+ if (level === 'warn' || level === 'error') console.error(`emet: ${message}`);
85
+ else if (level === 'info') console.error(`emet: ${message}`);
86
+ }
87
+
88
+ function field(manifest, key, fallback) {
89
+ const value = manifest && manifest[key];
90
+ if (typeof fallback === 'string') return typeof value === 'string' ? value : fallback;
91
+ if (typeof fallback === 'boolean') return typeof value === 'boolean' ? value : fallback;
92
+ if (typeof fallback === 'number') return Number.isInteger(value) ? value : fallback;
93
+ return value === undefined ? fallback : value;
94
+ }
95
+
96
+ /** What gems_available and emet_status show. A checker failure stays the check id. */
97
+ export function refusalText(result) {
98
+ if (!result || !result.firstFailure) return 'GC-01';
99
+ return result.firstFailure;
100
+ }
101
+
102
+ function schemaList(schema) {
103
+ const props = schema && schema.properties;
104
+ if (props && typeof props === 'object') {
105
+ const keys = Object.keys(props);
106
+ if (keys.length) return keys.join(',');
107
+ }
108
+ return 'object';
109
+ }
110
+
111
+ function toCandidate(dirName, dirReal, result, used) {
112
+ const manifest = result && result.manifest;
113
+ const id = manifest && typeof manifest.id === 'string' && ID_RE.test(manifest.id)
114
+ ? manifest.id
115
+ : fallbackId(dirName, used);
116
+ used.add(id);
117
+ return {
118
+ id,
119
+ dirName,
120
+ dirReal,
121
+ manifest,
122
+ name: field(manifest, 'name', id).slice(0, 80),
123
+ description: field(manifest, 'description', '').slice(0, 400),
124
+ version: field(manifest, 'version', '0.0.0'),
125
+ kind: manifest && (manifest.kind === 'gem' || manifest.kind === 'runed-gem') ? manifest.kind : 'gem',
126
+ spec: field(manifest, 'spec', ''),
127
+ priority: field(manifest, 'priority', 0),
128
+ distribution: manifest && manifest.distribution === 'library' ? 'library' : 'bundled',
129
+ defaultOn: field(manifest, 'defaultOn', false),
130
+ gcOk: Boolean(result && result.ok),
131
+ gcReason: refusalText(result),
132
+ floor: result && result.floor,
133
+ floorBytes: result && result.floorTrusted ? result.floorBytes : 0,
134
+ floorTrusted: Boolean(result && result.floorTrusted),
135
+ floorDigest: result && result.floorDigest,
136
+ loadable: false,
137
+ active: false,
138
+ reason: refusalText(result)
139
+ };
140
+ }
141
+
142
+ function releaseCopy(candidate, releaseReal) {
143
+ if (!releaseReal || !candidate.manifest || typeof candidate.manifest.id !== 'string') return false;
144
+ const expected = path.join(releaseReal, candidate.manifest.id);
145
+ let expectedReal;
146
+ try { expectedReal = fs.realpathSync(expected); } catch { return false; }
147
+ return expectedReal === candidate.dirReal;
148
+ }
149
+
150
+ /**
151
+ * SR-01: a bundled GEM loads only from gems/<id>/ of this release.
152
+ * A library GEM loads only when its id and digests are on the accepted list
153
+ * shipped with the release. This release ships no accepted list.
154
+ * distribution: bundled in the file is not evidence.
155
+ */
156
+ function serverRefusal(candidate, ctx) {
157
+ const manifest = candidate.manifest;
158
+ const digests = manifest && Array.isArray(manifest.files) ? manifest.files.map((f) => f.digest) : [];
159
+ const inRelease = releaseCopy(candidate, ctx.releaseReal);
160
+ if (manifest.distribution === 'library') {
161
+ if (!onAcceptedList(ctx.accepted, manifest.id, digests)) return 'SR-01';
162
+ } else if (!inRelease) {
163
+ return 'SR-01';
164
+ }
165
+ if (!satisfies(ctx.emetVersion, manifest.requires && manifest.requires.emet)) return 'SR-06';
166
+ if (Array.isArray(manifest.overrides) && manifest.overrides.length > 0 && !hasConsent(ctx.consent, manifest.id, digests)) {
167
+ return 'SR-05';
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function satisfies(version, range) {
173
+ return satisfiesEmetRange(version, range);
174
+ }
175
+
176
+ /**
177
+ * A missing `released` field is allowed only for these ids, at this version,
178
+ * with every pinned file present at its digest. A shorter file list does not
179
+ * match. The pin is the id, the version, and the file digests. It does not
180
+ * cover every other manifest field, including defaultOn, priority, requires,
181
+ * description, name, authors, license, rune description, schemas, the guides
182
+ * list, distribution, coverage, and rune metadata other than the version.
183
+ * overrides still needs SR-05 consent. A rune execution of server and
184
+ * distribution library are refused by GC-02. gem.json is not hashed: anyone
185
+ * who can edit it can already add released: true, so hashing it would grant
186
+ * nothing extra. The list is fixed in source. Nothing is read to decide
187
+ * whether SR-08 runs, so a missing or corrupt manifest cannot turn the check off.
188
+ */
189
+ export const RELEASED_LEGACY = Object.freeze([
190
+ Object.freeze({
191
+ id: 'neurendipity',
192
+ version: '1.2.3',
193
+ digests: Object.freeze({
194
+ 'floor.json': 'sha256:484dd159ccfec5d4fd85a764aea6db687b9c2355e1b7a11532f044fc350eca01',
195
+ 'guides/NEURODIVERGENT_SUPPORT.md': 'sha256:aa64c1f125960da7822970a87926bd042fcb4f8ba6d84e392a29dfb12321c809',
196
+ 'guides/AUTISTIC_BURNOUT.md': 'sha256:657810094cdde539648c7d06767072e71b8c712aee73a60e7e8040ac83df40e2'
197
+ })
198
+ }),
199
+ Object.freeze({
200
+ id: 'fieldnote',
201
+ version: '1.0.1',
202
+ digests: Object.freeze({
203
+ 'floor.json': 'sha256:11af3724b165beb3e9a49a681f1f07cb742bcb4535206f714a0dc689067f32d6',
204
+ 'runes/fieldnote.js': 'sha256:cfb62b669f67c69f53989ed2621a40bfbfa60a744f1b4040e5b8496755598a35'
205
+ })
206
+ })
207
+ ]);
208
+
209
+ function legacyDigests(manifest, pin) {
210
+ const files = Array.isArray(manifest.files) ? manifest.files : [];
211
+ const keys = Object.keys(pin.digests);
212
+ if (files.length !== keys.length) return false;
213
+ for (const file of files) {
214
+ if (!file || pin.digests[file.path] !== file.digest) return false;
215
+ }
216
+ return true;
217
+ }
218
+
219
+ function legacyReleased(manifest) {
220
+ if (!manifest || typeof manifest !== 'object') return false;
221
+ const pin = RELEASED_LEGACY.find((row) => row.id === manifest.id);
222
+ if (!pin || manifest.version !== pin.version || !legacyDigests(manifest, pin)) return false; // legacy-pin
223
+ return true;
224
+ }
225
+
226
+ /** Null when the manifest may seat. Otherwise the log line that says why. */
227
+ export function releasedReason(manifest) {
228
+ if (manifest && manifest.released === true) return null;
229
+ const missing = !manifest || typeof manifest !== 'object' || !Object.prototype.hasOwnProperty.call(manifest, 'released');
230
+ if (missing) {
231
+ if (legacyReleased(manifest)) return null;
232
+ const base = 'released is missing; boolean true is required';
233
+ // Exact and case-sensitive. neurendipity-x, xneurendipity, and Fieldnote are not pins.
234
+ const id = manifest && typeof manifest.id === 'string' ? manifest.id : '';
235
+ const pinned = RELEASED_LEGACY.some((row) => row.id === id);
236
+ return pinned ? `${base} (legacy pin mismatch: version/digests)` : base;
237
+ }
238
+ const value = manifest.released;
239
+ if (typeof value === 'string') return `released must be boolean true, got string ${JSON.stringify(value)}`;
240
+ if (typeof value === 'number') return `released must be boolean true, got number ${value}`;
241
+ if (typeof value === 'boolean') return `released must be boolean true, got boolean ${value}`;
242
+ return `released must be boolean true, got ${value === null ? 'null' : typeof value}`;
243
+ }
244
+
245
+ /**
246
+ * GEM ids the install asked to turn on. EMET_ACTIVE_GEMS is a comma-separated
247
+ * list. Unset means only a GEM with defaultOn true is active. options.asked,
248
+ * when passed, replaces the setting for that call. This is not consent: a GEM
249
+ * with overrides still needs consent bound to its digests.
250
+ */
251
+ export function askedGemIds(options = {}) {
252
+ const raw = options.asked !== undefined ? options.asked : setting('ACTIVE_GEMS', '');
253
+ const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
254
+ const ids = new Set();
255
+ for (const item of list) {
256
+ const id = String(item).trim();
257
+ if (ID_RE.test(id)) ids.add(id);
258
+ }
259
+ return ids;
260
+ }
261
+
262
+ function publicAvailable(candidate) {
263
+ const row = {
264
+ id: candidate.id,
265
+ name: candidate.name,
266
+ description: candidate.description,
267
+ version: candidate.version,
268
+ kind: candidate.kind,
269
+ spec: candidate.spec,
270
+ priority: candidate.priority,
271
+ distribution: candidate.distribution,
272
+ defaultOn: candidate.defaultOn,
273
+ active: candidate.active === true,
274
+ loadable: candidate.loadable === true,
275
+ bytes: candidate.floorTrusted ? candidate.floorBytes : 0
276
+ };
277
+ if (!row.loadable) row.reason = candidate.reason;
278
+ if (row.active && candidate.manifest && Array.isArray(candidate.manifest.runes) && candidate.manifest.runes.length) {
279
+ row.runes = candidate.manifest.runes.map((rune) => ({
280
+ name: rune.name,
281
+ runtime: rune.runtime,
282
+ inputs: schemaList(rune.inputSchema),
283
+ outputs: schemaList(rune.outputSchema),
284
+ sha256: rune.sha256
285
+ }));
286
+ }
287
+ return row;
288
+ }
289
+
290
+ function publicActive(candidate) {
291
+ const floor = candidate.floor && candidate.floor.sections ? candidate.floor.sections : {};
292
+ const reassertion = candidate.floor && Array.isArray(candidate.floor.reassertion) ? candidate.floor.reassertion : [];
293
+ return {
294
+ id: candidate.manifest.id,
295
+ version: candidate.manifest.version,
296
+ priority: candidate.manifest.priority,
297
+ floor_digest: candidate.floorDigest,
298
+ floor,
299
+ reassertion
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Discover and validate GEMs.
305
+ * root defaults to the release gems/ directory. Tests may pass another root
306
+ * only together with the same releaseRoot when they are exercising a bundled copy.
307
+ * There is no environment variable for this path.
308
+ * options.db is ignored if a caller passes one. The socket never reads it.
309
+ */
310
+ export async function seatGems(options = {}) {
311
+ const releaseRoot = options.releaseRoot ? path.resolve(options.releaseRoot) : releaseGemsRoot();
312
+ const root = options.root ? path.resolve(options.root) : releaseRoot;
313
+ const emetVersion = options.emetVersion || EMET_VERSION;
314
+ const consent = Array.isArray(options.consent) ? options.consent : [];
315
+ const accepted = Array.isArray(options.accepted) ? options.accepted : [];
316
+ const check = typeof options.check === 'function' ? options.check : checkGem;
317
+ const log = options.log || null;
318
+ const asked = askedGemIds(options);
319
+
320
+ let releaseReal = null;
321
+ try {
322
+ const st = await fs.promises.stat(releaseRoot);
323
+ if (st.isDirectory()) releaseReal = await fs.promises.realpath(releaseRoot);
324
+ } catch { releaseReal = null; }
325
+
326
+ let entries;
327
+ try {
328
+ const st = await fs.promises.lstat(root);
329
+ if (!st.isDirectory() && !st.isSymbolicLink()) {
330
+ return { gems_available: [], gems_active: [], refusals: [] };
331
+ }
332
+ entries = await fs.promises.readdir(root, { withFileTypes: true });
333
+ } catch (e) {
334
+ if (e && e.code === 'ENOENT') return { gems_available: [], gems_active: [], refusals: [] };
335
+ emit(log, 'error', 'GEM socket failed closed');
336
+ return { gems_available: [], gems_active: [], refusals: [] };
337
+ }
338
+
339
+ let rootReal;
340
+ try { rootReal = await fs.promises.realpath(root); }
341
+ catch {
342
+ emit(log, 'error', 'GEM socket failed closed');
343
+ return { gems_available: [], gems_active: [], refusals: [] };
344
+ }
345
+
346
+ const used = new Set();
347
+ const candidates = [];
348
+ for (const ent of entries) {
349
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
350
+ const abs = path.join(root, ent.name);
351
+ try {
352
+ const st = await fs.promises.stat(abs);
353
+ if (!st.isDirectory()) {
354
+ if (!ent.isSymbolicLink()) continue;
355
+ const row = toCandidate(ent.name, abs, { ok: false, firstFailure: 'GC-01', manifest: null }, used);
356
+ row.gcOk = false;
357
+ row.reason = 'GC-01';
358
+ candidates.push(row);
359
+ continue;
360
+ }
361
+ const dirReal = await fs.promises.realpath(abs);
362
+ if (!inside(rootReal, dirReal)) {
363
+ const row = toCandidate(ent.name, dirReal, { ok: false, firstFailure: 'GC-04', manifest: null }, used);
364
+ row.gcOk = false;
365
+ row.reason = 'GC-04';
366
+ candidates.push(row);
367
+ continue;
368
+ }
369
+ // GC-20 checks grammar. The running version is SR-06, so it is not passed here.
370
+ const result = await check(abs, { trustStore: options.trustStore, schema: options.schema });
371
+ candidates.push(toCandidate(ent.name, dirReal, result, used));
372
+ } catch {
373
+ const row = toCandidate(ent.name, abs, { ok: false, firstFailure: 'GC-01', manifest: null }, used);
374
+ row.gcOk = false;
375
+ row.reason = 'GC-01';
376
+ candidates.push(row);
377
+ }
378
+ }
379
+
380
+ candidates.sort(cmpId);
381
+ const ctx = { releaseReal, emetVersion, consent, accepted };
382
+
383
+ candidates.forEach((candidate, index) => {
384
+ if (index >= MAX_INSTALLED) {
385
+ candidate.loadable = false;
386
+ candidate.active = false;
387
+ candidate.reason = 'SR-03';
388
+ return;
389
+ }
390
+ if (!candidate.gcOk) {
391
+ candidate.loadable = false;
392
+ candidate.active = false;
393
+ candidate.reason = candidate.gcReason || 'GC-01';
394
+ return;
395
+ }
396
+ const why = releasedReason(candidate.manifest); // sr-08-always
397
+ if (why) {
398
+ candidate.loadable = false;
399
+ candidate.active = false;
400
+ candidate.reason = 'SR-08';
401
+ candidate.releasedWhy = why;
402
+ return;
403
+ }
404
+ const sr = serverRefusal(candidate, ctx);
405
+ if (sr) {
406
+ candidate.loadable = false;
407
+ candidate.active = false;
408
+ candidate.reason = sr;
409
+ return;
410
+ }
411
+ candidate.loadable = true;
412
+ candidate.reason = null;
413
+ candidate.wantsActive = candidate.defaultOn === true || asked.has(candidate.id);
414
+ });
415
+
416
+ let activeCount = 0;
417
+ let activeBytes = 0;
418
+ for (const candidate of candidates) {
419
+ if (!candidate.loadable || !candidate.wantsActive) {
420
+ if (candidate.loadable) candidate.active = false;
421
+ continue;
422
+ }
423
+ const size = candidate.floorTrusted ? candidate.floorBytes : 0;
424
+ if (activeCount + 1 > MAX_ACTIVE) {
425
+ candidate.loadable = false;
426
+ candidate.active = false;
427
+ candidate.reason = 'SR-02';
428
+ continue;
429
+ }
430
+ if (activeBytes + size > MAX_ACTIVE_FLOOR_BYTES) {
431
+ candidate.loadable = false;
432
+ candidate.active = false;
433
+ candidate.reason = 'SR-04';
434
+ continue;
435
+ }
436
+ candidate.active = true;
437
+ activeCount += 1;
438
+ activeBytes += size;
439
+ }
440
+
441
+ const gems_available = candidates.map(publicAvailable);
442
+ const refusals = gems_available.filter((row) => row.loadable === false);
443
+ const gems_active = candidates.filter((c) => c.active).map(publicActive)
444
+ .sort((a, b) => (b.priority - a.priority) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
445
+ for (const candidate of candidates) {
446
+ if (candidate.loadable) continue;
447
+ const why = candidate.reason === 'SR-08' ? candidate.releasedWhy : null;
448
+ const message = why
449
+ ? `GEM refused: ${candidate.id} ${candidate.reason}: ${why}`
450
+ : `GEM refused: ${candidate.id} ${candidate.reason}`;
451
+ emit(log, 'warn', message, { id: candidate.id, reason: candidate.reason, ...(why ? { detail: why } : {}) });
452
+ }
453
+ // Empty mount stays silent. Each seated GEM is named, then the summary.
454
+ // A refused GEM is not a seat line.
455
+ if (gems_available.length > 0) {
456
+ for (const row of gems_available) {
457
+ if (!row.loadable) continue;
458
+ emit(log, 'info', `GEM seated: ${row.id}`, { id: row.id });
459
+ }
460
+ emit(log, 'info', `GEMs seated: ${gems_available.length} installed, ${gems_active.length} active`, {
461
+ installed: gems_available.length,
462
+ active: gems_active.length
463
+ });
464
+ }
465
+ const production = options.root === undefined && options.releaseRoot === undefined;
466
+ if (production || options.bindRunes === true) {
467
+ const bound = [];
468
+ for (const candidate of candidates) {
469
+ if (!candidate.active || !candidate.manifest || !Array.isArray(candidate.manifest.runes)) continue;
470
+ for (const rune of candidate.manifest.runes) {
471
+ noteArtifact({ sha256: rune.sha256, gem: candidate.id, rune: rune.name, version: rune.version });
472
+ bound.push({
473
+ name: rune.name,
474
+ description: rune.description,
475
+ inputSchema: rune.inputSchema,
476
+ outputSchema: rune.outputSchema,
477
+ runtime: rune.runtime,
478
+ sha256: rune.sha256,
479
+ artifact: rune.artifact,
480
+ version: rune.version,
481
+ annotations: rune.annotations,
482
+ reads: rune.reads,
483
+ writes: rune.writes,
484
+ network: rune.network,
485
+ gemId: candidate.id,
486
+ gemVersion: candidate.version,
487
+ gemDir: candidate.dirReal,
488
+ tool: runeToolName(candidate.id, rune.name)
489
+ });
490
+ }
491
+ }
492
+ bindRunes(bound);
493
+ }
494
+ const result = { gems_available, gems_active, refusals };
495
+ if (options.floors) {
496
+ const floors = Object.create(null);
497
+ for (const candidate of candidates) {
498
+ if (!candidate.loadable || !candidate.floor) continue;
499
+ floors[candidate.id] = {
500
+ sections: candidate.floor.sections || {},
501
+ reassertion: Array.isArray(candidate.floor.reassertion) ? candidate.floor.reassertion : []
502
+ };
503
+ }
504
+ result.floors = floors;
505
+ }
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * Socket PR 4 (docs/ROADMAP.md): each GEM's byte cost, for emet_status.
511
+ * Read-only: built from a seatGems() result, never from the store. The caps
512
+ * are the ones seatGems already enforces; this only makes them visible, so an
513
+ * install can see what a GEM costs its startup before it asks for one.
514
+ * Bytes are the trusted floor bytes the socket counts toward
515
+ * MAX_ACTIVE_FLOOR_BYTES (0 for a GEM whose floor was not trusted).
516
+ */
517
+ export function gemCost(seated) {
518
+ const available = seated && Array.isArray(seated.gems_available) ? seated.gems_available : [];
519
+ const perGem = available.map((row) => {
520
+ const out = {
521
+ id: row.id,
522
+ version: row.version,
523
+ kind: row.kind,
524
+ active: row.active === true,
525
+ loadable: row.loadable === true,
526
+ bytes: Number.isFinite(row.bytes) ? row.bytes : 0
527
+ };
528
+ if (!out.loadable && row.reason) out.reason = row.reason;
529
+ return out;
530
+ });
531
+ const active = perGem.filter((g) => g.active);
532
+ const activeBytes = active.reduce((sum, g) => sum + g.bytes, 0);
533
+ return {
534
+ installed: perGem.length,
535
+ active: active.length,
536
+ active_floor_bytes: activeBytes,
537
+ headroom_bytes: Math.max(0, MAX_ACTIVE_FLOOR_BYTES - activeBytes),
538
+ caps: {
539
+ max_installed: MAX_INSTALLED,
540
+ max_active: MAX_ACTIVE,
541
+ max_active_floor_bytes: MAX_ACTIVE_FLOOR_BYTES
542
+ },
543
+ per_gem: perGem
544
+ };
545
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Paged startup (the host-aware startup design, paging; approved by the owner
3
- * 2026-10-03). The connector gateway cuts a tool result at 20,000 UTF-8
2
+ * Paged startup (ref 358 amendment, paging; Alex approved 2026-10-03, design
3
+ * paper question 1). The connector gateway cuts a tool result at 20,000 UTF-8
4
4
  * bytes, and the full startup is about 64K, so on those hosts the floor,
5
5
  * install notes, source tags, seeds_due, record gaps and lessons never
6
6
  * arrived. Paging delivers the SAME full payload in pages, binding text first.
@@ -41,9 +41,9 @@ export function pageBudget(raw = process.env.EMET_INIT_PAGE_BYTES) {
41
41
 
42
42
  /** Binding first: the order keys are paged in. Keys not listed follow, in payload order. */
43
43
  export const PAGE_ORDER = Object.freeze([
44
- 'state', 'database', 'source_tag', 'person_id', 'unhandled_drops', 'unhandled_drops_more', 'retired_drops', 'emet_line', 'emet_line_if_unanswered',
44
+ 'state', 'database', 'source_tag', 'source_basis', 'source_login', 'source_remembered', 'approval_state', 'person_id', 'client_approvals', 'unhandled_drops', 'unhandled_drops_more', 'retired_drops', 'emet_line', 'emet_line_if_unanswered',
45
45
  'tools_in_force', 'instructions_short', 'session_close_protocol', 'operating_discipline', 'charter',
46
- 'floor', 'floor_precedence', 'floor_reassertion',
46
+ 'floor', 'floor_precedence', 'floor_reassertion', 'gems_available', 'gems_active',
47
47
  'seeds_due', 'seeds_evaluated_on', 'install_notes_doc_id',
48
48
  'identity', 'character', 'character_doc_id', 'character_sha256', 'user',
49
49
  'record_gaps', 'named_items', 'lessons_pending', 'layer_health',
@@ -51,10 +51,11 @@ export const PAGE_ORDER = Object.freeze([
51
51
  'seeds', 'threads', 'recent_context', 'layer_counts', 'layer_counts_last_30d'
52
52
  ]);
53
53
 
54
- /** Keys that bind the session (the startup rules): never optional, never dropped. */
54
+ /** Keys that bind the session (ref 358): never optional, never dropped. */
55
55
  export const BINDING_KEYS = Object.freeze([
56
56
  'tools_in_force', 'instructions_short', 'session_close_protocol',
57
- 'floor', 'floor_precedence', 'floor_reassertion', 'identity', 'character', 'user'
57
+ 'floor', 'floor_precedence', 'floor_reassertion', 'gems_available', 'gems_active',
58
+ 'identity', 'character', 'user'
58
59
  ]);
59
60
 
60
61
  /** Page fields that are never payload keys. */
@@ -71,6 +72,16 @@ function orderedKeys(full) {
71
72
  const bytes = (s) => Buffer.byteLength(s, 'utf8');
72
73
  const entryBytes = (key, value) => bytes(JSON.stringify({ [key]: value })) - 1; // `"key":value` plus a comma
73
74
 
75
+ /**
76
+ * GEM lists travel with the floor. They are never split into chunks and never
77
+ * replaced by a pointer (startup-payload.js keeps them too). A list that does
78
+ * not fit the bytes left on the current page moves whole onto the next page.
79
+ * A list that cannot fit even an empty page is still one entry: this packer
80
+ * does not slice it. The caps in the socket keep a real list small enough to fit.
81
+ */
82
+ const WHOLE_KEYS = new Set(['gems_available', 'gems_active']);
83
+ export { WHOLE_KEYS };
84
+
74
85
  /**
75
86
  * Pack the payload into pages of entries. budgets(k) = content bytes allowed
76
87
  * on page k (0-based). An entry is {key, value} or {key, value, chunk, of, json}.
@@ -82,6 +93,12 @@ function pack(full, budgets) {
82
93
  for (const key of orderedKeys(full)) {
83
94
  const value = full[key];
84
95
  const size = entryBytes(key, value);
96
+ if (WHOLE_KEYS.has(key)) {
97
+ if (size > room() && pages[pages.length - 1].length) newPage();
98
+ pages[pages.length - 1].push({ key, value });
99
+ used[pages.length - 1] += size;
100
+ continue;
101
+ }
85
102
  if (size <= room()) { pages[pages.length - 1].push({ key, value }); used[pages.length - 1] += size; continue; }
86
103
  if (size <= budgets(pages.length)) { newPage(); pages[pages.length - 1].push({ key, value }); used[pages.length - 1] += size; continue; }
87
104
  // Too big for any one page: chunk it. Strings split as text; anything else
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The startup payload's size and its abridged form (the host-aware startup
3
- * design, conservative variant chosen by the owner 2026-10-02).
2
+ * The startup payload's size and its abridged form (ref 358 amendment, the
3
+ * host-aware startup; design: research paper emet-startup-amendment rev 3,
4
+ * sections 4a-4c, conservative variant chosen by Alex 2026-10-02, procedural
5
+ * ref 405 item 8 and ref 407).
4
6
  *
5
7
  * WHAT THIS FILE DOES AND DOES NOT DO (2026-10-03):
6
8
  * - payloadForLayerCharter: the layer charter as the payload serves it, with
@@ -13,13 +15,13 @@
13
15
  * selects it: the amendment says the server decides the mode and the model
14
16
  * does not, and a host can never ask for less than the server allows. The
15
17
  * server can only prove a returning host with the startup receipts of
16
- * stage 2 of the design, which do not exist yet, so emet_initialize serves
18
+ * stage 2 (review note 3b, 5), which do not exist yet, so emet_initialize serves
17
19
  * the full payload to every caller. This function is the tested builder
18
20
  * stage 2's mode check will call, and the measure of what abridging saves.
19
21
  * - resultSize: the size of a payload as the host receives it, in characters
20
22
  * and UTF-8 bytes (the connector gateway cuts at 20,000 bytes).
21
23
  *
22
- * FIDELITY RULE (the owner's startup rules): the floor, identity, character, user, the close
24
+ * FIDELITY RULE (Alex, ref 358): the floor, identity, character, user, the close
23
25
  * protocol and tools in force are never pointers and never shortened here.
24
26
  * Only install notes, the layer charter and the long instructions become
25
27
  * hash-pinned pointers in the abridged form, and the seed and thread indexes
@@ -54,7 +56,7 @@ export function keySizes(payload) {
54
56
  .sort((a, b) => b.chars - a.chars);
55
57
  }
56
58
 
57
- /** The keys every abridged load embeds exactly as the full load has them (the design's keep set; never dropped). */
59
+ /** The keys every abridged load embeds exactly as the full load has them (review note 4b; never dropped). */
58
60
  export const ABRIDGED_KEEP = Object.freeze([
59
61
  'state', 'database', 'source_tag', 'person_id', 'unhandled_drops', 'unhandled_drops_more', 'retired_drops',
60
62
  'emet_line', 'emet_line_if_unanswered',
@@ -63,7 +65,8 @@ export const ABRIDGED_KEEP = Object.freeze([
63
65
  'install_notes_doc_id',
64
66
  'seeds_due', 'seeds_evaluated_on',
65
67
  'record_gaps', 'named_items', 'lessons_pending',
66
- 'floor', 'floor_precedence', 'floor_reassertion'
68
+ 'floor', 'floor_precedence', 'floor_reassertion',
69
+ 'gems_available', 'gems_active'
67
70
  ]);
68
71
 
69
72
  /** Keys that become hash-pinned pointers: text the host already echoed at a full load, unchanged. */