@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/setup.js CHANGED
@@ -21,11 +21,14 @@ import fs from 'fs';
21
21
  import path from 'path';
22
22
  import crypto from 'crypto';
23
23
  import { resolveModulePath } from './module-path.js';
24
- import { setting } from './env.js';
24
+ import { setting, parseEnvAssignments, platformEnv, realEnvIsConfigSource } from './env.js';
25
+ import { approvedTags, cachedApprovals, approvalsView, rememberedView } from './attribution.js';
26
+ import { declaredAttribution, rawActor } from './persons.js';
25
27
  import { MONGODB_URI, MONGODB_DB, COLLECTION_PREFIX, DatabaseError, EmetDatabase } from './database.js';
26
28
  import { VALID_LAYERS, ValidationError } from './validation.js';
27
29
  import { DISCIPLINE_SUMMARY } from './disciplines.js';
28
30
  import { FLOOR, FLOOR_REASSERTION, PRECEDENCE as FLOOR_PRECEDENCE } from './floor.js';
31
+ import { seatGems } from './socket.js';
29
32
  import { LAYER_SUMMARY, assessLayerHealth } from './layers.js';
30
33
  import { CLOSE_PROTOCOL, handoffDoc, stateDoc } from './session.js';
31
34
  import { currentActor } from './persons.js';
@@ -39,6 +42,7 @@ function callerHoldsWriteScope() {
39
42
  return scopes.includes('emet.write') || scopes.includes('emet') || scopes.includes('*');
40
43
  }
41
44
  import { gapCounts } from './gaps.js';
45
+ import { registerSecretsFromEnv } from './redact.js';
42
46
  import { payloadForLayerCharter } from './startup-payload.js';
43
47
  import { namedItemsFrom } from './named-items.js';
44
48
  import { paginate, cacheLoad, cachedLoad, loadIdOf, restartPage, notePageServed, wholeStartup } from './startup-pages.js';
