@jhizzard/termdeck-stack 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,25 +2,43 @@
2
2
  * TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
3
3
  *
4
4
  * Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
5
- * Wired into ~/.claude/settings.json under hooks.Stop. Fires on Claude Code Stop event.
5
+ * Wired into ~/.claude/settings.json under hooks.SessionEnd fires once per
6
+ * Claude Code session close (`/exit`, Ctrl+D, terminal close, or process kill).
7
+ *
8
+ * History: this hook was originally registered under hooks.Stop, which fires
9
+ * after every assistant turn. That meant the same transcript got embedded and
10
+ * INSERTed dozens of times per session (and most fired with env-var-missing
11
+ * because Claude Code launched outside TermDeck doesn't have SUPABASE_URL in
12
+ * scope). Sprint 48 close-out moved registration to SessionEnd (one row per
13
+ * session, fires deterministically on /exit) AND added the secrets-env
14
+ * fallback below so a standalone-Claude-Code launch picks up the credentials
15
+ * without needing them in the parent shell.
6
16
  *
7
17
  * Behavior:
8
- * 1. Reads {transcript_path, cwd, session_id, sessionType?} from stdin (Claude
9
- * Code Stop payload, or a future server-driven invocation for non-Claude
10
- * agents).
11
- * 2. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
12
- * 3. Validates env vars; logs and exits cleanly if any required key is missing.
13
- * 4. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
18
+ * 1. Reads {transcript_path, cwd, session_id, sessionType?, source_agent?}
19
+ * from stdin (Claude Code SessionEnd payload, or Sprint 50 T1 a
20
+ * server-driven invocation for non-Claude agents). source_agent
21
+ * defaults to 'claude' when absent (Claude Code's existing hook
22
+ * payload doesn't carry it; the TermDeck server's per-adapter
23
+ * onPanelClose interceptor sets it explicitly for codex/gemini/grok).
24
+ * 2. Loads ~/.termdeck/secrets.env into process.env if any required key is
25
+ * absent OR is a literal `${VAR}` placeholder (Sprint 47.5 hotfix
26
+ * discipline — Claude Code does not expand `${VAR}` in MCP env, and we
27
+ * can't trust the parent shell to have sourced secrets.env).
28
+ * 3. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
29
+ * 4. Validates env vars; logs and exits cleanly if any required key is still
30
+ * missing after the secrets.env fallback.
31
+ * 5. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
14
32
  * map by editing the array below — see assets/hooks/README.md for guidance.
15
- * 5. Dispatches to a transcript parser by sessionType (Sprint 45 T4): Claude
33
+ * 6. Dispatches to a transcript parser by sessionType (Sprint 45 T4): Claude
16
34
  * JSONL, Codex JSONL, Gemini single-JSON, or auto-detect when sessionType
17
35
  * is absent. Builds a coarse summary from the resulting message list
18
36
  * (last ~30 message excerpts).
19
- * 6. Embeds the summary via OpenAI text-embedding-3-small.
20
- * 7. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
21
- * 8. Logs every step to ~/.claude/hooks/memory-hook.log.
37
+ * 7. Embeds the summary via OpenAI text-embedding-3-small.
38
+ * 8. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
39
+ * 9. Logs every step to ~/.claude/hooks/memory-hook.log.
22
40
  *
23
- * Required env vars (validated at entry):
41
+ * Required env vars (validated at entry, after the secrets.env fallback):
24
42
  * - SUPABASE_URL e.g. https://<project-ref>.supabase.co
25
43
  * - SUPABASE_SERVICE_ROLE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
26
44
  * - OPENAI_API_KEY sk-... for text-embedding-3-small
@@ -47,6 +65,14 @@ const os = require('os');
47
65
 
48
66
  const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
49
67
 
68
+ // Resolved per-call so tests can override via TERMDECK_HOOK_SECRETS_PATH
69
+ // (the const-at-load-time pattern would freeze the path before any test
70
+ // that mutates HOME or the override env var gets a chance to take effect).
71
+ function resolveSecretsPath() {
72
+ return process.env.TERMDECK_HOOK_SECRETS_PATH
73
+ || join(os.homedir(), '.termdeck', 'secrets.env');
74
+ }
75
+
50
76
  // PROJECT_MAP — minimal default. Users extend by adding entries to this array.
51
77
  // Patterns match against the cwd reported by Claude Code at Stop time.
52
78
  // First match wins; falls through to "global".
@@ -72,9 +98,59 @@ function detectProject(cwd) {
72
98
  return 'global';
73
99
  }
74
100
 
101
+ // Treat values shaped like `${VAR}` as unset. Claude Code does not expand
102
+ // shell placeholders in MCP env or hook env, so a literal `${SUPABASE_URL}`
103
+ // is non-empty-but-invalid — the same trap that caused the Sprint 47.5
104
+ // hotfix on the stack-installer + mnestra MCP. Mirroring that discipline
105
+ // here keeps the hook resilient if any future tooling regresses to the
106
+ // placeholder pattern.
107
+ function isUnexpandedPlaceholder(v) {
108
+ return typeof v === 'string' && v.startsWith('${') && v.endsWith('}');
109
+ }
110
+
111
+ // Load ~/.termdeck/secrets.env into process.env when keys are absent or
112
+ // hold an unexpanded `${VAR}` placeholder. Concrete values already in
113
+ // process.env always win — the fallback only fills gaps. Silent no-op if
114
+ // the file is missing. Mirrors mnestra's loadTermdeckSecretsFallback so
115
+ // the hook works in three launch contexts:
116
+ // 1. Inside TermDeck PTY (Sprint 48 T4 PTY env merge supplies the vars).
117
+ // 2. Standalone Claude Code launched from a shell with secrets.env sourced.
118
+ // 3. Standalone Claude Code launched from a vanilla shell (this fallback).
119
+ function loadTermdeckSecretsFallback() {
120
+ const secretsPath = resolveSecretsPath();
121
+ if (!existsSync(secretsPath)) return;
122
+ let raw;
123
+ try { raw = readFileSync(secretsPath, 'utf8'); }
124
+ catch (err) {
125
+ log(`secrets-env-read-failed: ${err && err.message ? err.message : String(err)}`);
126
+ return;
127
+ }
128
+ let loaded = 0;
129
+ for (const line of raw.split('\n')) {
130
+ const trimmed = line.trim();
131
+ if (!trimmed || trimmed.startsWith('#')) continue;
132
+ const m = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
133
+ if (!m) continue;
134
+ const key = m[1];
135
+ const cur = process.env[key];
136
+ if (cur && !isUnexpandedPlaceholder(cur)) continue;
137
+ let v = m[2];
138
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
139
+ v = v.slice(1, -1);
140
+ }
141
+ process.env[key] = v;
142
+ loaded++;
143
+ }
144
+ if (loaded > 0) debug(`secrets-env-loaded: ${loaded} keys from ${secretsPath}`);
145
+ }
146
+
75
147
  function readEnv() {
148
+ loadTermdeckSecretsFallback();
76
149
  const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
77
- const missing = required.filter((k) => !process.env[k]);
150
+ const missing = required.filter((k) => {
151
+ const v = process.env[k];
152
+ return !v || isUnexpandedPlaceholder(v);
153
+ });
78
154
  if (missing.length) {
79
155
  log(`env-var-missing: ${missing.join(', ')} — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.`);
80
156
  return null;
@@ -195,6 +271,57 @@ function parseGeminiJson(raw) {
195
271
  return messages;
196
272
  }
197
273
 
274
+ // Sprint 50 T1 — Grok parser. Mirrors packages/server/src/agent-adapters/grok.js
275
+ // parseTranscript: accepts either a JSON array or JSONL of `{role, content}`
276
+ // objects, where content is a string OR an array of `{type, text, ...}` parts
277
+ // (AI SDK provider shape). Tool-call / tool-result / reasoning parts are
278
+ // skipped — only the `type:'text'` parts contribute to the summary.
279
+ //
280
+ // The JSON envelope is produced server-side by the Grok adapter's
281
+ // `resolveTranscriptPath` (which extracts from ~/.grok/grok.db SQLite via
282
+ // better-sqlite3 and writes a tempfile). The hook itself never opens grok.db
283
+ // — that would require better-sqlite3 to be reachable from ~/.claude/hooks/,
284
+ // which isn't part of the install contract. The transcript_path the server
285
+ // hands the hook is the tempfile, and the sessionType in the payload is
286
+ // 'grok' so this parser is the one selected.
287
+ function parseGrokJson(raw) {
288
+ if (typeof raw !== 'string' || raw.length === 0) return [];
289
+ let messages = null;
290
+ try {
291
+ const parsed = JSON.parse(raw);
292
+ if (Array.isArray(parsed)) messages = parsed;
293
+ } catch (_) { /* fall through to JSONL */ }
294
+ if (!messages) {
295
+ messages = [];
296
+ for (const line of raw.split('\n')) {
297
+ const trimmed = line.trim();
298
+ if (!trimmed) continue;
299
+ try {
300
+ const obj = JSON.parse(trimmed);
301
+ if (obj && typeof obj === 'object') messages.push(obj);
302
+ } catch (_) { continue; }
303
+ }
304
+ }
305
+ const out = [];
306
+ for (const msg of messages) {
307
+ if (!msg || typeof msg !== 'object') continue;
308
+ const role = msg.role;
309
+ if (role !== 'user' && role !== 'assistant') continue;
310
+ const content = msg.content;
311
+ let text = '';
312
+ if (typeof content === 'string') {
313
+ text = content;
314
+ } else if (Array.isArray(content)) {
315
+ text = content
316
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
317
+ .map((c) => c.text)
318
+ .join(' ');
319
+ }
320
+ if (text) out.push({ role, content: text.slice(0, 400) });
321
+ }
322
+ return out;
323
+ }
324
+
198
325
  function parseAutoDetect(raw) {
199
326
  // Fallback when sessionType is absent. Tries Gemini's single-JSON shape
200
327
  // first (cheap to detect — starts with `{` and has a top-level `messages`
@@ -254,10 +381,10 @@ const TRANSCRIPT_PARSERS = {
254
381
  'claude-code': parseClaudeJsonl,
255
382
  'codex': parseCodexJsonl,
256
383
  'gemini': parseGeminiJson,
257
- // Sprint 45 T3 — grok parser entry goes here once the adapter lands.
258
- // Source-of-truth lives in packages/server/src/agent-adapters/grok.js;
259
- // mirror that adapter's parseTranscript function body into this dispatch
260
- // table at sprint close so the bundled hook can ingest grok transcripts.
384
+ // Sprint 50 T1 — grok parser. Server-side `resolveTranscriptPath` extracts
385
+ // ~/.grok/grok.db rows via better-sqlite3 and writes a JSON envelope to a
386
+ // tempfile; the hook reads that tempfile with parseGrokJson here.
387
+ 'grok': parseGrokJson,
261
388
  };
262
389
  const DEFAULT_SESSION_TYPE = 'auto';
263
390
 
@@ -317,7 +444,25 @@ async function embedText(text, openaiKey) {
317
444
  }
318
445
  }
319
446
 
320
- async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId }) {
447
+ // Sprint 50 T2: every row written by this hook carries an LLM-provenance
448
+ // tag (memory_items.source_agent). Defaults to 'claude' for backwards
449
+ // compat with Claude Code's existing SessionEnd payload, which doesn't
450
+ // supply the field; TermDeck server's per-adapter onPanelClose
451
+ // interceptor (Sprint 50 T1) sets it explicitly to 'codex'/'gemini'/'grok'
452
+ // for non-Claude panels. The set is open-ended on the server side; this
453
+ // constant gates only the spelling-mistake/empty-string case.
454
+ const ALLOWED_SOURCE_AGENTS = new Set([
455
+ 'claude', 'codex', 'gemini', 'grok', 'orchestrator',
456
+ ]);
457
+
458
+ function normalizeSourceAgent(raw) {
459
+ if (typeof raw !== 'string') return 'claude';
460
+ const v = raw.trim().toLowerCase();
461
+ if (!v) return 'claude';
462
+ return ALLOWED_SOURCE_AGENTS.has(v) ? v : 'claude';
463
+ }
464
+
465
+ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId, sourceAgent }) {
321
466
  try {
322
467
  const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
323
468
  method: 'POST',
@@ -334,6 +479,7 @@ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, pr
334
479
  category: 'workflow',
335
480
  project,
336
481
  source_session_id: sessionId || null,
482
+ source_agent: normalizeSourceAgent(sourceAgent),
337
483
  }),
