@jhizzard/termdeck-stack 0.6.1 → 0.6.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.
@@ -36,7 +36,32 @@
36
36
  * (last ~30 message excerpts).
37
37
  * 7. Embeds the summary via OpenAI text-embedding-3-small.
38
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.
39
+ * 9. (Sprint 51.6 T3) POSTs ONE row to Supabase /rest/v1/memory_sessions with
40
+ * Prefer: resolution=merge-duplicates so SessionEnd-fires-twice resolves
41
+ * to a single row. Requires Mnestra migration 017 on canonical installs;
42
+ * petvetbid already has the rich schema from rag-system bootstrap.
43
+ * 10. Logs every step to ~/.claude/hooks/memory-hook.log.
44
+ *
45
+ * Version stamp (Sprint 51.6 T3 — hook upgrade gap fix):
46
+ * The marker `@termdeck/stack-installer-hook v<N>` below is read by both
47
+ * stack-installer's installSessionEndHook (version-aware overwrite under
48
+ * --yes) and `termdeck init --mnestra` (refreshBundledHookIfNewer step).
49
+ * Bump the integer whenever a change to this file should overwrite an
50
+ * already-installed copy on the user's machine — e.g. a new write path,
51
+ * a new transcript parser, a default PROJECT_MAP change. Comment-only
52
+ * tweaks do not need a bump.
53
+ *
54
+ * v2 (Sprint 51.7 T2 — metadata completeness + wire-up insurance):
55
+ * - parseTranscriptMetadata() now populates memory_sessions.started_at /
56
+ * duration_minutes / facts_extracted from per-message timestamps and
57
+ * memory_remember tool_use counts, closing the v1 "minimum viable row"
58
+ * gap Codex flagged at Sprint 51.6 Phase B.
59
+ * - Stamp bump load-bearing as INSURANCE for the Sprint 51.6 wire-up bug
60
+ * (T1 fix landing in same v1.0.3 wave): an installed-v1 user upgrading
61
+ * to bundled-v2 always passes the `installed >= bundled` short-circuit
62
+ * at init-mnestra.js:550 and reaches the refresh path.
63
+ *
64
+ * @termdeck/stack-installer-hook v2
40
65
  *
41
66
  * Required env vars (validated at entry, after the secrets.env fallback):
42
67
  * - SUPABASE_URL e.g. https://<project-ref>.supabase.co
@@ -73,13 +98,43 @@ function resolveSecretsPath() {
73
98
  || join(os.homedir(), '.termdeck', 'secrets.env');
74
99
  }
75
100
 
76
- // PROJECT_MAP — minimal default. Users extend by adding entries to this array.
77
- // Patterns match against the cwd reported by Claude Code at Stop time.
101
+ // PROJECT_MAP — most-specific-first ordering (Sprint 41 design).
102
+ // Patterns match against the cwd reported by Claude Code at SessionEnd.
78
103
  // First match wins; falls through to "global".
