@jhizzard/termdeck 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -11,6 +11,7 @@
11
11
  "packages/cli/templates/**",
12
12
  "packages/server/src/**",
13
13
  "packages/client/public/**",
14
+ "packages/stack-installer/assets/hooks/**",
14
15
  "config/config.example.yaml",
15
16
  "config/secrets.env.example",
16
17
  "config/transcript-migration.sql",
@@ -10,7 +10,9 @@
10
10
  // existing values. Done BEFORE any database work so a later pg connect
11
11
  // or migration failure doesn't lose the user's typed-in keys.
12
12
  // 3. Connect via `pg` using the direct URL
13
- // 4. Apply the six bundled Mnestra migrations in order
13
+ // 4. Apply all bundled Mnestra migrations in order (currently 17 — count
14
+ // grows over time; audit-upgrade probes for any not yet applied and
15
+ // runs them idempotently against existing installs)
14
16
  // 5. Update ~/.termdeck/config.yaml — set rag.enabled: false (MCP-only
15
17
  // default; opt into TermDeck-side RAG via dashboard toggle) and point
16
18
  // at ${VAR} refs (only after migrations apply cleanly — otherwise the
@@ -75,7 +77,7 @@ const HELP = [
75
77
  ' saved values if a complete set already exists in secrets.env.',
76
78
  ' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
77
79
  ' pg connect or migration failure does not lose what you typed in.',
78
- ' 3. Connects to Postgres and applies the six Mnestra schema + RPC migrations.',
80
+ ' 3. Connects to Postgres and applies all bundled Mnestra schema + RPC migrations.',
79
81
  ' 4. Updates ~/.termdeck/config.yaml — sets rag.enabled: false (MCP-only',
80
82
  ' default) and references ${VAR} keys for credentials.',
81
83
  ' 5. Verifies the Mnestra store is reachable via memory_status_aggregation().',
@@ -182,7 +184,8 @@ This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
182
184
  4. Asking for an Anthropic API key (optional, summaries)
183
185
  5. Writing ~/.termdeck/secrets.env (before any database work, so a
184
186
  pg failure cannot lose what you typed in)
185
- 6. Connecting to Postgres + applying six SQL migrations
187
+ 6. Connecting to Postgres + applying all bundled SQL migrations
188
+ (audit-upgrade detects + applies any missing on existing installs)
186
189
  7. Updating ~/.termdeck/config.yaml — rag.enabled: false (MCP-only
187
190
  default; toggle in dashboard later) with \${VAR} refs (only after
188
191
  migrations apply cleanly)
@@ -481,6 +484,156 @@ function writeYamlConfig(dryRun) {
481
484
  else ok();
482
485
  }
483
486
 
487
+ // Sprint 51.6 T3 — hook upgrade gap fix.
488
+ //
489
+ // Codex's Sprint 51.6 GAP at 20:11 ET surfaced this: the bundled session-end
490
+ // hook ships in `packages/stack-installer/assets/hooks/memory-session-end.js`,
491
+ // and `npm install -g @jhizzard/termdeck@latest` lands the new bundled file
492
+ // in node_modules — but `termdeck init --mnestra` never touched
493
+ // ~/.claude/hooks/memory-session-end.js. The user's daily-driver kept
494
+ // running the OLD installed copy forever. v1.0.2 closes that gap by adding
495
+ // this refresh step to init --mnestra. The version stamp in the bundled
496
+ // hook (// @termdeck/stack-installer-hook v<N>) gates the overwrite — only
497
+ // strictly-newer bundled stamps trigger a refresh, so a hand-edited
498
+ // installed file with v=current stays put.
499
+ //
500
+ // Backup is best-effort timestamped: `<dest>.bak.<YYYYMMDDhhmmss>`. Matches
501
+ // the pattern Joshua already had on disk from earlier stack-installer runs.
502
+ function refreshBundledHookIfNewer(opts = {}) {
503
+ const dryRun = !!opts.dryRun;
504
+ const HOME = require('os').homedir();
505
+ const HOOK_DEST = opts.destPath || path.join(HOME, '.claude', 'hooks', 'memory-session-end.js');
506
+ // Sprint 51.6 T4-CODEX audit 20:28 ET fix: bundled hook source must be on
507
+ // a path that ships in @jhizzard/termdeck's npm tarball. Root package.json
508
+ // includes `packages/stack-installer/assets/hooks/**` (added 51.6 T3) so
509
+ // this path resolves both in the monorepo and in the published tarball.
510
+ const HOOK_SOURCE = opts.sourcePath
511
+ || path.join(__dirname, '..', '..', 'stack-installer', 'assets', 'hooks', 'memory-session-end.js');
512
+ const SIG_RE = /@termdeck\/stack-installer-hook\s+v(\d+)/;
513
+ const TERMDECK_MARKERS = [
514
+ /TermDeck session-end memory hook/,
515
+ /@jhizzard\/termdeck-stack/,
516
+ /Vendored into ~\/\.claude\/hooks\/memory-session-end\.js by @jhizzard/i,
517
+ ];
518
+
519
+ function readHead(p) {
520
+ try { return fs.readFileSync(p, 'utf8').slice(0, 4096); }
521
+ catch (_) { return null; }
522
+ }
523
+ function readVersion(p) {
524
+ const head = readHead(p);
525
+ if (!head) return null;
526
+ const m = head.match(SIG_RE);
527
+ return m ? parseInt(m[1], 10) : null;
528
+ }
529
+ function looksTermdeckManaged(p) {
530
+ const head = readHead(p);
531
+ if (!head) return false;
532
+ return TERMDECK_MARKERS.some((m) => m.test(head));
533
+ }
534
+
535
+ if (!fs.existsSync(HOOK_SOURCE)) {
536
+ return { status: 'no-bundled', message: 'bundled hook source not found' };
537
+ }
538
+ const bundled = readVersion(HOOK_SOURCE);
539
+ if (bundled === null) {
540
+ return { status: 'bundled-unsigned', message: 'bundled hook missing version stamp; skipping refresh' };
541
+ }
542
+ if (!fs.existsSync(HOOK_DEST)) {
543
+ if (dryRun) return { status: 'would-install', bundled };
544
+ fs.mkdirSync(path.dirname(HOOK_DEST), { recursive: true });
545
+ fs.copyFileSync(HOOK_SOURCE, HOOK_DEST);
546
+ fs.chmodSync(HOOK_DEST, 0o644);
547
+ return { status: 'installed', bundled };
548
+ }
549
+ const installed = readVersion(HOOK_DEST);
550
+ if (installed !== null && installed >= bundled) {
551
+ return { status: 'up-to-date', installed, bundled };
552
+ }
553
+ // Sprint 51.6 T4-CODEX audit 20:23 ET safety gate: an unsigned installed
554
+ // hook gets refreshed ONLY if it looks TermDeck-managed (carries one of
555
+ // the docstring markers from a prior bundled cut). A genuinely custom
556
+ // user hook with no TermDeck fingerprint stays put.
557
+ if (installed === null && !looksTermdeckManaged(HOOK_DEST)) {
558
+ return {
559
+ status: 'custom-hook-preserved',
560
+ message: 'installed hook lacks TermDeck-managed markers; keeping as-is. Re-run with --force-overwrite to bypass.',
561
+ bundled,
562
+ };
563
+ }
564
+ if (dryRun) return { status: 'would-refresh', from: installed, to: bundled };
565
+ const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
566
+ const backup = `${HOOK_DEST}.bak.${stamp}`;
567
+ try { fs.copyFileSync(HOOK_DEST, backup); } catch (_) { /* best-effort */ }
568
+ fs.copyFileSync(HOOK_SOURCE, HOOK_DEST);
569
+ fs.chmodSync(HOOK_DEST, 0o644);
570
+ return { status: 'refreshed', from: installed, to: bundled, backup };
571
+ }
572
+
573
+ // Sprint 51.7 T1 — wizard wire-up bug fix.
574
+ //
575
+ // Moved upstream of `pgRunner.connect` and the migration-replay loop so
576
+ // DB-side failures (Class A schema drift, network blips, partial state)
577
+ // cannot strand the hook upgrade. Joshua's 2026-05-03 Phase B run threw at
578
+ // `applyMigrations()` on `001_mnestra_tables.sql` (the `match_memories`
579
+ // CREATE OR REPLACE return-type drift on petvetbid — existing function had
580
+ // columns in a different order, Postgres rejected with "cannot change return
581
+ // type of existing function"). Outer catch at the old call site fired and
582
+ // returned exit 5; the refresh at the old wire-up never ran. Brad's
583
+ // jizzard-brain reproduced the same symptom under v1.0.2.
584
+ //
585
+ // Hook refresh is a LOCAL filesystem operation. It has no dependency on DB
586
+ // success, so it should run as part of the initial local-setup phase next
587
+ // to `writeSecretsFile`, not buried after a 17-migration replay. This also
588
+ // means the wizard ALWAYS lands the bundled hook on disk after a successful
589
+ // `npm install -g @jhizzard/termdeck@latest && termdeck init --mnestra`,
590
+ // even when the DB phase fails — a meaningful upgrade-path improvement
591
+ // because the hook fix is independently valuable.
592
+ //
593
+ // `--dry-run` exercises this path with `dryRun: true` so the wizard
594
+ // truthfully reports what WOULD happen on a live run (Sprint 51.6 Phase B
595
+ // dry-run probe couldn't catch the wire-up bug because dry-run early-
596
+ // returned BEFORE the old refresh location at line 677).
597
+ //
598
+ // Stderr instrumentation is gated behind `TERMDECK_DEBUG_WIREUP=1` so
599
+ // production users never see noise; the gate is broadly useful for future
600
+ // wire-up bisects (any developer can re-run the wizard with the env var
601
+ // set and get a deterministic trace).
602
+ function runHookRefresh({ dryRun = false } = {}) {
603
+ const debug = !!process.env.TERMDECK_DEBUG_WIREUP;
604
+ step('Refreshing ~/.claude/hooks/memory-session-end.js (if bundled is newer)...');
605
+ if (debug) {
606
+ const HOME = require('os').homedir();
607
+ const HOOK_DEST = path.join(HOME, '.claude', 'hooks', 'memory-session-end.js');
608
+ const HOOK_SOURCE = path.join(__dirname, '..', '..', 'stack-installer', 'assets', 'hooks', 'memory-session-end.js');
609
+ process.stderr.write(`[wire-up-debug] runHookRefresh entry: dryRun=${dryRun} HOOK_DEST=${HOOK_DEST} HOOK_SOURCE=${HOOK_SOURCE} HOOK_SOURCE_exists=${fs.existsSync(HOOK_SOURCE)} HOOK_DEST_exists=${fs.existsSync(HOOK_DEST)}\n`);
610
+ }
611
+ try {
612
+ const r = refreshBundledHookIfNewer({ dryRun });
613
+ if (debug) process.stderr.write(`[wire-up-debug] runHookRefresh return: ${JSON.stringify(r)}\n`);
614
+ if (r.status === 'refreshed') {
615
+ ok(`refreshed v${r.from ?? 0} → v${r.to} (backup: ${path.basename(r.backup)})`);
616
+ } else if (r.status === 'would-refresh') {
617
+ ok(`would-refresh v${r.from ?? 0} → v${r.to} (dry-run)`);
618
+ } else if (r.status === 'installed') {
619
+ ok(`installed v${r.bundled} (no prior copy)`);
620
+ } else if (r.status === 'would-install') {
621
+ ok(`would-install v${r.bundled} (dry-run, no prior copy)`);
622
+ } else if (r.status === 'up-to-date') {
623
+ ok(`up-to-date (v${r.installed})`);
624
+ } else {
625
+ ok(`(${r.status}${r.message ? ': ' + r.message : ''})`);
626
+ }
627
+ } catch (err) {
628
+ // Don't abort init for a hook-refresh failure — log + continue. The
629
+ // user's wizard goal (DB setup) is independent of hook refresh; even
630
+ // if refresh fails (e.g. permission denied, FS error), the wizard
631
+ // should continue to do the DB work.
632
+ process.stdout.write(` ! hook refresh failed: ${err.message} (continuing)\n`);
633
+ if (debug) process.stderr.write(`[wire-up-debug] runHookRefresh threw: ${err && err.stack || err}\n`);
634
+ }
635
+ }
636
+
484
637
  function printNextSteps() {
485
638
  process.stdout.write(`
486
639
  Mnestra is configured.
@@ -551,6 +704,17 @@ async function main(argv) {
551
704
  return 6;
552
705
  }
553
706
 
707
+ // Sprint 51.7 T1 — refresh ~/.claude/hooks/memory-session-end.js BEFORE the
708
+ // DB phase. Hook refresh is local FS work; coupling it downstream of pg
709
+ // connect + 17-migration replay (the old wire-up at line 677 in v1.0.2)
710
+ // meant ANY DB-side error (Joshua's mig-001 `match_memories` return-type
711
+ // drift, Brad's same on jizzard-brain) silently skipped the upgrade. With
712
+ // refresh here, the user always lands the bundled hook even when the DB
713
+ // phase later fails — decoupled concerns, idempotent re-runs, and the
714
+ // helper handles its own try/catch internally so a refresh failure never
715
+ // strands the wizard.
716
+ runHookRefresh({ dryRun: flags.dryRun });
717
+
554
718
  step('Connecting to Supabase...');
555
719
  if (flags.dryRun) {
556
720
  ok('(dry-run, skipped)');
@@ -578,6 +742,12 @@ async function main(argv) {
578
742
  await applyMigrations(client, false);
579
743
  await runMnestraAudit(client, inputs.projectUrl.projectRef, false);
580
744
  writeYamlConfig(false);
745
+ // Sprint 51.7 T1: hook refresh moved upstream — see runHookRefresh()
746
+ // call site near writeSecretsFile. The old wire-up here was reachable
747
+ // only when every DB step succeeded, which Sprint 51.6 Phase B proved
748
+ // was the bug (mig-001 `match_memories` return-type drift threw and
749
+ // stranded the upgrade for both Joshua and Brad).
750
+
581
751
  // v0.6.9: post-write outcome verification. Confirms each migration's
582
752
  // expected schema bits actually landed — including memory_items.
583
753
  // source_session_id (the v0.6.5 column whose absence cascaded into
@@ -623,3 +793,5 @@ if (require.main === module) {
623
793
  }
624
794
 
625
795
  module.exports = main;
796
+ // Sprint 51.6 T3 — exported for tests/init-mnestra-hook-refresh.test.js.
797
+ module.exports.refreshBundledHookIfNewer = refreshBundledHookIfNewer;
@@ -337,14 +337,21 @@ async function runRumenAudit(projectRef, secrets, dryRun) {
337
337
  pgClient: client,
338
338
  projectRef
339
339
  });
340
- if (result.applied.length === 0 && result.errors.length === 0) {
340
+ if (result.applied.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
341
341
  ok(`(install up to date — ${result.probed.length} probes all present)`);
342
342
  return true;
343
343
  }
344
- ok(`(probed ${result.probed.length}, applied ${result.applied.length})`);
344
+ ok(`(probed ${result.probed.length}, applied ${result.applied.length}, skipped ${result.skipped.length})`);
345
345
  for (const name of result.applied) {
346
346
  process.stdout.write(` ✓ applied ${name}\n`);
347
347
  }
348
+ // Sprint 51.6 T3 — Bug D: surface skipped[] entries (functionSource
349
+ // probes that detected drift but can't auto-redeploy from audit). The
350
+ // wizard will redeploy below in the deployFunctions step, which fixes
351
+ // the drift; this print just makes the diagnosis visible.
352
+ for (const s of result.skipped) {
353
+ process.stdout.write(` ⊘ skipped ${s.name}: ${s.reason}\n`);
354
+ }
348
355
  for (const e of result.errors) {
349
356
  process.stdout.write(` ! ${e.name}: ${e.error}\n`);
350
357
  }
@@ -363,7 +370,20 @@ async function runRumenAudit(projectRef, secrets, dryRun) {
363
370
  // copied verbatim. If a future function adds a placeholder, list it here.
364
371
  const FUNCTIONS_WITH_VERSION_PLACEHOLDER = new Set(['rumen-tick']);
365
372
 
366
- function deployFunctions(rumenVersion, dryRun) {
373
+ // Sprint 51.6 T3 — `projectRef` is required and passed explicitly to every
374
+ // `supabase functions deploy` invocation as `--project-ref <ref>`. Brad's
375
+ // 2026-05-03 jizzard-brain install hit Bug C: `supabase link --project-ref`
376
+ // runs successfully (audit-upgrade probes confirm the link is live), but a
377
+ // few subprocess calls later `supabase functions deploy` errors with
378
+ // `Cannot find project ref. Have you run supabase link?` because the link
379
+ // state persists per-cwd in supabase/config.toml and the staged-functions
380
+ // directory has none. Threading --project-ref through dodges link-state
381
+ // coupling entirely. The flag is supported by supabase CLI v1.x and v2.x.
382
+ function deployFunctions(rumenVersion, projectRef, dryRun) {
383
+ if (!projectRef || typeof projectRef !== 'string') {
384
+ fail('deployFunctions: projectRef is required (Sprint 51.6 T3 — explicit --project-ref to dodge subprocess link-state isolation)');
385
+ return false;
386
+ }
367
387
  const fnNames = migrations.listRumenFunctions();
368
388
  if (fnNames.length === 0) {
369
389
  fail('no Rumen Edge Function source found in bundled setup or @jhizzard/rumen package');
@@ -390,10 +410,14 @@ function deployFunctions(rumenVersion, dryRun) {
390
410
  ok();
391
411
 
392
412
  for (const name of fnNames) {
393
- step(`Running: supabase functions deploy ${name} --no-verify-jwt...`);
394
- const r = runShell('supabase', ['functions', 'deploy', name, '--no-verify-jwt'], {
395
- cwd: stage
396
- });
413
+ // Sprint 51.6 T3 `--project-ref <ref>` explicit, dodging supabase
414
+ // link-state subprocess isolation (Bug C, Brad's 2026-05-03 install).
415
+ step(`Running: supabase functions deploy ${name} --project-ref ${projectRef} --no-verify-jwt...`);
416
+ const r = runShell('supabase', [
417
+ 'functions', 'deploy', name,
418
+ '--project-ref', projectRef,
419
+ '--no-verify-jwt',
420
+ ], { cwd: stage });
397
421
  if (!r.ok) {
398
422
  fail(`deploy of ${name} failed (exit ${r.code})`);
399
423
  return false;
@@ -997,7 +1021,7 @@ async function main(argv) {
997
1021
  process.stderr.write(` ! falling back to pinned FALLBACK_RUMEN_VERSION=${FALLBACK_RUMEN_VERSION}\n`);
998
1022
  }
999
1023
 
1000
- if (!deployFunctions(resolved.version, flags.dryRun)) return 6;
1024
+ if (!deployFunctions(resolved.version, projectRef, flags.dryRun)) return 6;
1001
1025
 
1002
1026
  // Sprint 51.5 T3: install-time prompt for AI edge classification. Sets
1003
1027
  // secrets.GRAPH_LLM_CLASSIFY in-memory; the per-secret loop below picks
@@ -1042,6 +1066,9 @@ module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
1042
1066
  // Sprint 43 T3: stage helper exposed so init-rumen-deploy.test.js can pin
1043
1067
  // the multi-function staging contract without shelling out to `supabase`.
1044
1068
  module.exports._stageRumenFunctions = stageRumenFunctions;
1069
+ // Sprint 51.6 T3 — exported for tests/init-rumen-project-ref.test.js so the
1070
+ // --project-ref invariant can be asserted without spawning a real shell.
1071
+ module.exports._deployFunctions = deployFunctions;
1045
1072
  // Sprint 51.5 T3: per-secret CLI loop, Vault SQL-Editor URL builder, and
1046
1073
  // Vault-secret ensure helper exposed for tests/init-rumen-secrets-per-call,
1047
1074
  // init-rumen-graph-llm, and init-rumen-vault-deeplinks.
@@ -117,6 +117,23 @@ const PROBES = Object.freeze([
117
117
  " and column_name = 'source_agent' limit 1",
118
118
  presentWhen: 'rowReturned'
119
119
  },
120
+ {
121
+ // Sprint 51.6 T3 — bundled session-end hook (TermDeck v1.0.2+) writes
122
+ // the rich rag-system column set to memory_sessions; canonical engram
123
+ // mig 001 only ships (id, project, summary, metadata, created_at).
124
+ // Probe for memory_sessions.session_id (the most distinctive of the
125
+ // mig-017 columns) and apply mig 017 if absent. Idempotent on petvetbid
126
+ // where the columns are already present from hand-applied DDL.
127
+ name: 'memory_sessions.session_id',
128
+ kind: 'mnestra',
129
+ migrationFile: '017_memory_sessions_session_metadata.sql',
130
+ probeSql:
131
+ "select 1 as present from information_schema.columns " +
132
+ "where table_schema = 'public' " +
133
+ " and table_name = 'memory_sessions' " +
134
+ " and column_name = 'session_id' limit 1",
135
+ presentWhen: 'rowReturned'
136
+ },
120
137
  {
121
138
  name: 'rumen-tick cron schedule',
122
139
  kind: 'rumen',
@@ -134,6 +151,38 @@ const PROBES = Object.freeze([
134
151
  probeSql:
135
152
  "select 1 as present from cron.job where jobname = 'graph-inference-tick' limit 1",
136
153
  presentWhen: 'rowReturned'
154
+ },
155
+ // Sprint 51.6 T3 — Brad's Bug D: function-existence probes (cron schedule
156
+ // checks for jobname presence) are not enough. The deployed Edge Function
157
+ // SOURCE may be stale even when the cron job and function both exist.
158
+ // jizzard-brain on 2026-05-03: deployed rumen-tick was missing the
159
+ // SUPABASE_DB_URL fallback that Sprint 51.5 T1 added; cron probe said
160
+ // "present", source was old. The marker check below detects that drift.
161
+ //
162
+ // probeKind 'functionSource' triggers a Management API fetch instead of a
163
+ // pgClient.query. Bumps to skipped[] (not missing[]) when drift is
164
+ // detected — the corresponding "apply" is a redeploy via init-rumen's
165
+ // deployFunctions, not an SQL migration. The wizard shows skipped[]
166
+ // entries with their probeError, prompting the user to re-run init.
167
+ //
168
+ // Maintenance: bump `requiredMarker` whenever a new feature is added to
169
+ // the bundled function source that is meaningful enough to gate redeploys
170
+ // on. The marker should be a string unique to the post-change version.
171
+ {
172
+ name: 'rumen-tick deployed source has SUPABASE_DB_URL fallback',
173
+ kind: 'rumen',
174
+ probeKind: 'functionSource',
175
+ functionSlug: 'rumen-tick',
176
+ requiredMarker: "Deno.env.get('SUPABASE_DB_URL')",
177
+ presentWhen: 'sourceMatch'
178
+ },
179
+ {
180
+ name: 'graph-inference deployed source has SUPABASE_DB_URL fallback',
181
+ kind: 'rumen',
182
+ probeKind: 'functionSource',
183
+ functionSlug: 'graph-inference',
184
+ requiredMarker: "Deno.env.get('SUPABASE_DB_URL')",
185
+ presentWhen: 'sourceMatch'
137
186
  }
138
187
  ]);
139
188
 
@@ -147,8 +196,68 @@ function resolveMigrationFile(target, files) {
147
196
  return files.find((f) => path.basename(f) === wanted) || null;
148
197
  }
149
198
 
199
+ // Sprint 51.6 T3 — Bug D: Edge Function source-drift detection.
200
+ //
201
+ // Fetches the deployed Edge Function body from Supabase Management API and
202
+ // looks for a marker string the bundled source contains. Returns absent
203
+ // (with probeError) when the marker is missing — meaning the deployed
204
+ // function is older than the bundle and should be redeployed.
205
+ //
206
+ // Requires:
207
+ // - projectRef passed through from auditUpgrade()
208
+ // - SUPABASE_ACCESS_TOKEN in env (a personal access token, format `sbp_*`)
209
+ // Fail-soft when either is missing — recorded as probeError, treated as
210
+ // absent. The audit caller decides whether to surface to the user.
211
+ async function probeFunctionSource(target, { projectRef, fetchImpl }) {
212
+ const fn = fetchImpl || (typeof globalThis !== 'undefined' ? globalThis.fetch : undefined);
213
+ if (typeof fn !== 'function') {
214
+ return { present: false, probeError: 'no fetch implementation available' };
215
+ }
216
+ if (!projectRef) {
217
+ return { present: false, probeError: 'projectRef required for functionSource probe' };
218
+ }
219
+ const accessToken = process.env.SUPABASE_ACCESS_TOKEN;
220
+ if (!accessToken) {
221
+ return {
222
+ present: false,
223
+ probeError: 'SUPABASE_ACCESS_TOKEN not set; cannot fetch deployed function body. Set the personal access token (`supabase login` writes it to ~/.supabase/access-token) to enable function-source drift detection.',
224
+ };
225
+ }
226
+ let res;
227
+ try {
228
+ res = await fn(
229
+ `https://api.supabase.com/v1/projects/${projectRef}/functions/${target.functionSlug}/body`,
230
+ { headers: { 'Authorization': `Bearer ${accessToken}` } }
231
+ );
232
+ } catch (err) {
233
+ return { present: false, probeError: `Management API fetch failed: ${err.message}` };
234
+ }
235
+ if (!res.ok) {
236
+ return {
237
+ present: false,
238
+ probeError: `Management API returned HTTP ${res.status} for ${target.functionSlug}/body — function may not be deployed yet, or access token lacks permission.`
239
+ };
240
+ }
241
+ let body;
242
+ try { body = await res.text(); }
243
+ catch (err) { return { present: false, probeError: `body decode failed: ${err.message}` }; }
244
+
245
+ if (target.requiredMarker && body.includes(target.requiredMarker)) {
246
+ return { present: true };
247
+ }
248
+ return {
249
+ present: false,
250
+ probeError: `deployed ${target.functionSlug} source missing marker (${JSON.stringify(target.requiredMarker)}) — re-run \`termdeck init --rumen\` to redeploy from bundled source.`,
251
+ };
252
+ }
253
+
150
254
  // Run a probe and decide present/absent based on the probe's contract.