338
484
  });
339
485
  if (!res.ok) {
@@ -369,6 +515,17 @@ async function processStdinPayload(input) {
369
515
  process.env.TERMDECK_SESSION_TYPE ||
370
516
  DEFAULT_SESSION_TYPE;
371
517
 
518
+ // Sprint 50 T2: provenance tag the row with the LLM that produced it.
519
+ // Default 'claude' — Claude Code's native SessionEnd payload doesn't
520
+ // carry source_agent, so any unset path is implicitly Claude. The
521
+ // TermDeck server's per-adapter onPanelClose interceptor (Sprint 50 T1)
522
+ // sets it explicitly for non-Claude panels.
523
+ const sourceAgent =
524
+ data.source_agent ||
525
+ data.sourceAgent ||
526
+ process.env.TERMDECK_SOURCE_AGENT ||
527
+ 'claude';
528
+
372
529
  if (!transcriptPath) { log('no-transcript-path: skipping'); return; }
373
530
 
374
531
  let stat;
@@ -399,9 +556,10 @@ async function processStdinPayload(input) {
399
556
  embedding,
400
557
  project,
401
558
  sessionId,
559
+ sourceAgent,
402
560
  });
403
561
 
404
- if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} sessionType=${sessionType}`);
562
+ if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)}`);
405
563
  }