104
+ //
105
+ // Sprint 51.6 (T1 side finding b): a previous version shipped this array
106
+ // empty, which caused every session to tag as "global" — orphaning rows
107
+ // from project-scoped memory_recall queries. The default below restores
108
+ // the most-specific-first taxonomy from Sprint 41 T1, generalized for
109
+ // universal shipping. Users still extend in place by editing this array.
110
+ //
111
+ // Patterns NOT specific to Joshua's filesystem (e.g. /\/PVB\//i, /\/DOR\//i)
112
+ // are kept because they're benign on other machines — the regex simply
113
+ // doesn't fire on cwds that don't contain those segments. The chopin-
114
+ // nashville catch-all stays LAST (structural invariant) so a TermDeck cwd
115
+ // inside ChopinNashville/SideHustles/ resolves to "termdeck", not the
116
+ // catch-all.
79
117
  const PROJECT_MAP = [
80
- // Example entries uncomment + edit, or add your own:
81
- // { pattern: /\/myproject\//i, project: 'my-project' },
82
- // { pattern: /work-stuff/i, project: 'work' },
118
+ // ── Active code projects (most-specific FIRST) ──
119
+ { pattern: /\/SideHustles\/TermDeck\/termdeck/i, project: 'termdeck' },
120
+ { pattern: /\/Graciella\/engram(\/|$)/i, project: 'mnestra' },
121
+ { pattern: /\/Graciella\/rumen(\/|$)/i, project: 'rumen' },
122
+ { pattern: /\/Graciella\/rag-system(\/|$)/i, project: 'rag-system' },
123
+ { pattern: /\/ChopinInBohemia\/podium(\/|$)/i, project: 'podium' },
124
+ { pattern: /\/ChopinInBohemia(\/|$)/i, project: 'chopin-in-bohemia' },
125
+ { pattern: /\/SideHustles\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
126
+ { pattern: /\/ChopinNashville\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
127
+ { pattern: /\/Graciella\/PVB(\/|$)|\/PVB\/pvb(\/|$)/i, project: 'pvb' },
128
+ { pattern: /\/Unagi\/gorgias-ticket-monitor(\/|$)/i, project: 'claimguard' },
129
+ { pattern: /\/ChopinNashville\/SideHustles\/ClaimGuard(\/|$)/i, project: 'claimguard' },
130
+ { pattern: /\/Documents\/DOR(\/|$)/i, project: 'dor' },
131
+ { pattern: /\/Graciella\/joshuaizzard-dev(\/|$)/i, project: 'portfolio' },
132
+ { pattern: /\/Graciella\/imessage-reader(\/|$)/i, project: 'imessage-reader' },
133
+
134
+ // ── chopin-nashville catch-all (MUST be LAST among /ChopinNashville/ matchers).
135
+ // Sprint 35 + 41 lesson: any /ChopinNashville/-matching pattern placed below
136
+ // this entry gets shadowed and the row mis-tags as 'chopin-nashville'.
137
+ { pattern: /\/ChopinNashville(\/|$)/i, project: 'chopin-nashville' },
83
138
  ];
84
139
 
85
140
  const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
@@ -395,6 +450,117 @@ function selectTranscriptParser(sessionType) {
395
450
  return { parser: parseAutoDetect, sessionType: 'auto' };
396
451
  }
397
452
 
453
+ // ──────────────────────────────────────────────────────────────────────────
454
+ // Sprint 51.7 T2 — transcript metadata extractor for memory_sessions.
455
+ //
456
+ // The v1 bundled hook (Sprint 51.6 T3) intentionally shipped the "minimum
457
+ // viable row" — postMemorySession set started_at, duration_minutes, and
458
+ // facts_extracted to NULL/0 because v1 omitted transcript parsing for
459
+ // per-message timestamps. The legacy rag-system writer
460
+ // (~/Documents/Graciella/rag-system/src/scripts/process-session.ts) populated
461
+ // those fields by parsing the transcript JSONL passed to it on stdin, and
462
+ // petvetbid's 289 baseline rows carried the rich shape from that writer.
463
+ // v2 closes the gap in pure Node so the bundled hook reaches parity without
464
+ // the rag-system dependency (Class E hidden-dependency rule).
465
+ //
466
+ // Heuristic for facts_extracted: count distinct `tool_use` blocks whose
467
+ // `name` matches a memory_remember MCP tool. Conservative by design — a
468
+ // regex like /Remember:/ inside summary text would over-match quoted user
469
+ // content (e.g., "the user typed 'Remember:' in their prompt"). Counting
470
+ // tool_use blocks instead measures what was actually written into the store
471
+ // during the session, which is the semantic the rag-system writer used.
472
+ //
473
+ // Tool name variants observed in real transcripts (T4-CODEX 11:09 ET pre-
474
+ // audit confirmed both prefixes are live in `~/.claude/projects/`):
475
+ // - `memory_remember` (bare; CC native + future-proofing)
476
+ // - `mcp__mnestra__memory_remember` (current Mnestra MCP, post-rename)
477
+ // - `mcp__memory__memory_remember` (legacy MCP server name from when
478
+ // the project was called "memory")
479
+ // Counting all three avoids undercounting on existing user transcripts.
480
+ // ──────────────────────────────────────────────────────────────────────────
481
+
482
+ const FACT_TOOL_NAMES = new Set([
483
+ 'memory_remember',
484
+ 'mcp__mnestra__memory_remember',
485
+ 'mcp__memory__memory_remember',
486
+ ]);
487
+
488
+ // Sprint 51.7 T2 / T4-CODEX 11:13 ET catch: each adapter shipped by this
489
+ // hook stores message content under a different key shape, and we have to
490
+ // match all of them or facts_extracted under-counts whenever a non-Claude
491
+ // session writes to memory_sessions. Mirror the shapes already documented
492
+ // at the top of TRANSCRIPT_PARSERS:
493
+ //
494
+ // - Claude Code (current): msg.message.content[]
495
+ // - Grok (Sprint 50 T1): msg.content[] (flat, AI SDK provider shape)
496
+ // - Codex (response_item): msg.payload.content[] when msg.type === 'response_item'
497
+ //
498
+ // Gemini's single-JSON envelope doesn't apply per-line — its content lives
499
+ // inside a top-level messages array, and each entry's content is a flat
500
+ // array OR a string. extractContentBlocks() handles flat arrays; strings
501
+ // are skipped (no tool_use can hide inside a string).
502
+ function extractContentBlocks(msg) {
503
+ if (!msg || typeof msg !== 'object') return null;
504
+ if (msg.message && Array.isArray(msg.message.content)) return msg.message.content;
505
+ if (Array.isArray(msg.content)) return msg.content;
506
+ if (msg.type === 'response_item' && msg.payload && Array.isArray(msg.payload.content)) {
507
+ return msg.payload.content;
508
+ }
509
+ return null;
510
+ }
511
+
512
+ function parseTranscriptMetadata(rawJsonl) {
513
+ if (typeof rawJsonl !== 'string' || rawJsonl.length === 0) {
514
+ return { startedAt: null, endedAt: null, durationMinutes: null, factsExtracted: 0 };
515
+ }
516
+ const lines = rawJsonl.split('\n').filter(Boolean);
517
+ let earliestTs = null;
518
+ let latestTs = null;
519
+ let factsExtracted = 0;
520
+
521
+ for (const line of lines) {
522
+ let msg;
523
+ try { msg = JSON.parse(line); } catch (_) { continue; }
524
+ if (!msg || typeof msg !== 'object') continue;
525
+
526
+ // Timestamp: top-level `timestamp` is the canonical Claude Code shape.
527
+ // Fall back to `msg.message.timestamp` for any future / alt-shape that
528
+ // nests it (Codex/Gemini/Grok adapters preserve the top-level form, so
529
+ // this is mostly forward-compat).
530
+ const ts = msg.timestamp || (msg.message && msg.message.timestamp);
531
+ if (typeof ts === 'string' || typeof ts === 'number') {
532
+ const t = Date.parse(ts);
533
+ if (!Number.isNaN(t)) {
534
+ if (earliestTs === null || t < earliestTs) earliestTs = t;
535
+ if (latestTs === null || t > latestTs) latestTs = t;
536
+ }
537
+ }
538
+
539
+ // facts_extracted: count tool_use blocks matching a memory_remember
540
+ // MCP tool name. See FACT_TOOL_NAMES + extractContentBlocks above.
541
+ const blocks = extractContentBlocks(msg);
542
+ if (blocks) {
543
+ for (const b of blocks) {
544
+ if (b && b.type === 'tool_use' && typeof b.name === 'string' && FACT_TOOL_NAMES.has(b.name)) {
545
+ factsExtracted += 1;
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ const startedAt = earliestTs !== null ? new Date(earliestTs).toISOString() : null;
552
+ const endedAt = latestTs !== null ? new Date(latestTs).toISOString() : null;
553
+ const durationMinutes = (earliestTs !== null && latestTs !== null)
554
+ ? Math.max(0, Math.round((latestTs - earliestTs) / 60000))
555
+ : null;
556
+ return { startedAt, endedAt, durationMinutes, factsExtracted };
557
+ }
558
+
559
+ // Sprint 51.6 T3 → 51.7 T2: `buildSummary` now also returns parser-derived
560
+ // metadata (startedAt, endedAt, durationMinutes, factsExtracted) merged into
561
+ // the result object. parseTranscriptMetadata reuses the same raw string —
562
+ // no second readFileSync. Returns null when the transcript is unreadable or
563
+ // has fewer than 5 messages (skip semantics unchanged from v1).
398
564
  function buildSummary(transcriptPath, sessionType) {
399
565
  let raw;
400
566
  try { raw = readFileSync(transcriptPath, 'utf8'); }
@@ -418,7 +584,18 @@ function buildSummary(transcriptPath, sessionType) {
418
584
  tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
419
585
  // OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
420
586
  // 7000 chars is a safe headroom that survives multibyte expansion.
421
- return summary.slice(0, 7000);
587
+
588
+ // Sprint 51.7 T2: merge transcript-derived metadata so the caller (
589
+ // processStdinPayload → postMemorySession) can populate the
590
+ // memory_sessions.started_at/duration_minutes/facts_extracted fields the
591
+ // v1 hook left NULL/0.
592
+ const metadata = parseTranscriptMetadata(raw);
593
+
594
+ return {
595
+ summary: summary.slice(0, 7000),
596
+ messagesCount: messages.length,
597
+ ...metadata,
598
+ };
422
599
  }
423
600
 
424
601
  async function embedText(text, openaiKey) {
@@ -494,6 +671,89 @@ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, pr
494
671
  }
495
672
  }
496
673
 
674
+ // Sprint 51.6 T3 — companion write to memory_sessions.
675
+ //
676
+ // History: the bundled hook never wrote memory_sessions until v1.0.2. Joshua's
677
+ // PRIOR personal rag-system hook spawned process-session.ts which inserted
678
+ // memory_sessions rows; the Sprint 38 P0 rewrite replaced that hook with a
679
+ // Mnestra-direct hook that only wrote memory_items. Result: from 2026-05-02
680
+ // 13:24 ET (when bundled overwrote personal) until v1.0.2, no memory_sessions
681
+ // rows accumulated. Sprint 51.6 T1+T2+T3 documented the gap; this function
682
+ // closes it.
683
+ //
684
+ // Schema target: Mnestra migration 017 brings canonical engram in line with
685
+ // petvetbid's rag-system flavor (session_id, summary_embedding, started_at,
686
+ // ended_at, duration_minutes, messages_count, transcript_path, etc). The
687
+ // bundled hook writes the rich shape on every install — fresh-canonical
688
+ // (post-mig-017) and petvetbid alike.
689
+ //
690
+ // Idempotency: Prefer: resolution=merge-duplicates relies on the
691
+ // memory_sessions_session_id_key unique constraint. Mig 017 adds it where
692
+ // absent. SessionEnd-fires-twice (e.g. /exit then PTY close) resolves to a
693
+ // single row.
694
+ async function postMemorySession({
695
+ supabaseUrl, supabaseKey,
696
+ summary, summaryEmbedding,
697
+ project, sessionId,
698
+ transcriptPath, messagesCount,
699
+ endedAt,
700
+ // Sprint 51.7 T2 — transcript-derived metadata (closes Sprint 51.6's
701
+ // started_at/duration_minutes/facts_extracted=NULL gap). All optional;
702
+ // null/null/0 fallback preserves the v1 minimum-viable-row shape when the
703
+ // transcript carries no timestamps (e.g. legacy fixtures, pre-CC-2.x
704
+ // payloads, or hand-fed test inputs).
705
+ startedAt = null,
706
+ durationMinutes = null,
707
+ factsExtracted = 0,
708
+ }) {
709
+ if (!sessionId) {
710
+ log('memory-sessions-skip: sessionId missing — cannot satisfy session_id NOT NULL/UNIQUE.');
711
+ return false;
712
+ }
713
+ try {
714
+ // Sprint 51.6 T3 / T4-CODEX audit 20:23 ET: PostgREST requires both
715
+ // `Prefer: resolution=merge-duplicates` AND `?on_conflict=<column>`
716
+ // on the URL to trigger an UPSERT. Without `on_conflict=session_id`
717
+ // a duplicate fire would error against memory_sessions_session_id_key.
718
+ const res = await fetch(`${supabaseUrl}/rest/v1/memory_sessions?on_conflict=session_id`, {
719
+ method: 'POST',
720
+ headers: {
721
+ 'Content-Type': 'application/json',
722
+ 'apikey': supabaseKey,
723
+ 'Authorization': `Bearer ${supabaseKey}`,
724
+ 'Prefer': 'resolution=merge-duplicates,return=minimal',
725
+ },
726
+ body: JSON.stringify({
727
+ session_id: sessionId,
728
+ summary,
729
+ summary_embedding: Array.isArray(summaryEmbedding)
730
+ ? `[${summaryEmbedding.join(',')}]`
731
+ : null,
732
+ project,
733
+ // Sprint 51.7 T2: started_at + duration_minutes + facts_extracted now
734
+ // populated from parseTranscriptMetadata when transcript timestamps
735
+ // are present. files_changed and topics remain unpopulated (would
736
+ // require diff parsing the bundled hook doesn't have; deferred).
737
+ started_at: typeof startedAt === 'string' ? startedAt : null,
738
+ ended_at: (endedAt instanceof Date ? endedAt : new Date()).toISOString(),
739
+ duration_minutes: typeof durationMinutes === 'number' ? durationMinutes : null,
740
+ messages_count: typeof messagesCount === 'number' ? messagesCount : 0,
741
+ facts_extracted: typeof factsExtracted === 'number' ? factsExtracted : 0,
742
+ transcript_path: transcriptPath || null,
743
+ }),
744
+ });
745
+ if (!res.ok) {
746
+ const body = await res.text().catch(() => '');
747
+ log(`memory-sessions-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
748
+ return false;
749
+ }
750
+ return true;
751
+ } catch (e) {
752
+ log(`memory-sessions-insert-exception: ${e.message}`);
753
+ return false;
754
+ }
755
+ }
756
+
497
757
  async function processStdinPayload(input) {
498
758
  let data;
499
759
  try { data = JSON.parse(input); }
@@ -543,13 +803,21 @@ async function processStdinPayload(input) {
543
803
  const project = detectProject(cwd);
544
804
  debug(`project="${project}", session=${sessionId}, sessionType=${sessionType}`);
545
805
 
546
- const summary = buildSummary(transcriptPath, sessionType);
547
- if (!summary) return;
806
+ const built = buildSummary(transcriptPath, sessionType);
807
+ if (!built) return;
808
+ const {
809
+ summary,
810
+ messagesCount,
811
+ startedAt: parsedStartedAt,
812
+ endedAt: parsedEndedAt,
813
+ durationMinutes,
814
+ factsExtracted,
815
+ } = built;
548
816
 
549
817
  const embedding = await embedText(summary, env.openaiKey);
550
818
  if (!embedding) return;
551
819
 
552
- const ok = await postMemoryItem({
820
+ const itemOk = await postMemoryItem({
553
821
  supabaseUrl: env.supabaseUrl,
554
822
  supabaseKey: env.supabaseKey,
555
823
  content: summary,
@@ -559,7 +827,34 @@ async function processStdinPayload(input) {
559
827
  sourceAgent,
560
828
  });
561
829
 
562
- if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)}`);
830
+ // Sprint 51.6 T3: companion memory_sessions write. Independent of the
831
+ // memory_items write — a memory_items failure shouldn't suppress the
832
+ // memory_sessions row, and vice versa. Both errors fail-soft.
833
+ //
834
+ // Sprint 51.7 T2: prefer parser-derived `endedAt` (last-message
835
+ // timestamp) over hook-fire-time when the transcript carried timestamps.
836
+ // Matches the rag-system writer's semantics — `ended_at` is "when the
837
+ // conversation last had activity," not "when the SessionEnd hook
838
+ // happened to fire." Falls back to `new Date()` when the parser found
839
+ // no timestamps, preserving v1 behavior.
840
+ const sessionOk = await postMemorySession({
841
+ supabaseUrl: env.supabaseUrl,
842
+ supabaseKey: env.supabaseKey,
843
+ summary,
844
+ summaryEmbedding: embedding,
845
+ project,
846
+ sessionId,
847
+ transcriptPath,
848
+ messagesCount,
849
+ endedAt: parsedEndedAt ? new Date(parsedEndedAt) : new Date(),
850
+ startedAt: parsedStartedAt,
851
+ durationMinutes,
852
+ factsExtracted,
853
+ });
854
+
855
+ if (itemOk || sessionOk) {
856
+ log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} messages=${messagesCount} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)} startedAt=${parsedStartedAt || 'null'} durationMin=${durationMinutes === null ? 'null' : durationMinutes} factsExtracted=${factsExtracted} memory_items=${itemOk ? 'ok' : 'fail'} memory_sessions=${sessionOk ? 'ok' : 'fail'}`);
857
+ }
563
858
  }
564
859
 
565
860
  // Module-export contract for testability. When run as a script (require.main === module),
@@ -579,6 +874,8 @@ if (require.main === module) {
579
874
  buildSummary,
580
875
  embedText,
581
876
  postMemoryItem,
877
+ // Sprint 51.6 T3 — memory_sessions write companion.
878
+ postMemorySession,
582
879
  processStdinPayload,
583
880
  LOG_FILE,
584
881
  // Sprint 45 T4 — adapter-pluggable transcript-parser surface.
@@ -593,5 +890,9 @@ if (require.main === module) {
593
890
  // Sprint 50 T2 — source_agent provenance plumbing.
594
891
  normalizeSourceAgent,
595
892
  ALLOWED_SOURCE_AGENTS,
893
+ // Sprint 51.7 T2 — transcript-metadata extractor for memory_sessions.
894
+ parseTranscriptMetadata,
895
+ FACT_TOOL_NAMES,
896
+ extractContentBlocks,
596
897
  };
597
898
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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
@@ -525,6 +525,61 @@ function _compareHookFiles(srcPath, destPath) {
525
525
  return a.equals(b) ? 'identical' : 'different';
526
526
  }
527
527
 
528
+ // Sprint 51.6 T3 — version-aware overwrite gate. The bundled hook carries a
529
+ // `// @termdeck/stack-installer-hook v<N>` marker; bumping <N> means "the
530
+ // next install should overwrite a stale copy on the user's machine even
531
+ // under --yes." Without this, --yes preserved the existing hook (the safe
532
+ // default for hand-edited files) and never landed bundled fixes — the gap
533
+ // Codex surfaced in Sprint 51.6 (item #2 of the 5-part fix).
534
+ const HOOK_SIGNATURE_REGEX = /@termdeck\/stack-installer-hook\s+v(\d+)/;
535
+
536
+ function _readHookSignatureVersion(filepath) {
537
+ try {
538
+ const head = fs.readFileSync(filepath, 'utf8').slice(0, 4096);
539
+ const m = head.match(HOOK_SIGNATURE_REGEX);
540
+ return m ? parseInt(m[1], 10) : null;
541
+ } catch (_) { return null; }
542
+ }
543
+
544
+ // Sprint 51.6 T4-CODEX audit 20:23 ET — safety gate: only auto-overwrite an
545
+ // unsigned installed hook when it was clearly TermDeck-managed (carries one
546
+ // of the docstring markers from a prior bundled cut). A genuinely custom
547
+ // user hook with no TermDeck fingerprint stays put under --yes; the user
548
+ // gets prompted (interactive) or must `--force-overwrite`. Markers are
549
+ // matched in the first 4KB so a long custom file with TermDeck mentions
550
+ // in the body doesn't false-positive.
551
+ const TERMDECK_MANAGED_MARKERS = [
552
+ /TermDeck session-end memory hook/,
553
+ /@jhizzard\/termdeck-stack/,
554
+ /Vendored into ~\/\.claude\/hooks\/memory-session-end\.js by @jhizzard/i,
555
+ ];
556
+
557
+ function _looksTermdeckManaged(filepath) {
558
+ try {
559
+ const head = fs.readFileSync(filepath, 'utf8').slice(0, 4096);
560
+ return TERMDECK_MANAGED_MARKERS.some((m) => m.test(head));
561
+ } catch (_) { return false; }
562
+ }
563
+
564
+ // Returns true when the bundled hook's version stamp is strictly newer than
565
+ // the installed one (or the installed file is unsigned BUT visibly TermDeck-
566
+ // managed — older installs pre-Sprint-51.6 had no marker, treat them as v0).
567
+ // Returns false when the bundled hook itself is unsigned (safety: a missing
568
+ // source marker means "don't auto-overwrite") OR the installed file is
569
+ // unsigned and looks like a custom user hook (no TermDeck fingerprint).
570
+ // Used to gate --yes overwrite under installSessionEndHook.
571
+ function _hookSignatureUpgradeAvailable(sourcePath, destPath) {
572
+ const bundled = _readHookSignatureVersion(sourcePath);
573
+ if (bundled === null) return false; // bundled unsigned — never auto-overwrite
574
+ const installed = _readHookSignatureVersion(destPath);
575
+ if (installed === null) {
576
+ // Installed has no version stamp. Only auto-overwrite if it looks
577
+ // TermDeck-managed; otherwise preserve as a possible user-custom hook.
578
+ return _looksTermdeckManaged(destPath);
579
+ }
580
+ return bundled > installed;
581
+ }
582
+
528
583
  async function promptYesNo({ question, defaultYes = true }) {
529
584
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
530
585
  const suffix = defaultYes ? '(Y/n)' : '(y/N)';
@@ -583,8 +638,12 @@ async function installSessionEndHook(opts = {}) {
583
638
  statusLine(`${ANSI.dim}=${ANSI.reset}`, 'hook file', 'already present, identical contents');
584
639
  fileStatus = 'already-current';
585
640
  } else {
586
- // different
587
- const overwrite = opts.assumeYes ? false // --yes preserves existing on overwrite
641
+ // different. Sprint 51.6 T3: under --yes, consult the version stamp —
642
+ // a strictly newer bundled stamp (or an unsigned existing file) means
643
+ // we should refresh; same-or-older stamp keeps existing. This closes
644
+ // the upgrade gap where bundled fixes never reached users' machines.
645
+ const overwrite = opts.assumeYes
646
+ ? _hookSignatureUpgradeAvailable(sourcePath, destPath)
588
647
  : opts.forceOverwrite ? true
589
648
  : await promptOverwrite();
590
649
  if (!overwrite) {
@@ -594,6 +653,12 @@ async function installSessionEndHook(opts = {}) {
594
653
  statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would overwrite ${destPath}`);
595
654
  fileStatus = 'would-overwrite';
596
655
  } else {
656
+ // Sprint 51.6 T3: timestamped backup before overwrite so a hand-
657
+ // edited PROJECT_MAP or comment is recoverable. Matches the pattern
658
+ // ~/.claude/hooks/memory-session-end.js.bak.<YYYYMMDDhhmmss> Joshua
659
+ // already had on disk from prior installs.
660
+ const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
661
+ try { fs.copyFileSync(destPath, `${destPath}.bak.${stamp}`); } catch (_) { /* best-effort */ }
597
662
  fs.copyFileSync(sourcePath, destPath);
598
663
  fs.chmodSync(destPath, 0o644);
599
664
  statusLine(`${ANSI.green}↻${ANSI.reset}`, 'hook file', `overwrote ${destPath}`);
@@ -788,8 +853,15 @@ module.exports._readSettingsJson = _readSettingsJson;
788
853
  module.exports._writeSettingsJson = _writeSettingsJson;
789
854
  module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
790
855
  module.exports._compareHookFiles = _compareHookFiles;
856
+ // Sprint 51.6 T3 — version-aware hook refresh helpers. Exported so init-mnestra
857
+ // (and tests) can gate refresh decisions on the same logic the installer uses.
858
+ module.exports._readHookSignatureVersion = _readHookSignatureVersion;
859
+ module.exports._hookSignatureUpgradeAvailable = _hookSignatureUpgradeAvailable;
860
+ module.exports.HOOK_SIGNATURE_REGEX = HOOK_SIGNATURE_REGEX;
791
861
  module.exports.installSessionEndHook = installSessionEndHook;
792
862
  module.exports.HOOK_COMMAND = HOOK_COMMAND;
863
+ module.exports.HOOK_SOURCE = HOOK_SOURCE;
864
+ module.exports.HOOK_DEST = HOOK_DEST;
793
865
  module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
794
866
  module.exports.HOOK_SOURCE = HOOK_SOURCE;
795
867
  module.exports._mcpInternals = _mcpInternals;