151
- async function probeOne(pgClient, target) {
255
+ // Sprint 51.6 T3: dispatches by target.probeKind. Default is the legacy
256
+ // pgClient.query path; 'functionSource' calls probeFunctionSource (HTTP).
257
+ async function probeOne(pgClient, target, ctx = {}) {
258
+ if (target.probeKind === 'functionSource') {
259
+ return probeFunctionSource(target, ctx);
260
+ }
152
261
  let result;
153
262
  try {
154
263
  result = await pgClient.query(target.probeSql);
@@ -233,7 +342,8 @@ async function auditUpgrade({
233
342
  projectRef,
234
343
  dryRun = false,
235
344
  probes,
236
- _migrations
345
+ _migrations,
346
+ _fetch
237
347
  } = {}) {
238
348
  if (!pgClient || typeof pgClient.query !== 'function') {
239
349
  throw new Error('auditUpgrade: pgClient with .query() is required');
@@ -255,11 +365,23 @@ async function auditUpgrade({
255
365
 
256
366
  for (const target of targets) {
257
367
  probed.push(target.name);
258
- const probeResult = await probeOne(pgClient, target);
368
+ const probeResult = await probeOne(pgClient, target, { projectRef, fetchImpl: _fetch });
259
369
  if (probeResult.present) {
260
370
  present.push(target.name);
261
371
  continue;
262
372
  }
373
+
374
+ // Sprint 51.6 T3 — Bug D: functionSource probes go to skipped[] (not
375
+ // missing[]). The corresponding fix is a re-run of `init --rumen` which
376
+ // calls deployFunctions; audit-upgrade does not auto-redeploy.
377
+ if (target.probeKind === 'functionSource') {
378
+ skipped.push({
379
+ name: target.name,
380
+ reason: probeResult.probeError || 'function source drift — redeploy via init --rumen',
381
+ });
382
+ continue;
383
+ }
384
+
263
385
  missing.push(target.name);
264
386
 
265
387
  if (dryRun) continue;
@@ -297,6 +419,7 @@ module.exports = {
297
419
  // Test surface — kept exported so audit-upgrade.test.js can pin probe
298
420
  // selection / apply pathway behavior without needing a live pg client.
299
421
  _probeOne: probeOne,
422
+ _probeFunctionSource: probeFunctionSource,
300
423
  _applyOne: applyOne,
301
424
  _resolveMigrationFile: resolveMigrationFile
302
425
  };
@@ -0,0 +1,94 @@
1
+ -- Migration 017 — memory_sessions session metadata columns.
2
+ --
3
+ -- Sprint 51.6 T3 (TermDeck v1.0.2 hotfix wave). Brings the canonical engram
4
+ -- memory_sessions schema in line with the rag-system writer's column set so
5
+ -- TermDeck's bundled session-end hook can write a uniform shape on both
6
+ -- fresh-canonical installs and Joshua's daily-driver petvetbid (where the
7
+ -- columns were already added by hand when rag-system bootstrap ran).
8
+ --
9
+ -- Why: until v1.0.2 the bundled hook only wrote memory_items. The actual
10
+ -- memory_sessions writer was Joshua's PRIOR personal hook at
11
+ -- ~/Documents/Graciella/rag-system/hooks/memory-session-end.js, which spawned
12
+ -- ~/Documents/Graciella/rag-system/src/scripts/process-session.ts; that script
13
+ -- INSERTed memory_sessions rows with this richer column set. When the
14
+ -- TermDeck stack-installer overwrote the personal hook on 2026-05-02, the
15
+ -- writer disappeared and memory_sessions stopped accumulating. v1.0.2's
16
+ -- bundled hook gains a memory_sessions write path; this migration ensures
17
+ -- the schema it expects exists everywhere.
18
+ --
19
+ -- Idempotent — safe on:
20
+ -- 1. petvetbid (where these columns are already present from hand-applied
21
+ -- DDL Joshua ran when setting up rag-system; the IF NOT EXISTS guards
22
+ -- no-op on every column).
23
+ -- 2. Fresh canonical installs that ran migrations 001-016 only (the canonical
24
+ -- engram set, which left memory_sessions at the minimal mig-001 shape).
25
+ -- 3. Re-runs of this migration — every operation is guarded.
26
+ --
27
+ -- The unique constraint on session_id is wrapped in a do-block because
28
+ -- ADD CONSTRAINT does not support IF NOT EXISTS in PostgreSQL. Joshua's
29
+ -- petvetbid already has the constraint as memory_sessions_session_id_key
30
+ -- (auto-named by the rag-system bootstrap); this block detects that name
31
+ -- and skips re-adding.
32
+ --
33
+ -- session_id is added NULLABLE on canonical installs even though petvetbid's
34
+ -- existing constraint is NOT NULL. Adding NOT NULL via ALTER TABLE on a
35
+ -- table with existing rows would fail; the bundled hook always supplies
36
+ -- session_id at write time, so nullability is non-blocking. A future sprint
37
+ -- may tighten to NOT NULL with a DEFAULT after a backfill pass.
38
+
39
+ -- Defensive: vector extension must already be installed (migration 001
40
+ -- requires it for memory_items.embedding). If it's somehow missing this
41
+ -- ADD COLUMN errors, surfacing the real environment issue rather than
42
+ -- silently skipping the embedding column.
43
+
44
+ alter table public.memory_sessions
45
+ add column if not exists session_id text,
46
+ add column if not exists summary_embedding vector(1536),
47
+ add column if not exists started_at timestamptz,
48
+ add column if not exists ended_at timestamptz,
49
+ add column if not exists duration_minutes integer,
50
+ add column if not exists messages_count integer default 0,
51
+ add column if not exists facts_extracted integer default 0,
52
+ add column if not exists files_changed jsonb default '[]'::jsonb,
53
+ add column if not exists topics jsonb default '[]'::jsonb,
54
+ add column if not exists transcript_path text;
55
+
56
+ -- Unique constraint on session_id. Skip if any unique constraint on
57
+ -- (session_id) is already in place — covers both the canonical name
58
+ -- memory_sessions_session_id_key and any alternate name from a manual
59
+ -- ALTER TABLE Joshua may have run on petvetbid.
60
+ do $$
61
+ declare
62
+ has_unique boolean;
63
+ begin
64
+ select exists (
65
+ select 1
66
+ from pg_constraint c
67
+ join pg_class t on t.oid = c.conrelid
68
+ join pg_namespace n on n.oid = t.relnamespace
69
+ where n.nspname = 'public'
70
+ and t.relname = 'memory_sessions'
71
+ and c.contype = 'u'
72
+ and (
73
+ select array_agg(att.attname order by att.attnum)
74
+ from unnest(c.conkey) as colnum
75
+ join pg_attribute att on att.attrelid = c.conrelid and att.attnum = colnum
76
+ ) = ARRAY['session_id']::name[]
77
+ ) into has_unique;
78
+
79
+ if not has_unique then
80
+ alter table public.memory_sessions
81
+ add constraint memory_sessions_session_id_key unique (session_id);
82
+ end if;
83
+ end $$;
84
+
85
+ -- HNSW index on summary_embedding for future similarity search. Idempotent.
86
+ -- Cost on insert is negligible; cost on backfill is one-time.
87
+ create index if not exists memory_sessions_summary_embedding_hnsw_idx
88
+ on public.memory_sessions using hnsw (summary_embedding vector_cosine_ops)
89
+ with (m = 16, ef_construction = 64);
90
+
91
+ -- Helpful covering index for time-range scans (used by Flashback / rumen
92
+ -- queries that filter by ended_at). Idempotent.
93
+ create index if not exists memory_sessions_ended_at_idx
94
+ on public.memory_sessions(ended_at desc nulls last);
@@ -0,0 +1,73 @@
1
+ # @jhizzard/termdeck-stack
2
+
3
+ One-command installer for the TermDeck developer memory stack.
4
+
5
+ ```
6
+ npx @jhizzard/termdeck-stack
7
+ ```
8
+
9
+ ## What gets installed
10
+
11
+ | Layer | Package | What it does |
12
+ |-------|---------|--------------|
13
+ | 1 | `@jhizzard/termdeck` | Browser terminal multiplexer with metadata overlays and Flashback recall toasts |
14
+ | 2 | `@jhizzard/mnestra` | pgvector memory store + MCP server. Lights up Flashback. Provides `memory_*` tools to Claude Code, Cursor, Windsurf |
15
+ | 3 | `@jhizzard/rumen` | Async learning loop on a Supabase Edge Function cron. Synthesizes cross-project insights |
16
+ | 4 | `@supabase/mcp-server-supabase` | MCP that lets the TermDeck setup wizard provision your Supabase project automatically |
17
+
18
+ The wizard:
19
+
20
+ 1. Prints the four-layer overview so you see what you're agreeing to.
21
+ 2. Detects which pieces are already on your machine.
22
+ 3. Asks which tier you want (default: 4 — full stack).
23
+ 4. Runs `npm install -g` for the missing pieces.
24
+ 5. Merges Mnestra and Supabase MCP entries into `~/.claude/mcp.json` — preserving any existing MCP servers.
25
+ 6. Prints the next steps (Supabase PAT, credentials, `termdeck` to start).
26
+
27
+ ## Modes
28
+
29
+ ```
30
+ npx @jhizzard/termdeck-stack # interactive
31
+ npx @jhizzard/termdeck-stack --tier 4 # unattended
32
+ npx @jhizzard/termdeck-stack --dry-run # print plan, don't install
33
+ npx @jhizzard/termdeck-stack --yes # accept defaults (combine with --tier)
34
+ ```
35
+
36
+ ## Known limitations
37
+
38
+ Tier 3 (Rumen) currently still requires one manual command after the
39
+ installer finishes:
40
+
41
+ ```
42
+ termdeck init --rumen
43
+ ```
44
+
45
+ That command deploys the Rumen Supabase Edge Function, applies the
46
+ migration, and installs the `pg_cron` schedule. Auto-running it from
47
+ the meta-installer is queued — until then the wizard prints it as an
48
+ explicit next step.
49
+
50
+ ## Version vs. the rest of the stack
51
+
52
+ This package's version tracks the meta-installer surface, not the
53
+ underlying packages. Each layer ships on its own release cadence:
54
+
55
+ | Package | Where to look |
56
+ |---------|---------------|
57
+ | `@jhizzard/termdeck` | https://www.npmjs.com/package/@jhizzard/termdeck |
58
+ | `@jhizzard/mnestra` | https://www.npmjs.com/package/@jhizzard/mnestra |
59
+ | `@jhizzard/rumen` | https://www.npmjs.com/package/@jhizzard/rumen |
60
+
61
+ The installer always pulls each layer's `latest` dist-tag, so a fresh
62
+ `npx @jhizzard/termdeck-stack` run picks up the most recent published
63
+ version of every layer regardless of this package's own version.
64
+
65
+ ## Why this exists
66
+
67
+ The TermDeck stack used to be a 15-step install: provision Supabase, run six SQL migrations, mint API keys, paste them into `secrets.env`, edit `config.yaml`, install Mnestra globally, deploy Rumen, install the Supabase MCP, wire `~/.claude/mcp.json`. Most testers bounced before step 5.
68
+
69
+ This installer collapses every step that's a `npm install -g` into one command, then drops the user at the doorstep of the in-browser setup wizard (which handles credentials).
70
+
71
+ ## License
72
+
73
+ MIT