406
564
 
407
565
  // Module-export contract for testability. When run as a script (require.main === module),
@@ -429,7 +587,11 @@ if (require.main === module) {
429
587
  parseClaudeJsonl,
430
588
  parseCodexJsonl,
431
589
  parseGeminiJson,
590
+ parseGrokJson,
432
591
  parseAutoDetect,
433
592
  selectTranscriptParser,
593
+ // Sprint 50 T2 — source_agent provenance plumbing.
594
+ normalizeSourceAgent,
595
+ ALLOWED_SOURCE_AGENTS,
434
596
  };
435
597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"
package/src/index.js CHANGED
@@ -436,33 +436,60 @@ function _isSessionEndHookEntry(entry) {
436
436
  && entry.command.includes('memory-session-end.js');
437
437
  }
438
438
 
439
- // Pure: merges our Stop entry into the given settings object. Idempotent.
440
- // Returns { settings, status } where status is 'already-installed' or
441
- // 'installed'. Mutates the input.
439
+ // Pure: merges our SessionEnd entry into the given settings object. Idempotent.
440
+ // Returns { settings, status } where status is 'already-installed', 'installed',
441
+ // or 'migrated-from-stop' (when an old `Stop` entry pointing at our hook is
442
+ // detected and moved over to `SessionEnd`). Mutates the input.
443
+ //
444
+ // Why SessionEnd, not Stop: the `Stop` event fires after every assistant turn,
445
+ // so a Stop-registered session-summary hook embeds + INSERTs the same growing
446
+ // transcript dozens of times per session. The `SessionEnd` event fires once
447
+ // per Claude Code session close (`/exit`, Ctrl+D, terminal close, kill) — the
448
+ // correct semantics for "summarize this session." Sprint 48 close-out moved
449
+ // the registration; the migration branch below heals existing installs from
450
+ // `@jhizzard/termdeck-stack@<=0.5.0` that wired the hook under `Stop`.
442
451
  function _mergeSessionEndHookEntry(settings, opts = {}) {
443
452
  const command = opts.command || HOOK_COMMAND;
444
453
  const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
454
+ const entry = { type: 'command', command, timeout };
445
455
 
446
456
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
447
- if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
448
457
 
449
- for (const group of settings.hooks.Stop) {
458
+ // Migrate any pre-Sprint-48 Stop registration of OUR hook to SessionEnd.
459
+ // We only touch entries that match `_isSessionEndHookEntry` — any unrelated
460
+ // Stop hooks the user has are preserved verbatim.
461
+ let migrated = false;
462
+ if (Array.isArray(settings.hooks.Stop)) {
463
+ for (const group of settings.hooks.Stop) {
464
+ if (!group || !Array.isArray(group.hooks)) continue;
465
+ const before = group.hooks.length;
466
+ group.hooks = group.hooks.filter((e) => !_isSessionEndHookEntry(e));
467
+ if (group.hooks.length !== before) migrated = true;
468
+ }
469
+ settings.hooks.Stop = settings.hooks.Stop.filter(
470
+ (g) => g && Array.isArray(g.hooks) && g.hooks.length > 0
471
+ );
472
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
473
+ }
474
+
475
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
476
+
477
+ for (const group of settings.hooks.SessionEnd) {
450
478
  if (!group || !Array.isArray(group.hooks)) continue;
451
479
  if (group.hooks.some(_isSessionEndHookEntry)) {
452
- return { settings, status: 'already-installed' };
480
+ return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
453
481
  }
454
482
  }
455
483
 
456
- const entry = { type: 'command', command, timeout };
457
- const emptyMatcher = settings.hooks.Stop.find(
484
+ const emptyMatcher = settings.hooks.SessionEnd.find(
458
485
  (g) => g && g.matcher === '' && Array.isArray(g.hooks)
459
486
  );
460
487
  if (emptyMatcher) {
461
488
  emptyMatcher.hooks.push(entry);
462
489
  } else {
463
- settings.hooks.Stop.push({ matcher: '', hooks: [entry] });
490
+ settings.hooks.SessionEnd.push({ matcher: '', hooks: [entry] });
464
491
  }
465
- return { settings, status: 'installed' };
492
+ return { settings, status: migrated ? 'migrated-from-stop' : 'installed' };
466
493
  }
467
494
 
468
495
  function _readSettingsJson(filePath) {
@@ -583,14 +610,23 @@ async function installSessionEndHook(opts = {}) {
583
610
  } else {
584
611
  const merged = _mergeSessionEndHookEntry(read.settings);
585
612
  if (merged.status === 'already-installed') {
586
- statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json Stop hook', 'already installed');
613
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json SessionEnd hook', 'already installed');
587
614
  settingsStatus = 'already-installed';
615
+ } else if (merged.status === 'migrated-from-stop') {
616
+ if (dryRun) {
617
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would migrate Stop hook → SessionEnd in ${settingsPath}`);
618
+ settingsStatus = 'would-migrate';
619
+ } else {
620
+ _writeSettingsJson(settingsPath, merged.settings);
621
+ statusLine(`${ANSI.green}↻${ANSI.reset}`, 'settings.json SessionEnd hook', 'migrated from Stop (was firing on every turn)');
622
+ settingsStatus = 'migrated';
623
+ }
588
624
  } else if (dryRun) {
589
- statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge Stop hook into ${settingsPath}`);
625
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge SessionEnd hook into ${settingsPath}`);
590
626
  settingsStatus = 'would-install';
591
627
  } else {
592
628
  _writeSettingsJson(settingsPath, merged.settings);
593
- statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json Stop hook', 'merged');
629
+ statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json SessionEnd hook', 'merged');
594
630
  settingsStatus = 'installed';
595
631
  }
596
632
  }
package/src/launcher.js CHANGED
@@ -154,6 +154,87 @@ function ensureFirstRunConfig(_fs = fs) {
154
154
  return true;
155
155
  }
156
156
 
157
+ // Sprint 49 T4 — walk up from the resolved `termdeck` binary to find the
158
+ // installed `@jhizzard/termdeck` package root and require its agent-adapter
159
+ // registry + mcp-autowire helper. The installer is a zero-dep package (no
160
+ // `dependencies` field in package.json) so we cannot require these by package
161
+ // name; instead we resolve by absolute path. Works for both global installs
162
+ // (`/usr/local/lib/node_modules/@jhizzard/termdeck/...`) and dev checkouts
163
+ // where the binary symlinks back into the source tree. Returns null when the
164
+ // adapter tree isn't reachable — caller skips auto-wire with a warning.
165
+ function loadTermdeckExports(termdeckBinary, _fs = fs) {
166
+ if (!termdeckBinary) return null;
167
+ let realPath;
168
+ try { realPath = _fs.realpathSync(termdeckBinary); }
169
+ catch (_) { return null; }
170
+ let dir = path.dirname(realPath);
171
+ for (let i = 0; i < 10; i++) {
172
+ const pkgPath = path.join(dir, 'package.json');
173
+ if (_fs.existsSync(pkgPath)) {
174
+ let pkg;
175
+ try { pkg = JSON.parse(_fs.readFileSync(pkgPath, 'utf8')); }
176
+ catch (_) { pkg = null; }
177
+ if (pkg && pkg.name === '@jhizzard/termdeck') {
178
+ const adaptersPath = path.join(dir, 'packages/server/src/agent-adapters');
179
+ const autowirePath = path.join(dir, 'packages/server/src/mcp-autowire.js');
180
+ if (_fs.existsSync(adaptersPath) && _fs.existsSync(autowirePath)) {
181
+ try {
182
+ const adaptersMod = require(adaptersPath);
183
+ const autowireMod = require(autowirePath);
184
+ return {
185
+ adapters: adaptersMod.AGENT_ADAPTERS,
186
+ ensureMnestraBlock: autowireMod.ensureMnestraBlock,
187
+ packageRoot: dir,
188
+ };
189
+ } catch (_) { return null; }
190
+ }
191
+ return null;
192
+ }
193
+ }
194
+ const parent = path.dirname(dir);
195
+ if (parent === dir) break;
196
+ dir = parent;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // Sprint 49 T4 — iterates an adapter registry (record or array) and calls
202
+ // `ensureMnestraBlock(adapter, opts)` for each adapter that declares a non-null
203
+ // `mcpConfig`. Adapters with `mcpConfig: null` (Claude — user-managed via
204
+ // `claude mcp add`) are skipped without invoking the helper. Helper exceptions
205
+ // on a single adapter don't abort the loop — they're captured under
206
+ // `errored[]` so the launcher can continue with the remaining adapters and
207
+ // surface diagnostics in the step line.
208
+ //
209
+ // Returns { wired: string[], unchanged: string[], skipped: string[],
210
+ // errored: { name, error }[] } — caller renders a one-line summary.
211
+ // Idempotent: a second call against an already-wired environment shifts every
212
+ // adapter from `wired` to `unchanged` because the helper's per-shape
213
+ // detect-existing branches return `{ unchanged: true }` on no-op writes.
214
+ function autowireMcp(adapters, ensureMnestraBlockFn, opts = {}) {
215
+ const summary = { wired: [], unchanged: [], skipped: [], errored: [] };
216
+ if (!adapters || typeof ensureMnestraBlockFn !== 'function') return summary;
217
+ const list = Array.isArray(adapters) ? adapters : Object.values(adapters);
218
+ for (const adapter of list) {
219
+ const name = (adapter && adapter.name) || '<unknown>';
220
+ if (!adapter || !adapter.mcpConfig) {
221
+ summary.skipped.push(name);
222
+ continue;
223
+ }
224
+ let result;
225
+ try {
226
+ result = ensureMnestraBlockFn(adapter, opts);
227
+ } catch (err) {
228
+ summary.errored.push({ name, error: err && err.message ? err.message : String(err) });
229
+ continue;
230
+ }
231
+ if (result && result.wrote) summary.wired.push(name);
232
+ else if (result && result.unchanged) summary.unchanged.push(name);
233
+ else summary.skipped.push(name);
234
+ }
235
+ return summary;
236
+ }
237
+
157
238
  function spawnDetached(command, args, logPath, env, _spawn = child_process.spawn, _fs = fs) {
158
239
  // open() the log file then pass the fd to spawn so the child inherits a
159
240
  // real disk-backed stdout/stderr. Close our handle after spawn so we
@@ -197,9 +278,9 @@ async function startStack(opts = {}) {
197
278
  const secrets = readSecrets(SECRETS_PATH, _fs);
198
279
  const secretCount = Object.keys(secrets).length;
199
280
  if (secretCount === 0) {
200
- step('1/3', 'Loading secrets', 'WARN', `(no readable keys in ${SECRETS_PATH} — run \`npx @jhizzard/termdeck-stack\` to set up)`);
281
+ step('1/4', 'Loading secrets', 'WARN', `(no readable keys in ${SECRETS_PATH} — run \`npx @jhizzard/termdeck-stack\` to set up)`);
201
282
  } else {
202
- step('1/3', 'Loading secrets', 'OK', `(${secretCount} keys from ${SECRETS_PATH})`);
283
+ step('1/4', 'Loading secrets', 'OK', `(${secretCount} keys from ${SECRETS_PATH})`);
203
284
  }
204
285
 
205
286
  // Resolve binaries.
@@ -210,32 +291,62 @@ async function startStack(opts = {}) {
210
291
  }
211
292
  const mnestraInvocation = resolveMnestraInvocation({ ..._deps, fs: _fs, whichBinary: _deps.whichBinary });
212
293
 
213
- // Step 2: mnestra
294
+ // Step 2: auto-wire MCP for non-Claude adapters (Sprint 49 T4)
295
+ let autowireSummary = null;
296
+ if (opts.noWire) {
297
+ step('2/4', 'Auto-wiring MCP', 'SKIP', '(--no-wire)');
298
+ } else {
299
+ const termdeckExports = (_deps.termdeckExports !== undefined)
300
+ ? _deps.termdeckExports
301
+ : loadTermdeckExports(termdeckBinary, _fs);
302
+ if (!termdeckExports) {
303
+ step('2/4', 'Auto-wiring MCP', 'WARN', '(@jhizzard/termdeck adapter tree not resolvable — skipping)');
304
+ } else {
305
+ autowireSummary = autowireMcp(
306
+ termdeckExports.adapters,
307
+ termdeckExports.ensureMnestraBlock,
308
+ { secrets },
309
+ );
310
+ const parts = [];
311
+ for (const name of autowireSummary.wired) parts.push(`${name} (wrote)`);
312
+ for (const name of autowireSummary.unchanged) parts.push(`${name} (unchanged)`);
313
+ for (const e of autowireSummary.errored) parts.push(`${e.name} (error: ${e.error})`);
314
+ if (autowireSummary.wired.length === 0 && autowireSummary.unchanged.length === 0 && autowireSummary.errored.length === 0) {
315
+ step('2/4', 'Auto-wiring MCP', 'SKIP', '(no adapters declare mcpConfig)');
316
+ } else if (autowireSummary.errored.length > 0 && autowireSummary.wired.length === 0 && autowireSummary.unchanged.length === 0) {
317
+ step('2/4', 'Auto-wiring MCP', 'FAIL', parts.join(', '));
318
+ } else {
319
+ step('2/4', 'Auto-wiring MCP', 'OK', parts.join(', '));
320
+ }
321
+ }
322
+ }
323
+
324
+ // Step 3: mnestra
214
325
  const childEnv = { ...process.env, ...secrets };
215
326
  let mnestraPid = null;
216
327
  if (!mnestraInvocation) {
217
- step('2/3', 'Starting Mnestra', 'SKIP', '(not installed — npm i -g @jhizzard/mnestra)');
328
+ step('3/4', 'Starting Mnestra', 'SKIP', '(not installed — npm i -g @jhizzard/mnestra)');
218
329
  } else if (!secrets.SUPABASE_URL || !secrets.SUPABASE_SERVICE_ROLE_KEY) {
219
- step('2/3', 'Starting Mnestra', 'WARN', '(SUPABASE_URL / SERVICE_ROLE_KEY missing — run wizard)');
330
+ step('3/4', 'Starting Mnestra', 'WARN', '(SUPABASE_URL / SERVICE_ROLE_KEY missing — run wizard)');
220
331
  } else {
221
332
  const child = spawnDetached(mnestraInvocation.command, mnestraInvocation.args, MNESTRA_LOG_PATH, childEnv, _spawn, _fs);
222
333
  mnestraPid = child.pid;
223
334
  const health = await waitForHealth(`http://127.0.0.1:${mnestraPort}/healthz`, HEALTH_RETRIES, _fetch);
224
335
  if (health.ok) {
225
336
  const rows = (health.body && health.body.store && health.body.store.rows) || 0;
226
- step('2/3', 'Starting Mnestra', 'OK', `(:${mnestraPort}, ${rows} memories)`);
337
+ step('3/4', 'Starting Mnestra', 'OK', `(:${mnestraPort}, ${rows} memories)`);
227
338
  } else {
228
- step('2/3', 'Starting Mnestra', 'FAIL', `(no /healthz response — see ${MNESTRA_LOG_PATH})`);
339
+ step('3/4', 'Starting Mnestra', 'FAIL', `(no /healthz response — see ${MNESTRA_LOG_PATH})`);
229
340
  }
230
341
  }
231
342
 
232
- // Step 3: termdeck
343
+ // Step 4: termdeck
233
344
  const termdeckChild = spawnDetached(termdeckBinary, ['--port', String(port), '--no-stack'], TERMDECK_LOG_PATH, childEnv, _spawn, _fs);
234
345
  const termdeckHealth = await waitForHealth(`http://127.0.0.1:${port}/api/health`, HEALTH_RETRIES, _fetch);
235
346
  if (termdeckHealth.ok) {
236
- step('3/3', 'Starting TermDeck', 'OK', `(:${port})`);
347
+ step('4/4', 'Starting TermDeck', 'OK', `(:${port})`);
237
348
  } else {
238
- step('3/3', 'Starting TermDeck', 'FAIL', `(no /api/health response — see ${TERMDECK_LOG_PATH})`);
349
+ step('4/4', 'Starting TermDeck', 'FAIL', `(no /api/health response — see ${TERMDECK_LOG_PATH})`);
239
350
  }
240
351
 
241
352
  const pidRecord = {
@@ -344,6 +455,8 @@ module.exports = {
344
455
  _ensureFirstRunConfig: ensureFirstRunConfig,
345
456
  _probeHealth: probeHealth,
346
457
  _spawnDetached: spawnDetached,
458
+ _autowireMcp: autowireMcp,
459
+ _loadTermdeckExports: loadTermdeckExports,
347
460
  PID_PATH,
348
461
  SECRETS_PATH,
349
462
  CONFIG_PATH,