@@ -46,6 +50,7 @@ import { paginate, cacheLoad, cachedLoad, loadIdOf, restartPage, notePageServed,
46
50
  const DOCS_COLLECTION = 'documents';
47
51
  const CHARTER_DOC = 'CHARTER.md';
48
52
  const CHARTER_FILE = resolveModulePath(import.meta.url, ['..', 'CHARTER.md']);
53
+ export const ENV_FILE = resolveModulePath(import.meta.url, ['..', '.env']);
49
54
  const IDENTITY_DOC = 'bootstrap/IDENTITY.md';
50
55
  const USER_DOC = 'bootstrap/USER.md';
51
56
 
@@ -166,7 +171,7 @@ async function resolveCharacterDoc(docs, override) {
166
171
  * names in both places. Rule stated once, in the architecture charter; this is
167
172
  * the code half of it.
168
173
  *
169
- * PARKED ITEMS ARE NAMED TOO (2026-09-23, a decision record). This list used to
174
+ * PARKED ITEMS ARE NAMED TOO (2026-09-23, procedural ref 355). This list used to
170
175
  * end with "leave out items the user has parked" - which is the same failure
171
176
  * one step removed: for a neurodivergent user, what they deliberately set aside
172
177
  * drops out of mind exactly as fast as what they forgot. The user, verbatim:
@@ -176,7 +181,7 @@ async function resolveCharacterDoc(docs, override) {
176
181
  * items are named in a section of their own, with what brings each one back, and
177
182
  * nothing is asked of them. Visible is not owed.
178
183
  */
179
- export function startupListInstructions() {
184
+ export function startupListInstructions(available) {
180
185
  return (
181
186
  'Then, before any question, read `' + handoffDoc() + '` and `' + stateDoc() + '` ' +
182
187
  '(read_doc) if this store has them, and name the user\'s items for them: ' +
@@ -194,6 +199,7 @@ export function startupListInstructions() {
194
199
  'times it has landed; ask nothing of them. Each is settled by a new meta entry carrying the ' +
195
200
  'family tags and metadata.lesson_disposition {kind: check|document|uncheckable, ref}. ' +
196
201
  seedsAndThreadsInstructions() + ' ' +
202
+ gemStartupLine(available) + ' ' +
197
203
  'Never open with a question alone. If this store has neither document, say so once ' +
198
204
  'rather than inventing a list.'
199
205
  );
@@ -239,7 +245,16 @@ function retirementFields(row) {
239
245
 
240
246
  /** The registered source tags (EMET_SOURCE_TAGS), as a list. */
241
247
  export function registeredSourceTags() {
242
- return String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
248
+ const env = String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
249
+ return env.length ? [...new Set([...env, ...approvedTags()])] : env;
250
+ }
251
+
252
+ /** Attribution v2: the caller's real tag, not the server-wide default (Alex 2026-10-12 (c)). */
253
+ export function callerAttribution() {
254
+ const d = declaredAttribution();
255
+ if (!d) return { source_tag: setting('SOURCE_TAG') || null };
256
+ const tag = d.effective || d.login || null;
257
+ return { source_tag: tag, source_basis: d.basis || (tag ? 'login' : 'none'), ...(d.login && d.login !== tag ? { source_login: d.login } : {}), approval_state: d.approval || (d.basis === 'login' ? 'login' : 'unknown'), ...((() => { const v = rememberedView({ rememberedTags: d.rememberedTags, declaredTag: d.declared || d.effective }); return v ? { source_remembered: v } : {}; })()) };
243
258
  }
244
259
 
245
260
  export function hostSetupInstructions() {
@@ -310,7 +325,7 @@ export function summarizeSeed(doc_id, text, today = new Date().toISOString().sli
310
325
  }
311
326
 
312
327
  /**
313
- * One line per seed for the initialize payload (the owner, 2026-09-27, code audit
328
+ * One line per seed for the initialize payload (Alex, 2026-09-27, code audit
314
329
  * 2.3: "seeds can be one line since follow-up inquiry by the user will expand
315
330
  * it"). Before this every trigger's full test text was served - 13K characters
316
331
  * for 21 seeds with none due. The line keeps what a startup scan needs: the
@@ -363,7 +378,28 @@ export const EMET_LINE_UNANSWERED = 'MET — the aleph is missing. Memory not co
363
378
  * the rest. This is the subset that must survive a cut; the long form still
364
379
  * follows at the end and governs wording where the two differ.
365
380
  */
366
- export function shortInstructions() {
381
+ /** The sentence an empty GEM list prints. Tests strip this exact text, trailing space included. */
382
+ export const GEMS_NONE_LINE = 'GEMs installed: none.';
383
+
384
+ /**
385
+ * One line per installed GEM: id, name, and on or off. A refused GEM adds the
386
+ * reason code and nothing else. An empty list still names itself.
387
+ */
388
+ export function gemStartupLine(available) {
389
+ const list = Array.isArray(available) ? available : [];
390
+ if (list.length === 0) return GEMS_NONE_LINE;
391
+ return list.map((gem) => {
392
+ const id = gem && typeof gem.id === 'string' ? gem.id : 'unknown';
393
+ const name = gem && typeof gem.name === 'string' && gem.name ? gem.name : id;
394
+ const flag = gem && gem.active === true ? 'on' : 'off';
395
+ const why = gem && gem.loadable === false && typeof gem.reason === 'string' ? ` ${gem.reason}` : '';
396
+ const runes = gem && gem.active === true && Array.isArray(gem.runes) ? gem.runes : [];
397
+ const served = runes.map((rune) => ` ${rune.name} runtime ${rune.runtime} inputs ${rune.inputs} outputs ${rune.outputs} sha256 ${rune.sha256}`).join('');
398
+ return `${id}, ${name}, ${flag}${why}${served}`;
399
+ }).join('\n');
400
+ }
401
+
402
+ export function shortInstructions(available) {
367
403
  return (
368
404
  'Read in this order if the payload is cut short. (1) Say `emet_line` exactly, alone; then ' +
369
405
  'the identity phrase from `identity` exactly, if one is set. (2) `identity`, `character`, ' +
@@ -372,7 +408,7 @@ export function shortInstructions() {
372
408
  'host\'s source tag. (4) After every startup page, call emet_session_open with its load_id, then say every name in ' +
373
409
  '`named_items` (half-state, dated within seven days, open, undecided, then parked under its own heading, never pressed). ' +
374
410
  'Read HANDOFF.md and STATE.md when a section is marked unread. Then `lessons_pending`. A count is the check, never the list. ' +
375
- 'Never open with a question alone. (5) Append every exchange with ' +
411
+ gemStartupLine(available) + ' Never open with a question alone. (5) Append every exchange with ' +
376
412
  'emet_transcript_append; pass session_id and exchange on every write; follow ' +
377
413
  '`session_graph.next`. (6) Close per ' +
378
414
  '`session_close_protocol`; handoff last; saved only ' +
@@ -407,7 +443,7 @@ export function startupIndicatorInstructions() {
407
443
  * stand alone, because on its own it reads as "say nothing" and the user's
408
444
  * items drop out of sight with it.
409
445
  */
410
- export function readyInstructions(hasCharacter) {
446
+ export function readyInstructions(hasCharacter, available) {
411
447
  const core =
412
448
  'Before answering anything that may have prior context, search memory first ' +
413
449
  'rather than assuming. `layer_charter` says what belongs in each of the six layers - use ' +
@@ -439,7 +475,7 @@ export function readyInstructions(hasCharacter) {
439
475
  'where things stood; do not recite this payload back, list the layer counts, or announce ' +
440
476
  'that you loaded memory; if nothing in recent_context bears on what they ask first, leave ' +
441
477
  'recent_context out of the greeting. ' + hostSetupInstructions() + ' ' +
442
- startupListInstructions() + ' ' + core
478
+ startupListInstructions(available) + ' ' + core
443
479
  );
444
480
  }
445
481
 
@@ -454,7 +490,7 @@ export function readyInstructions(hasCharacter) {
454
490
  'Open with a short greeting that shows you know where things stood - do not recite ' +
455
491
  'this payload back, do not list the layer counts, and do not announce that you loaded ' +
456
492
  'memory. If nothing in recent_context is relevant to what they ask next, say nothing ' +
457
- 'about it. ' + hostSetupInstructions() + ' ' + startupListInstructions() + ' ' + core
493
+ 'about it. ' + hostSetupInstructions() + ' ' + startupListInstructions(available) + ' ' + core
458
494
  );
459
495
  }
460
496
 
@@ -492,9 +528,155 @@ export const SETUP_INTERVIEW = Object.freeze([
492
528
  why: 'Recorded as standing preferences rather than relearned each session.' },
493
529
  { key: 'source_tag', required: false,
494
530
  ask: 'What should writes from THIS host be tagged as? (e.g. laptop, phone, cli)',
495
- why: 'Every write records its origin. A shared default tag makes provenance meaningless once a second host connects.' }
531
+ why: 'Every write records its origin. A shared default tag makes provenance meaningless once a second host connects.' },
532
+ { key: 'more_than_one_bot', required: false,
533
+ ask: 'Will more than one bot or AI use this memory? Answer yes or no.',
534
+ why: 'Yes turns on delegated client sub-keys when this server reads .env. No, or leaving it out, leaves them off. A hosted server does not write a pepper; it tells you the three settings to put in the platform\'s variables. Each bot\'s tag must be listed in EMET_SOURCE_TAGS. See docs/SUBKEYS.md.' },
535
+ { key: 'bot_team_backup', required: false,
536
+ ask: 'Turn on bot-team backup? It is off unless you say yes. Yes is the recommended choice. Answer yes or no.',
537
+ why: 'Yes records that choice and means the assistant follows the bot-team backup recipe: each bot\'s name, prompt, settings, routines and skills are written as controlled documents, revised only when they change. No, or leaving it out, leaves the practice off. EMET does not copy those documents itself, and secret values are never stored. See gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md.' }
496
538
  ]);
497
539
 
540
+ /**
541
+ * yes or no from the interview. Absent means no. Anything else is not an
542
+ * answer, and setup does not invent one.
543
+ */
544
+ export function parseMoreThanOneBot(value) {
545
+ if (value == null || String(value).trim() === '') return false;
546
+ const s = String(value).trim().toLowerCase().replace(/[.!]+$/, '');
547
+ if (s === 'yes' || s === 'y' || s === 'true') return true;
548
+ if (s === 'no' || s === 'n' || s === 'false') return false;
549
+ return null;
550
+ }
551
+
552
+ /**
553
+ * yes or no from the interview, in the user's words, the same way as
554
+ * more_than_one_bot. Absent means no. A non-string is not an answer.
555
+ * "Yes" and "Yes." are yes. Anything else is refused before a write.
556
+ */
557
+ export function parseBotTeamBackup(value) {
558
+ if (value == null) return false;
559
+ if (typeof value !== 'string') return null;
560
+ const s = value.trim().toLowerCase().replace(/[.!]+$/, '');
561
+ if (s === '') return false;
562
+ if (s === 'yes' || s === 'y' || s === 'true') return true;
563
+ if (s === 'no' || s === 'n' || s === 'false') return false;
564
+ return null;
565
+ }
566
+
567
+ /** Steps returned when yes is refused because real env vars are the config source. Thrown before any store write. */
568
+ export const HOSTED_SUBKEY_STEPS =
569
+ 'This server takes its settings from the platform, not from a .env file, so setup will not write a pepper here. No pepper or .env was written. ' +
570
+ 'Run setup again without yes to finish the rest. ' +
571
+ 'The platform-variable steps are in docs/SUBKEYS.md: set EMET_SUBKEYS=1, EMET_SUBKEY_PEPPER (generate it yourself, at least 32 bytes, for example with openssl rand -hex 32), and EMET_SUBKEY_PEPPER_VERSION=1, then redeploy. ' +
572
+ 'Do not paste the pepper into the chat or into any EMET document.';
573
+
574
+ /** .env is writable only when this process actually loaded that file, and no platform is injecting the environment. */
575
+ export function dotenvWritable(envFile = ENV_FILE, env = process.env, startup) {
576
+ if (platformEnv(env)) return false;
577
+ if (path.resolve(envFile) !== path.resolve(ENV_FILE)) return true;
578
+ return !realEnvIsConfigSource(env, startup);
579
+ }
580
+
581
+ function pepperStrong(value) {
582
+ return typeof value === 'string' && Buffer.byteLength(value) >= 32;
583
+ }
584
+
585
+ /** Replace active KEY= lines. Commented lines stay. Missing keys are appended. */
586
+ export function upsertEnvAssignments(text, assignments) {
587
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
588
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
589
+ const seen = new Set();
590
+ const out = lines.map((line) => {
591
+ const trimmed = line.trim();
592
+ if (!trimmed || trimmed.startsWith('#')) return line;
593
+ const eq = trimmed.indexOf('=');
594
+ if (eq < 1) return line;
595
+ const key = trimmed.slice(0, eq).trim();
596
+ if (!Object.prototype.hasOwnProperty.call(assignments, key)) return line;
597
+ seen.add(key);
598
+ const lead = (line.match(/^\s*/) || [''])[0];
599
+ return `${lead}${key}=${assignments[key]}`;
600
+ });
601
+ const missing = Object.keys(assignments).filter((key) => !seen.has(key));
602
+ if (missing.length) {
603
+ if (out.length && out[out.length - 1] !== '') out.push('');
604
+ out.push('# Written by first-use setup. The pepper is a server secret. Do not commit this file.');
605
+ for (const key of missing) out.push(`${key}=${assignments[key]}`);
606
+ }
607
+ return `${out.join('\n')}\n`;
608
+ }
609
+
610
+ /**
611
+ * Turn the existing sub-key flag on. A single client does not call this.
612
+ *
613
+ * Default-on was rejected. A single client has one login and one tag, so the
614
+ * flag does not change what it can write. The sub-key tools fail closed
615
+ * without a pepper of at least 32 bytes. A phone or a web project cannot
616
+ * store a key file and keeps the tag on its own connection. The tools, the
617
+ * owner page and the store already ship. This only writes EMET_SUBKEYS=1, a
618
+ * pepper, and EMET_SUBKEY_PEPPER_VERSION=1. The pepper is not returned. An
619
+ * existing strong pepper is kept, not rotated. This does not set process.env:
620
+ * the flag takes effect on restart, with the tools and /owner/approvals together.
621
+ * A hosted process, where real env vars are the config source, is refused
622
+ * instead of writing a pepper into the container.
623
+ */
624
+ export function enableSubkeysAtFirstUse({ envFile = ENV_FILE, env = process.env, startup } = {}) {
625
+ if (!dotenvWritable(envFile, env, startup)) {
626
+ throw new ValidationError('more_than_one_bot', HOSTED_SUBKEY_STEPS);
627
+ }
628
+ let raw = '';
629
+ try {
630
+ raw = fs.readFileSync(envFile, 'utf8');
631
+ } catch (e) {
632
+ if (!e || e.code !== 'ENOENT') {
633
+ throw new ValidationError('more_than_one_bot', 'Could not read .env. Sub-keys were not turned on.');
634
+ }
635
+ }
636
+ const fromFile = parseEnvAssignments(raw);
637
+ const current = pepperStrong(env.EMET_SUBKEY_PEPPER) ? env.EMET_SUBKEY_PEPPER
638
+ : (pepperStrong(fromFile.EMET_SUBKEY_PEPPER) ? fromFile.EMET_SUBKEY_PEPPER : null);
639
+ const pepper = current || crypto.randomBytes(32).toString('hex');
640
+ const versionNum = Number(env.EMET_SUBKEY_PEPPER_VERSION || fromFile.EMET_SUBKEY_PEPPER_VERSION);
641
+ const version = Number.isInteger(versionNum) && versionNum > 0 ? String(versionNum) : '1';
642
+ const next = upsertEnvAssignments(raw, {
643
+ EMET_SUBKEYS: '1',
644
+ EMET_SUBKEY_PEPPER: pepper,
645
+ EMET_SUBKEY_PEPPER_VERSION: version
646
+ });
647
+ const tmp = path.join(path.dirname(envFile), `.env.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
648
+ try {
649
+ fs.writeFileSync(tmp, next, { mode: 0o600 });
650
+ fs.renameSync(tmp, envFile);
651
+ } catch (e) {
652
+ try { fs.unlinkSync(tmp); } catch { /* the temp file may not exist */ }
653
+ throw new ValidationError('more_than_one_bot', 'Could not write .env. Sub-keys were not turned on.');
654
+ }
655
+ // The flag stays off in this process. Tools and /owner/approvals appear together on restart.
656
+ // Register the pepper so a later write that contains it is masked.
657
+ registerSecretsFromEnv({ EMET_SUBKEY_PEPPER: pepper });
658
+ return {
659
+ enabled: true,
660
+ turned_on: true,
661
+ pepper_generated: current === null,
662
+ pepper_version: Number(version),
663
+ restart_for_owner_page: true
664
+ };
665
+ }
666
+
667
+ /** Two sentences and a pointer. The steps live in docs/SUBKEYS.md. The pepper is never in this note. */
668
+ export function subkeySetupNote(multi, subkeys) {
669
+ if (multi && subkeys && subkeys.env_error) {
670
+ if (subkeys.env_error === HOSTED_SUBKEY_STEPS) return HOSTED_SUBKEY_STEPS;
671
+ return 'The store is set up. Sub-keys were not turned on because .env could not be written. See docs/SUBKEYS.md.';
672
+ }
673
+ if (multi && subkeys && subkeys.enabled) {
674
+ return 'Sub-keys were written to .env. The pepper is not in this result. Restart (or redeploy) the server once so the tools and /owner/approvals appear together. Each bot\'s tag must be listed in EMET_SOURCE_TAGS. See docs/SUBKEYS.md.';
675
+ }
676
+ if (setting('SUBKEYS') === '1') return 'Sub-keys were already on. This answer does not turn them off.';
677
+ return 'Sub-keys stay off. See docs/SUBKEYS.md.';
678
+ }
679
+
498
680
  export async function setupBegin() {
499
681
  const state = await setupStatus();
500
682
  return {
@@ -505,6 +687,8 @@ export async function setupBegin() {
505
687
  instructions:
506
688
  'Ask the user these questions conversationally - one at a time, in their own words, not as a form. ' +
507
689
  'Do not invent answers, and do not fill in an answer the user did not give. ' +
690
+ 'more_than_one_bot is optional. Yes or no, in the user\'s words. Leaving it out leaves sub-keys off. ' +
691
+ 'bot_team_backup is optional. Yes is the recommended choice. No, or leaving it out, leaves bot-team backup off. ' +
508
692
  'When you have them, call emet_setup_complete with an `answers` object keyed exactly as below.',
509
693
  questions: SETUP_INTERVIEW.map(q => ({ key: q.key, ask: q.ask, why: q.why, required: q.required }))
510
694
  };
@@ -682,7 +866,7 @@ export async function initialize(options = {}, hooks = {}) {
682
866
  if (late && late.length && typeof hooks.onLate === 'function') { try { hooks.onLate(late); } catch { /* recording never fails a page */ } }
683
867
  return late;
684
868
  };
685
- // Paged startup (2026-10-03, the owner approved paging; src/startup-pages.js).
869
+ // Paged startup (2026-10-03, Alex approved paging; src/startup-pages.js).
686
870
  // The full payload is built exactly as before; only its delivery changes.
687
871
  const raw = options.page;
688
872
  const wantAll = typeof raw === 'string' && raw.trim().toLowerCase() === 'all';
@@ -749,8 +933,23 @@ export async function initialize(options = {}, hooks = {}) {
749
933
  let startupBuildForTests = null;
750
934
  export function setStartupBuildForTests(fn) { startupBuildForTests = typeof fn === 'function' ? fn : null; }
751
935
 
936
+ /** Seat GEMs for this startup. A throw here still returns empty lists. */
937
+ async function loadSeatedGems() {
938
+ try {
939
+ return await seatGems();
940
+ } catch {
941
+ // SR-07: the session is never what fails. One line, no stack in the payload.
942
+ console.error('emet: GEM socket failed closed');
943
+ return { gems_available: [], gems_active: [] };
944
+ }
945
+ }
946
+
752
947
  /** The full startup payload, in one object: what initialize returned before paging. */
753
948
  async function buildStartup(options = {}) {
949
+ const seated = await loadSeatedGems();
950
+ const gems_available = seated.gems_available;
951
+ const gems_active = seated.gems_active;
952
+ const gemLine = gemStartupLine(gems_available);
754
953
  const status = await setupStatus();
755
954
 
756
955
  if (!status.reachable) {
@@ -769,7 +968,9 @@ async function buildStartup(options = {}) {
769
968
  'the user plainly that the memory store cannot be reached, and what the ' +
770
969
  'problem is. Do not proceed as though memory is available, and do not invent ' +
771
970
  'continuity you do not have. `charter` is still present: it ships with the release, not ' +
772
- 'the store, so it is readable even now. docs/SETUP.md has a failure table.'
971
+ 'the store, so it is readable even now. docs/SETUP.md has a failure table. ' + gemLine,
972
+ gems_available,
973
+ gems_active
773
974
  };
774
975
  }
775
976
 
@@ -793,7 +994,9 @@ async function buildStartup(options = {}) {
793
994
  'Do NOT run the setup interview and do NOT offer to create an identity. This store already ' +
794
995
  'holds memory that this server is not configured to see. Tell the user plainly what was ' +
795
996
  'found and what to change, and stop. Creating a second identity here would leave two ' +
796
- 'disconnected memories in one database.'
997
+ 'disconnected memories in one database. ' + gemLine,
998
+ gems_available,
999
+ gems_active
797
1000
  };
798
1001
  }
799
1002
 
@@ -824,8 +1027,11 @@ async function buildStartup(options = {}) {
824
1027
  'This memory store is new. Do not report a list of questions back to the user. ' +
825
1028
  'Explain in a sentence that you have no memory of them yet and would like to set that ' +
826
1029
  'up, then ask these questions conversationally, one at a time, in your own words. ' +
827
- 'Never invent or assume an answer. When you have them, call emet_setup_complete.',
828
- questions: begin.questions
1030
+ 'Never invent or assume an answer. One question is optional, yes or no: whether more than one bot or AI will use this memory. Leaving it out leaves sub-keys off. ' +
1031
+ 'When you have them, call emet_setup_complete. ' + gemLine,
1032
+ questions: begin.questions,
1033
+ gems_available,
1034
+ gems_active
829
1035
  };
830
1036
  }
831
1037
 
@@ -914,8 +1120,8 @@ async function buildStartup(options = {}) {
914
1120
  lessonsPending = { assessed: false, error: e && e.message ? e.message : String(e), pending: [] };
915
1121
  }
916
1122
 
917
- // Stage 1 of the host-aware startup amendment (2026-10-02; the owner's decision 2,
918
- // the owner's decision record: "emet_initialize is the startup doorway every bot
1123
+ // Stage 1 of the ref 358 startup amendment (2026-10-02; the owner's decision 2,
1124
+ // procedural ref 407: "emet_initialize is the startup doorway every bot
919
1125
  // calls; only first-time setup stays the owner's"). Initialize no longer creates
920
1126
  // the owner person: that write moved to setupComplete, the owner's
921
1127
  // first-time setup, so initialize is read-only in every branch and matches
@@ -930,12 +1136,20 @@ async function buildStartup(options = {}) {
930
1136
  const openDrops = dropRows.filter((d) => d.retired !== true);
931
1137
  const retiredDrops = dropRows.filter((d) => d.retired === true);
932
1138
  const shown = openDrops.slice(0, 20);
1139
+ let subkeys = null;
1140
+ if (setting('SUBKEYS') === '1') {
1141
+ const { subkeysView, loadSubkeyRecords } = await import('./subkeys.js');
1142
+ const { approvalsDeps } = await import('./tools.js');
1143
+ subkeys = subkeysView(await loadSubkeyRecords(approvalsDeps), rawActor() || { transport: 'stdio' }, personId, new Date());
1144
+ }
933
1145
 
934
1146
  return {
935
1147
  state: 'ready',
936
1148
  database: status.database,
937
- source_tag: setting('SOURCE_TAG') || null,
1149
+ ...callerAttribution(),
938
1150
  person_id: personId,
1151
+ ...((() => { const v = approvalsView(cachedApprovals(), rawActor() || { transport: 'stdio' }, null); return v ? { client_approvals: v } : {}; })()),
1152
+ ...(subkeys ? { subkeys } : {}),
939
1153
  unhandled_drops: shown.map((d) => d.doc_id),
940
1154
  unhandled_drops_more: Math.max(0, openDrops.length - shown.length),
941
1155
  retired_drops: retiredDrops.map((d) => ({ doc_id: d.doc_id, ...retirementFields(d) })),
@@ -944,7 +1158,7 @@ async function buildStartup(options = {}) {
944
1158
  // and connection; the user's name does the animating.
945
1159
  emet_line: EMET_LINE,
946
1160
  emet_line_if_unanswered: EMET_LINE_UNANSWERED,
947
- // 2026-09-26 the owner: prune may not drop the close-order tool. These two
1161
+ // 2026-09-26 Alex: prune may not drop the close-order tool. These two
948
1162
  // fields MUST precede identity/character/user so a 20k host truncate
949
1163
  // still receives them. The charter text may stay pointed; the steps may not.
950
1164
  tools_in_force: {
@@ -957,12 +1171,12 @@ async function buildStartup(options = {}) {
957
1171
  'source tag on every write'
958
1172
  ],
959
1173
  on_wrap: ['working documents', 'episodic session record', 'board', 'verbatim transcript', 'HANDOFF last one write'],
960
- // 2026-09-26 the owner (a decision record): close is blocked without receipts.
1174
+ // 2026-09-26 Alex (procedural ref 391): close is blocked without receipts.
961
1175
  confirm: 'emet_session_close with receipts for all four items and the handoff; say its speak lines in order; saved only when state is complete'
962
1176
  },
963
1177
  // Code audit 2026-09-27 (2.1): the short instructions ride up front with
964
1178
  // tools_in_force, for the same reason - a 20K cut must not take them.
965
- instructions_short: shortInstructions(),
1179
+ instructions_short: shortInstructions(gems_available),
966
1180
  session_close_protocol: CLOSE_PROTOCOL,
967
1181
  operating_discipline: DISCIPLINE_SUMMARY,
968
1182
  charter: charterPayload(charterStore ? charterStore.content : null),
@@ -984,7 +1198,7 @@ async function buildStartup(options = {}) {
984
1198
  ...retirementFields(installNotes)
985
1199
  } : null,
986
1200
  install_notes_doc_id: installDoc(),
987
- // One line each since 2026-09-27 (the owner); the full seed is one read_doc away.
1201
+ // One line each since 2026-09-27 (Alex); the full seed is one read_doc away.
988
1202
  seeds: seeds.map((s) => seedLine(s)),
989
1203
  seeds_due: seeds.filter((s) => s.due && s.retired !== true).map((s) => s.doc_id),
990
1204
  seeds_evaluated_on: today,
@@ -1025,7 +1239,9 @@ async function buildStartup(options = {}) {
1025
1239
  floor: FLOOR,
1026
1240
  floor_precedence: FLOOR_PRECEDENCE,
1027
1241
  floor_reassertion: FLOOR_REASSERTION,
1028
- instructions: readyInstructions(Boolean(character))
1242
+ instructions: readyInstructions(Boolean(character), gems_available),
1243
+ gems_available,
1244
+ gems_active
1029
1245
  };
1030
1246
  } finally {
1031
1247
  if (client) await client.close();
@@ -1107,6 +1323,11 @@ function renderUserDoc(a) {
1107
1323
  '## §3 PREFERENCES',
1108
1324
  a.user_preferences && String(a.user_preferences).trim() ? a.user_preferences : 'None stated.',
1109
1325
  '',
1326
+ '## §4 BOT-TEAM BACKUP',
1327
+ a.bot_team_backup === true ? 'yes' : 'no',
1328
+ '',
1329
+ 'Recorded at setup. Yes means follow gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md. No, or leaving the question out, leaves the practice off. EMET does not copy prompts or settings itself. Never store secret values in a bot-team document.',
1330
+ '',
1110
1331
  '---',
1111
1332
  '',
1112
1333
  '*Written by the EMET setup interview. Add to it as you learn more — this document is',
@@ -1116,16 +1337,16 @@ function renderUserDoc(a) {
1116
1337
  }
1117
1338
 
1118
1339
  export async function setupComplete(answers = {}, logger = null, options = {}) {
1119
- // First-time setup is the owner's (decision 2, the owner's decision record). Stage 1
1120
- // guards (2026-10-02, the reviewer's stage 1 admission, conditions S1-S2):
1340
+ // First-time setup is the owner's (decision 2, procedural ref 407). Stage 1
1341
+ // guards (2026-10-02, the tester's stage 1 admission, conditions S1-S2):
1121
1342
  // - refused under a configured bot tag. The tag is self-declared and
1122
1343
  // emet_initialize takes no source until stage 2, so this is inert until a
1123
- // caller declares a bot tag that the owner has configured (stage 3);
1344
+ // caller declares a bot tag that Alex has configured (stage 3);
1124
1345
  // - refused on a store that is already set up unless `reissue: true` (the owner's
1125
1346
  // answer 5): a re-run replaces the user's identity documents with new
1126
1347
  // versions, so it needs the owner's explicit word. Nothing is written
1127
1348
  // when either refusal fires.
1128
- // S1-1 (the reviewer's re-check of the first draft): EACH declared value is checked, not
1349
+ // S1-1 (the tester's re-check of commit-1a215d7): EACH declared value is checked, not
1129
1350
  // the first non-empty one. The write below takes answers.source_tag first, so
1130
1351
  // checking only `source` let `source: <host>` + `answers.source_tag: <bot>`
1131
1352
  // write the identity documents as the bot. The tag that would be written
@@ -1151,8 +1372,27 @@ export async function setupComplete(answers = {}, logger = null, options = {}) {
1151
1372
  'Ask the user for them - do not supply them yourself.'
1152
1373
  );
1153
1374
  }
1375
+ const multi = parseMoreThanOneBot(answers.more_than_one_bot);
1376
+ if (multi === null) {
1377
+ throw new ValidationError(
1378
+ 'more_than_one_bot',
1379
+ 'Answer yes or no. Yes turns on delegated client sub-keys. No leaves them off. Leaving it out leaves them off. Do not invent the answer. Nothing was written.'
1380
+ );
1381
+ }
1382
+ const backup = parseBotTeamBackup(answers.bot_team_backup);
1383
+ if (backup === null) {
1384
+ throw new ValidationError(
1385
+ 'bot_team_backup',
1386
+ 'Answer yes or no, in your own words. Yes and Yes. record bot-team backup as on. Yes is the recommended choice. No leaves it off. Leaving it out leaves it off. A non-string is refused. Do not invent the answer. Nothing was written.'
1387
+ );
1388
+ }
1389
+ answers = { ...answers, bot_team_backup: backup };
1390
+ if (multi && !dotenvWritable(options.envFile || ENV_FILE, process.env, options.startup)) {
1391
+ throw new ValidationError('more_than_one_bot', HOSTED_SUBKEY_STEPS);
1392
+ }
1154
1393
 
1155
1394
  const written = [];
1395
+ let subkeys = null;
1156
1396
  let client;
1157
1397
 
1158
1398
  try {
@@ -1173,7 +1413,7 @@ export async function setupComplete(answers = {}, logger = null, options = {}) {
1173
1413
  const [hasIdentity, hasUser] = await Promise.all([
1174
1414
  docsCol.findOne({ doc_id: IDENTITY_DOC }), docsCol.findOne({ doc_id: USER_DOC })
1175
1415
  ]);
1176
- // Set up = BOTH identity documents exist (the second reader's S-c; the reviewer: test it,
1416
+ // Set up = BOTH identity documents exist (the reviewer's S-c; the tester: test it,
1177
1417
  // don't loosen it). A half-set-up store (one of the two) is an interrupted
1178
1418
  // first setup, and completing it needs no reissue.
1179
1419
  if (hasIdentity && hasUser && options.reissue !== true) {
@@ -1230,6 +1470,18 @@ export async function setupComplete(answers = {}, logger = null, options = {}) {
1230
1470
  const { ensureOwnerPerson } = await import('./persons.js');
1231
1471
  const person = await ensureOwnerPerson(conn.db, { display_name: answers.user_name });
1232
1472
 
1473
+ if (multi) {
1474
+ try {
1475
+ subkeys = enableSubkeysAtFirstUse({ envFile: options.envFile || ENV_FILE, startup: options.startup });
1476
+ } catch (e) {
1477
+ const message = e.validationMessage || e.message;
1478
+ // A hosted refusal is a refusal, not a half-finished setup. It is thrown
1479
+ // before these writes; if it surfaces here, do not report it as a file error.
1480
+ if (message === HOSTED_SUBKEY_STEPS) throw e;
1481
+ subkeys = { enabled: false, turned_on: false, env_error: message };
1482
+ }
1483
+ }
1484
+
1233
1485
  return {
1234
1486
  ok: true,
1235
1487
  documents: written,
@@ -1238,12 +1490,27 @@ export async function setupComplete(answers = {}, logger = null, options = {}) {
1238
1490
  source_tag: tag,
1239
1491
  person_id: person.person_id,
1240
1492
  person_role: person.role,
1493
+ bot_team_backup: backup,
1494
+ subkeys: multi
1495
+ ? (subkeys.env_error
1496
+ ? { enabled: false, turned_on: false, env_error: subkeys.env_error }
1497
+ : {
1498
+ enabled: true,
1499
+ pepper_generated: subkeys.pepper_generated,
1500
+ pepper_version: subkeys.pepper_version,
1501
+ restart_for_owner_page: true
1502
+ })
1503
+ : { enabled: setting('SUBKEYS') === '1', turned_on: false },
1241
1504
  next_step:
1242
1505
  'Setup complete and verified. Tell the user what was recorded, in their own words, and ' +
1243
1506
  'that they can change any of it later with write_doc. If other hosts will connect to this ' +
1244
1507
  'same store, each one needs its own source tag: on a server several hosts share, leave EMET_SOURCE_TAG ' +
1245
1508
  'unset, have each host pass its tag as `source` on its writes, and register the tags in EMET_SOURCE_TAGS; ' +
1246
- 'set EMET_SOURCE_TAG only on a server one host uses. New logins will be issued as this person, not as a generic owner.'
1509
+ 'set EMET_SOURCE_TAG only on a server one host uses. New logins will be issued as this person, not as a generic owner. ' +
1510
+ subkeySetupNote(multi, subkeys) + ' ' +
1511
+ (backup
1512
+ ? 'Bot-team backup is on. Follow gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md. Revise bot-team documents only when they change, and never store secret values.'
1513
+ : 'Bot-team backup stays off.')
1247
1514
  };
1248
1515
  } finally {
1249
1516
  if (client) await client.close();