total-recall-brain 3.28.0 → 3.28.2

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.
@@ -172,24 +172,59 @@ export function releasePidLock() {
172
172
  let taskCount = 0;
173
173
  let running = true;
174
174
  let vaultWatcher = null;
175
+ let shuttingDown = false;
176
+
177
+ // Upper bound on a graceful stop. A loop iteration can be mid-task, so the
178
+ // signal cannot exit instantly — but it must exit *eventually*.
179
+ const SHUTDOWN_GRACE_MS = 30000;
175
180
 
176
181
  import { releaseLease } from './leader-election.mjs';
177
182
 
178
- process.on('SIGTERM', async () => {
179
- logger.info({ subsystem: 'daemon-loop', message: 'SIGTERM received — shutting down gracefully' });
180
- if (vaultWatcher) vaultWatcher.stop();
181
- await releaseLease();
182
- releasePidLock();
183
+ /**
184
+ * Graceful stop, with a guaranteed exit.
185
+ *
186
+ * Clearing `running` alone never stopped this process. The vault and source
187
+ * watchers hold the event loop open, so `daemon stop` printed "✅ Daemon
188
+ * stopped" while the daemon kept running — still executing the OLD module code
189
+ * and still ingesting sessions. No "Daemon stopped." line ever reached
190
+ * daemon.log across three separate SIGTERMs. The fallback timer bounds that, so
191
+ * even a wedged iteration cannot keep the daemon alive indefinitely.
192
+ */
193
+ function shutdown(signal) {
194
+ if (shuttingDown) return;
195
+ shuttingDown = true;
196
+ logger.info({ subsystem: 'daemon-loop', message: `${signal} received — shutting down gracefully` });
183
197
  running = false;
184
- });
185
-
186
- process.on('SIGINT', async () => {
187
- logger.info({ subsystem: 'daemon-loop', message: 'SIGINT received — shutting down gracefully' });
188
198
  if (vaultWatcher) vaultWatcher.stop();
189
- await releaseLease();
190
- releasePidLock();
191
- running = false;
192
- });
199
+
200
+ const fallback = setTimeout(() => {
201
+ logger.info({
202
+ subsystem: 'daemon-loop',
203
+ message: `Graceful shutdown exceeded ${SHUTDOWN_GRACE_MS}ms — forcing exit`,
204
+ });
205
+ process.exit(0);
206
+ }, SHUTDOWN_GRACE_MS);
207
+ fallback.unref();
208
+
209
+ Promise.resolve()
210
+ .then(() => releaseLease())
211
+ .catch((err) => {
212
+ logger.info({
213
+ subsystem: 'daemon-loop',
214
+ message: `Lease release failed during shutdown: ${err.message}`,
215
+ });
216
+ })
217
+ .finally(() => {
218
+ try {
219
+ releasePidLock();
220
+ } catch {
221
+ // Lock already gone — nothing to release.
222
+ }
223
+ });
224
+ }
225
+
226
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
227
+ process.on('SIGINT', () => shutdown('SIGINT'));
193
228
 
194
229
  process.on('uncaughtException', (err) => {
195
230
  logger.error({ subsystem: 'daemon-loop', message: `Uncaught Exception (suppressed to keep daemon alive): ${err.stack || err.message}` });
@@ -291,7 +326,7 @@ async function main() {
291
326
 
292
327
  // Deterministic election (lowest mesh IP) — tryAcquire/renew are no-op shims
293
328
  // that re-evaluate isLeader(); no node-local lease documents are written.
294
- const { tryAcquireLease, isLeader, getLeaderInfo, renewLease } = await import('./leader-election.mjs');
329
+ const { tryAcquireLease, isLeader, getLeaderInfo, renewLease, leaderPin } = await import('./leader-election.mjs');
295
330
 
296
331
  const acquired = await tryAcquireLease();
297
332
  if (acquired) {
@@ -300,7 +335,22 @@ async function main() {
300
335
  const info = await getLeaderInfo();
301
336
  const leaderStr = info ? info.hostname : 'unknown';
302
337
  logger.info({ subsystem: 'daemon-loop', message: `Starting as FOLLOWER (leader: ${leaderStr})` });
303
-
338
+
339
+ // Lowest-online-IP says nothing about whether the elected node can run Total
340
+ // Recall. On this mesh the shared droplet holds 100.64.0.1 and runs no TR at
341
+ // all, so every real machine idled as a follower for months: the daemon
342
+ // reported itself healthy while the pipeline stayed silent. A follower with
343
+ // no pin configured anywhere is almost certainly that mistake, and it is
344
+ // invisible without this warning — followers skip the task loop entirely.
345
+ if (!leaderPin()) {
346
+ logger.warn(
347
+ 'daemon-loop',
348
+ `No leader pin found — following "${leaderStr}" by lowest-IP order. Followers skip the task loop, ` +
349
+ `so this node will run no research, dream cycle, or surface recompile. ` +
350
+ `Pin the brain host with TR_LEADER_IP=<mesh-ip> or {"leaderIp":"<mesh-ip>"} in config/leader.json.`,
351
+ );
352
+ }
353
+
304
354
  // Phase 4C: Startup Sync (follower only)
305
355
  try {
306
356
  const { syncLoop } = await import('./secrets-sync.mjs');
@@ -653,6 +703,9 @@ async function main() {
653
703
  }
654
704
 
655
705
  logger.info({ subsystem: 'daemon-loop', message: 'Daemon stopped.' });
706
+ // The watchers keep the event loop alive, so returning from main() is not
707
+ // enough — without this the process lingers and `daemon stop` lies.
708
+ process.exit(0);
656
709
  }
657
710
 
658
711
  main().catch(async (err) => {
@@ -61,15 +61,25 @@ let currentDbPath = null;
61
61
  const dbVssEnabled = new WeakMap();
62
62
 
63
63
  /**
64
- * better-sqlite3 on Linux appends ".so" when the path already ends in ".so",
65
- * producing "vss0.so.so". Strip known extensions before loadExtension.
64
+ * better-sqlite3 appends the platform suffix, so a path that already ends in
65
+ * ".so" reports "vss0.so.so: cannot open shared object file". Strip known
66
+ * extensions before loadExtension.
67
+ *
68
+ * When BOTH attempts fail, rethrow the FIRST error: the second is the artifact
69
+ * of the appended suffix, and logging it hid the real cause on a Linux host —
70
+ * "vss0.so.so: cannot open shared object file" while the actual reason was a
71
+ * missing libblas.so.3 that `ldd vss0.so` names immediately.
66
72
  */
67
73
  function loadExtensionBare(db, absolutePath) {
68
74
  const bare = String(absolutePath).replace(/\.(so|dylib|dll)$/i, '');
69
75
  try {
70
76
  db.loadExtension(bare);
71
- } catch {
72
- db.loadExtension(absolutePath);
77
+ } catch (bareErr) {
78
+ try {
79
+ db.loadExtension(absolutePath);
80
+ } catch {
81
+ throw bareErr;
82
+ }
73
83
  }
74
84
  }
75
85
 
@@ -96,6 +96,20 @@ export function loadAgenda() {
96
96
  function saveAgenda(topics) {
97
97
  const agendaFile = getAgendaFile();
98
98
  const dir = path.dirname(agendaFile);
99
+ // Same backstop as the research queue (see research-queue.mjs): a test run must
100
+ // never rewrite the operator's real agenda. The 29 leaked "Automated API
101
+ // Integration Build:" fixture topics came from exactly this hole.
102
+ const isTest = Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test';
103
+ if (
104
+ isTest &&
105
+ !process.env.AGENT_DIR &&
106
+ !process.env._TR_TEST_AGENT_DIR &&
107
+ path.resolve(dir) === path.resolve(getBrainDir())
108
+ ) {
109
+ throw new Error(
110
+ 'Refusing to write the real research agenda from a test run: set _TR_TEST_AGENT_DIR',
111
+ );
112
+ }
99
113
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
100
114
  atomicWrite(agendaFile, topics.map(t => JSON.stringify(t)).join('\n') + '\n');
101
115
  }
@@ -137,6 +151,68 @@ export function addToAgenda({ topic, priority = 50, source, rationale = '', tags
137
151
  return existing || agenda.find(t => t.topic.toLowerCase() === topic.toLowerCase());
138
152
  }
139
153
 
154
+ /**
155
+ * Status for a topic retired on purpose — distinct from `failed`.
156
+ *
157
+ * A cancelled topic was never attempted, so recording it as a failure
158
+ * misreports pipeline health. See `reclassifyCancelledAgendaTopics()`.
159
+ */
160
+ export const CANCELLED_STATUS = 'cancelled';
161
+
162
+ /**
163
+ * Retire obsolete key-name scrape topics from the AGENDA.
164
+ *
165
+ * Companion to `cancelBogusApiIntegrationQueueItems()` in
166
+ * secret-integration-research.mjs. That one walks the QUEUE, but the June/July
167
+ * 2026 pollution actually landed in the agenda (29 "Automated API Integration
168
+ * Build: <KEY>" fixture topics), so the designated remediation never touched it.
169
+ * The agenda is fact-seeker's file, so the agenda half lives here.
170
+ *
171
+ * Also sweeps legacy rows still recorded as `failed` with a `cancelled_reason`
172
+ * onto CANCELLED_STATUS.
173
+ */
174
+ export function cancelBogusAgendaTopics() {
175
+ const agenda = loadAgenda();
176
+ let cancelled = 0;
177
+ for (const t of agenda) {
178
+ if (t.status !== 'pending' && t.status !== 'in_progress') continue;
179
+ if (!/^Automated API Integration Build:/i.test(String(t.topic || ''))) continue;
180
+ t.status = CANCELLED_STATUS;
181
+ t.coverage_score = 0;
182
+ t.cancelled_reason =
183
+ 'obsolete key-name scrape job; re-add the secret to trigger AI provider inference';
184
+ cancelled += 1;
185
+ }
186
+ if (cancelled > 0) saveAgenda(agenda);
187
+ return { cancelled, reclassified: reclassifyCancelledAgendaTopics() };
188
+ }
189
+
190
+ /**
191
+ * Repair topics that were retired before CANCELLED_STATUS existed.
192
+ *
193
+ * A row carrying `status: 'failed'` *together with* a `cancelled_reason` was
194
+ * cancelled, never attempted — `cancelled_reason` is written only by the
195
+ * cancellation paths. The 2026-09 agenda cleanup retired 231 such topics
196
+ * (204 unbounded follow-ups + 27 obsolete fixtures) as `failed`, so anything
197
+ * tallying `status === 'failed'` reported 231 phantom failures beside 43
198
+ * genuinely pending topics. Move them onto the dedicated status so failure
199
+ * counts mean real failures. Idempotent.
200
+ *
201
+ * @returns {number} rows reclassified
202
+ */
203
+ export function reclassifyCancelledAgendaTopics() {
204
+ const agenda = loadAgenda();
205
+ let reclassified = 0;
206
+ for (const t of agenda) {
207
+ if (t.status === 'failed' && t.cancelled_reason) {
208
+ t.status = CANCELLED_STATUS;
209
+ reclassified += 1;
210
+ }
211
+ }
212
+ if (reclassified > 0) saveAgenda(agenda);
213
+ return reclassified;
214
+ }
215
+
140
216
  /**
141
217
  * Mark a topic as researched and update its coverage score.
142
218
  */
@@ -918,9 +994,25 @@ export async function runKnowledgeAcquisitionCycle({
918
994
  const coverageScore = Math.min(1.0, (results.length / 5) * confidence);
919
995
  markTopicResearched(topicId, { coverageScore, sourcesConsulted: sourcesUsed });
920
996
 
921
- // Enqueue follow-up research for identified gaps (self-multiplication)
922
- const gaps = synthesis.further_research_needed || [];
923
- for (const gap of gaps.slice(0, 3)) {
997
+ // Enqueue follow-up research for identified gaps (self-multiplication).
998
+ //
999
+ // Bounded per parent topic. Research runs five phases and the monitoring and
1000
+ // expansion phases repeat, so an uncapped 3 gaps per cycle compounds without
1001
+ // limit — two completed reports generated 116 and 98 follow-ups respectively,
1002
+ // none of which ever ran. The agenda is a backlog, not a fan-out tree.
1003
+ const FOLLOW_UP_CAP_PER_TOPIC = 5;
1004
+ const alreadySpawned = loadAgenda().filter(
1005
+ (t) => t.source === `follow-up:${topic}`,
1006
+ ).length;
1007
+ const remaining = Math.max(0, FOLLOW_UP_CAP_PER_TOPIC - alreadySpawned);
1008
+ const gaps = (synthesis.further_research_needed || []).slice(0, remaining);
1009
+ if (remaining === 0 && (synthesis.further_research_needed || []).length > 0) {
1010
+ logger.info({
1011
+ subsystem: 'fact-seeker',
1012
+ message: `Follow-up cap reached for "${topic}" (${alreadySpawned} already queued); not spawning more.`,
1013
+ });
1014
+ }
1015
+ for (const gap of gaps) {
924
1016
  addToAgenda({
925
1017
  topic: gap,
926
1018
  priority: Math.max(20, topicEntry.priority - 15),
@@ -6,7 +6,8 @@
6
6
  * hardcoded here — it is resolved from the operator's own secret entry or
7
7
  * environment, so this works against any Headscale (or Tailscale SaaS) install.
8
8
  */
9
- import { getSecretsCatalog, getSecret } from './secrets-store.mjs';
9
+ import path from 'node:path';
10
+ import { getSecretsCatalog, getSecret, defaultBrainDir } from './secrets-store.mjs';
10
11
  import { throttledFetch } from './throttled-fetch.mjs';
11
12
 
12
13
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -39,10 +40,32 @@ export function normalizeControlUrl(url) {
39
40
  /**
40
41
  * Locate the operator's headscale credential without knowing its key name.
41
42
  * Any secret whose provider is `headscale` is treated as the control-server token.
43
+ *
44
+ * The control server is a machine-level resource, not a per-repo one, so the
45
+ * credential normally lives in the GLOBAL brain while callers pass whichever
46
+ * project brain they happen to be running in. Falling back to the global store
47
+ * is what lets `mesh status`, `mesh nodes` and node enrollment work from inside
48
+ * any project checkout — without it every project reported "not configured"
49
+ * while the key sat in ~/.agent the whole time.
42
50
  */
51
+ async function locateHeadscaleKey(brainDir) {
52
+ const target = brainDir || defaultBrainDir();
53
+
54
+ const local = await getSecretsCatalog(target);
55
+ const hit = local.keys.find((k) => k.provider === 'headscale');
56
+ if (hit) return { meta: hit, storeDir: target };
57
+
58
+ const globalDir = defaultBrainDir();
59
+ if (path.resolve(globalDir) === path.resolve(target)) return null;
60
+
61
+ const globalCatalog = await getSecretsCatalog(globalDir);
62
+ const globalHit = globalCatalog.keys.find((k) => k.provider === 'headscale');
63
+ return globalHit ? { meta: globalHit, storeDir: globalDir } : null;
64
+ }
65
+
43
66
  export async function findHeadscaleKeyMeta(brainDir) {
44
- const catalog = await getSecretsCatalog(brainDir);
45
- return catalog.keys.find((k) => k.provider === 'headscale') || null;
67
+ const found = await locateHeadscaleKey(brainDir);
68
+ return found ? found.meta : null;
46
69
  }
47
70
 
48
71
  /**
@@ -51,12 +74,13 @@ export async function findHeadscaleKeyMeta(brainDir) {
51
74
  * gracefully should use `describeHeadscaleAvailability()` instead.
52
75
  */
53
76
  export async function resolveHeadscaleConfig(brainDir) {
54
- const meta = await findHeadscaleKeyMeta(brainDir);
55
- if (!meta) {
77
+ const found = await locateHeadscaleKey(brainDir);
78
+ if (!found) {
56
79
  throw new Error('Headscale API Key not configured in ApiKeysPage');
57
80
  }
81
+ const { meta, storeDir } = found;
58
82
 
59
- const got = await getSecret(brainDir, meta.key, {
83
+ const got = await getSecret(storeDir, meta.key, {
60
84
  action: 'use',
61
85
  actor: 'headscale-client',
62
86
  });
@@ -1,4 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { getMeshPeers, getMeshSelf } from './mesh.mjs';
4
+ import { brainDir } from './config.mjs';
2
5
 
3
6
  /**
4
7
  * Deterministic lowest-mesh-IP leader election.
@@ -19,6 +22,44 @@ import { getMeshPeers, getMeshSelf } from './mesh.mjs';
19
22
  /** Documented upper bound for isLeader() flip after the prior leader goes offline. */
20
23
  export const FAILOVER_BOUND_MS = 12_000;
21
24
 
25
+ /**
26
+ * Operator pin for which node leads.
27
+ *
28
+ * Lowest-online-IP is deterministic, but it says nothing about whether that node
29
+ * can actually run the daemon. On this mesh the shared droplet holds the lowest
30
+ * IP (100.64.0.1) and runs no Total Recall at all — it captured leadership
31
+ * permanently, so it could never serve `/api/secrets/sync` and every other node
32
+ * idled as a follower: no research, no dream cycle, no surface recompile,
33
+ * anywhere. Followers skip the task loop entirely, so "lowest IP" quietly became
34
+ * "nobody works".
35
+ *
36
+ * Set `TR_LEADER_IP` to the mesh IP of the machine that owns the canonical brain
37
+ * on EVERY node that runs Total Recall, so they all agree on the answer. A node
38
+ * whose own IP is not the pin never leads. When the pinned node is offline nobody
39
+ * leads — fail-closed, which is the right default for an explicit operator
40
+ * choice, and far better than electing a leader that cannot do the work.
41
+ */
42
+ export function leaderPin(brain = brainDir) {
43
+ const raw = String(process.env.TR_LEADER_IP || '').trim();
44
+ if (raw) return raw;
45
+
46
+ // File fallback. The env var only exists when the supervisor supplies it:
47
+ // `total-recall daemon start` from a plain shell has no TR_LEADER_IP, so the
48
+ // pin silently disappeared and the node became a follower of the lowest-IP
49
+ // peer — the droplet, which runs no Total Recall. That is the exact
50
+ // "lowest IP quietly became nobody works" outage this pin was added to fix,
51
+ // and it is invisible: the daemon starts, looks healthy, and does nothing.
52
+ try {
53
+ const file = path.join(brain, 'config', 'leader.json');
54
+ if (!fs.existsSync(file)) return null;
55
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ const ip = String(parsed?.leaderIp || '').trim();
57
+ return ip || null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
22
63
  function compareNodes(a, b) {
23
64
  const ipOrder = String(a.ip || '').localeCompare(String(b.ip || ''), undefined, { numeric: true });
24
65
  return ipOrder || String(a.hostname || '').localeCompare(String(b.hostname || ''));
@@ -31,6 +72,14 @@ function compareNodes(a, b) {
31
72
  export async function getLeaderInfo() {
32
73
  const nodes = getMeshPeers({ includeSelf: true }).filter((node) => node.online);
33
74
  if (nodes.length === 0) return null;
75
+
76
+ const pin = leaderPin();
77
+ if (pin) {
78
+ const pinned = nodes.find((node) => node.ip === pin);
79
+ if (!pinned) return null;
80
+ return { hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' };
81
+ }
82
+
34
83
  const leader = [...nodes].sort(compareNodes)[0];
35
84
  return { hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' };
36
85
  }
@@ -28,6 +28,27 @@ function getQueueFile(overrideBrainDir) {
28
28
  return path.join(dir, 'research-queue.jsonl');
29
29
  }
30
30
 
31
+ /**
32
+ * Refuse to write the operator's REAL research state from inside a test run.
33
+ *
34
+ * The June/July 2026 agenda accumulated 29 "Automated API Integration Build:"
35
+ * fixture entries plus a queue file the daemon never drained, because a code
36
+ * path reached the default brain while tests were running. Threading `brainDir`
37
+ * through every caller is the real fix; this is the backstop, so a future caller
38
+ * that forgets it fails loudly instead of quietly polluting real data. Tests
39
+ * point at a temp brain with _TR_TEST_AGENT_DIR (or AGENT_DIR).
40
+ */
41
+ function assertNotRealBrainInTest(dir, what) {
42
+ const isTest = Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test';
43
+ if (!isTest) return;
44
+ if (process.env._TR_TEST_AGENT_DIR || process.env.AGENT_DIR) return;
45
+ if (path.resolve(dir) === path.resolve(getBrainDir())) {
46
+ throw new Error(
47
+ `Refusing to write the real ${what} from a test run: pass an explicit brainDir or set _TR_TEST_AGENT_DIR`,
48
+ );
49
+ }
50
+ }
51
+
31
52
  const STATUS_RANK = { pending: 0, in_progress: 1, done: 2, failed: 3 };
32
53
 
33
54
  // ─── Summary Compiler ─────────────────────────────────────────────────────────
@@ -278,6 +299,7 @@ export function loadQueue(overrideBrainDir) {
278
299
 
279
300
  export function saveQueue(items, overrideBrainDir) {
280
301
  const queueFile = getQueueFile(overrideBrainDir);
302
+ assertNotRealBrainInTest(path.dirname(queueFile), 'research queue');
281
303
  fs.mkdirSync(path.dirname(queueFile), { recursive: true });
282
304
  fs.writeFileSync(queueFile, items.map(i => JSON.stringify(i)).join('\n') + '\n', 'utf8');
283
305
  }
@@ -92,6 +92,17 @@ export async function handleProactiveResearch(task, context = {}) {
92
92
  draftSlug = saveSynthesizedReportToDraft(task.target, finalReport, inboxDir) || draftSlug;
93
93
  }
94
94
 
95
+ // Phase 4b: Promote the finished report into the vault. The deliberation phase
96
+ // resolves its target from the vault, not the inbox, so without this the
97
+ // five-phase pipeline always stalls at step 2 with "Target node not found".
98
+ if (draftSlug) {
99
+ await promoteDraftToVault(
100
+ task.target,
101
+ inboxDir,
102
+ context.vaultDir || path.join(BRAIN_DIR, 'memory-vault'),
103
+ );
104
+ }
105
+
95
106
  // Phase 5: Also add topic to the Research Agenda for ongoing tracking
96
107
  addToAgenda({
97
108
  topic: task.target,
@@ -514,6 +525,66 @@ export function saveSynthesizedReportToDraft(parentTopic, finalReport, inboxDir)
514
525
  return slug;
515
526
  }
516
527
 
528
+ /**
529
+ * Promote a synthesized report out of the inbox into the vault.
530
+ *
531
+ * Acquisition stages every report as `status: draft` under
532
+ * `memory-inbox/pending`, but the Phase 2/5 deliberation cycle resolves its
533
+ * target with `getNodes(vaultDir).find(n => n.slug === nodeSlug)`. A draft that
534
+ * never leaves the inbox therefore made every deliberation task fail with
535
+ * "Target node not found for slug: …", so the five-phase pipeline stalled at
536
+ * step 2 on every topic no matter how good the report was.
537
+ *
538
+ * The documented flow gates on confidence: >= 0.7 is a "Direct Vault Write &
539
+ * Immediate Surface Recompile", below that stays staged for review. Synthesized
540
+ * reports carry confidence 0.9, so they take the fast path.
541
+ */
542
+ export const VAULT_PROMOTION_MIN_CONFIDENCE = 0.7;
543
+
544
+ export async function promoteDraftToVault(parentTopic, inboxDir, vaultDir) {
545
+ const slug = `research-report-${slugify(parentTopic)}`;
546
+ const filePath = path.join(inboxDir, `${slug}.md`);
547
+ if (!fs.existsSync(filePath)) return null;
548
+
549
+ let parsed;
550
+ try {
551
+ parsed = matter(fs.readFileSync(filePath, 'utf8'));
552
+ } catch (err) {
553
+ logger.warn({ subsystem: 'deep-research', message: `Cannot read draft ${slug}: ${err.message}` });
554
+ return null;
555
+ }
556
+
557
+ const confidence = Number(parsed.data?.confidence);
558
+ if (!Number.isFinite(confidence) || confidence < VAULT_PROMOTION_MIN_CONFIDENCE) {
559
+ logger.info({
560
+ subsystem: 'deep-research',
561
+ message: `Report ${slug} stays in the inbox (confidence ${parsed.data?.confidence ?? 'unset'} < ${VAULT_PROMOTION_MIN_CONFIDENCE})`,
562
+ });
563
+ return null;
564
+ }
565
+
566
+ // The draft already carries the full SSSS frontmatter; promotion only flips it
567
+ // from staged-for-review to a live vault node.
568
+ const node = { ...parsed.data, slug, status: 'active', body: parsed.content.trim() };
569
+
570
+ try {
571
+ const { writeNode } = await import('./vault.mjs');
572
+ await writeNode(node, vaultDir || path.join(BRAIN_DIR, 'memory-vault'));
573
+ } catch (err) {
574
+ logger.warn({
575
+ subsystem: 'deep-research',
576
+ message: `Vault promotion failed for ${slug}: ${err.message}`,
577
+ });
578
+ return null;
579
+ }
580
+
581
+ logger.info({
582
+ subsystem: 'deep-research',
583
+ message: `Promoted ${slug} to the vault (confidence ${confidence}) — deliberation can now resolve it`,
584
+ });
585
+ return slug;
586
+ }
587
+
517
588
  // ─── Synthesis ───────────────────────────────────────────────────────────────────
518
589
 
519
590
  async function synthesizeLocally(task, results, runtimeConfig) {
@@ -277,6 +277,64 @@ function resolveAgent(config) {
277
277
  * @param {object} config - Runtime config from loadRuntimeConfig()
278
278
  * @returns {string} The agent's text response
279
279
  */
280
+ /**
281
+ * Merge every secrets layer the runtime can see.
282
+ *
283
+ * Precedence, lowest first (each later layer overrides the earlier ones):
284
+ * 1. <agentRoot>/secrets.enc legacy flat store
285
+ * 2. <cwd>/.agent/secrets.enc legacy flat store
286
+ * 3. ~/.agent/skills/total-recall/config/secrets.enc global brain
287
+ * 4. <agentRoot>/skills/total-recall/config/secrets.enc project brain
288
+ *
289
+ * Project-over-global matches `resolveBrainDir('auto')`, which every other TR
290
+ * component already uses: the project store is the more specific answer. The
291
+ * legacy flat stores are leftovers from older installs — they may still hold
292
+ * keys nothing else has, but they must never mask a canonical brain store.
293
+ *
294
+ * The previous implementation took the first path that held ANY keys and
295
+ * stopped, in legacy-first order. A stale 12-key <cwd>/.agent/secrets.enc
296
+ * therefore shadowed the 100-key brain store completely, and its dead
297
+ * `google_api_key` (AIza…, HTTP 400 from generativelanguage) overrode the
298
+ * working one — so every spawned agent received a key that cannot call Gemini
299
+ * while the good key sat unreachable one layer down.
300
+ *
301
+ * Exported for tests; callers use the defaults.
302
+ *
303
+ * @param {{ cwd?: string, agentRoot?: string, home?: string }} [opts]
304
+ * @returns {Promise<Record<string, string>>}
305
+ */
306
+ export async function loadDynamicSecrets(opts = {}) {
307
+ const cwd = opts.cwd ?? process.cwd();
308
+ const agentRoot = opts.agentRoot ?? agentDir;
309
+ const home = opts.home ?? os.homedir();
310
+
311
+ const { loadSecretsSync } = await import('./secrets-store.mjs');
312
+ const layers = [
313
+ path.join(agentRoot, 'secrets.enc'),
314
+ path.join(cwd, '.agent', 'secrets.enc'),
315
+ path.join(home, '.agent', 'skills', 'total-recall', 'config', 'secrets.enc'),
316
+ path.join(agentRoot, 'skills', 'total-recall', 'config', 'secrets.enc'),
317
+ ];
318
+
319
+ const merged = {};
320
+ for (const p of layers) {
321
+ if (!fs.existsSync(p)) continue;
322
+ try {
323
+ const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
324
+ const dir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
325
+ const loaded = loadSecretsSync(dir);
326
+ for (const [k, v] of Object.entries(loaded)) {
327
+ if (k === '__tr_secrets_meta') continue;
328
+ if (v) merged[k] = v;
329
+ }
330
+ } catch {
331
+ // An unreadable or undecryptable layer must not break dispatch; the
332
+ // remaining layers still apply.
333
+ }
334
+ }
335
+ return merged;
336
+ }
337
+
280
338
  export async function callLocalRuntime(prompt, system, config) {
281
339
  // Pre-flight budget watchdog safety check
282
340
  checkBudgetSafety();
@@ -292,29 +350,8 @@ export async function callLocalRuntime(prompt, system, config) {
292
350
 
293
351
  let lastError;
294
352
 
295
- // Load dynamic secrets from secrets.enc to ensure changes take effect immediately without server restart
296
- let dynamicSecrets = {};
297
- try {
298
- const pathsToCheck = [
299
- path.join(process.cwd(), '.agent', 'secrets.enc'),
300
- path.join(agentDir, 'secrets.enc'),
301
- path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'config', 'secrets.enc'),
302
- path.join(agentDir, 'skills', 'total-recall', 'config', 'secrets.enc'),
303
- ];
304
- for (const p of pathsToCheck) {
305
- if (fs.existsSync(p)) {
306
- try {
307
- const { loadSecretsSync } = await import('./secrets-store.mjs');
308
- const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
309
- const brainDir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
310
- dynamicSecrets = loadSecretsSync(brainDir);
311
- if (Object.keys(dynamicSecrets).length > 0) {
312
- break;
313
- }
314
- } catch {}
315
- }
316
- }
317
- } catch {}
353
+ // Loaded on every dispatch so a store edit takes effect without a restart.
354
+ const dynamicSecrets = await loadDynamicSecrets();
318
355
 
319
356
  // Try agents in priority order, never re-trying one that already failed.
320
357
  //
@@ -397,8 +434,11 @@ export async function callLocalRuntime(prompt, system, config) {
397
434
  promptLength: fullPrompt.length,
398
435
  });
399
436
 
400
- // Pre-flight command execution validation
401
- validateCommand(cmd);
437
+ // Pre-flight command execution validation. The spawn below passes an argv
438
+ // array (no shell), so a prompt payload is one inert argument — scan only the
439
+ // unquoted part, or any vault fact quoting a `curl … | bash` install line
440
+ // trips a false "piping download tool to shell" exception on every dispatch.
441
+ validateCommand(cmd, { argvSpawn: true });
402
442
 
403
443
  const xaiKey =
404
444
  process.env.XAI_API_KEY ||