@jhizzard/termdeck-stack 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ underlying packages (`@jhizzard/termdeck`, `@jhizzard/mnestra`,
5
5
  `@jhizzard/rumen`) ship on their own cadences and have their own
6
6
  changelogs — see the root `CHANGELOG.md` for `@jhizzard/termdeck`.
7
7
 
8
+ ## [1.3.0] — 2026-05-14
9
+
10
+ ### Added
11
+
12
+ - **`installPreCompactHook`** at `src/index.js` — bundles `assets/hooks/memory-pre-compact.js` (235 LOC, NEW) and wires it into `~/.claude/settings.json` under `hooks.PreCompact` with `matcher: "*"`. Closes Investigation 2 of `docs/CRITICAL-READ-FIRST-2026-05-07.md` (auto-commit on context-compaction-near). Hook is fail-soft (exit 0 on error; never blocks compaction); writes `source_type='pre_compact_snapshot'` rows to Mnestra. Two firing modes: Claude Code `PreCompact` lifecycle event AND TermDeck server-side periodic-capture timer (for Codex/Gemini/Grok panels). Discriminated via `resolveFiringContext`. Refresh path at `packages/cli/src/init-mnestra.js::runHookRefresh` extended with the same version-stamp regex.
13
+ - **Uninstall path** at `src/uninstall.js` — `_isPreCompactHookEntry` + `'PreCompact'` added to the settings.json event-name loop in `_stepSpliceSettingsJson`; new `_stepBackupPreCompactHookFile` step in the orchestrator chain. Round-trips cleanly with install.
14
+
15
+ ### Documentation
16
+
17
+ - Audit-trail update: validated against `@jhizzard/termdeck@1.3.0`, the Sprint 64 close-out ship. Wave bundles (a) install-polish wizard with `--auto` MCP-mediated Supabase provisioning + 10-phase pipeline + OS-detection (macOS / Ubuntu / Docker fedora / Docker debian / Alpine / Arch / SUSE) + unified `init` orchestrator collapsing the new-user path from 15+ manual steps to "paste 2 credentials, click 3 buttons"; (b) Sprint 63 four carve-outs — codex `resolveTranscriptPath` strict birthtime epsilon + `adapter.spawn` honored by `spawnTerminalSession` + codex auto-update persisted-last-seen-version probe + `MIN_TRANSCRIPT_MESSAGES` env-configurable threshold; (c) Investigation 2 closure — `PreCompact` harness hook for Claude Code panels + server-side `onPanelPeriodicCapture` timer (10 min default, 1 KB throttle) for non-Claude panels; (d) security regression caught pre-merge by T4-CODEX adversarial audit — Supabase PAT was about to broadcast to every spawned PTY env via `secrets.env` merge; fixed with PAT non-persistence + PTY env exclusion list + dual-layer credential redaction (source-side regex + caller-side explicit). 295/295 root `npm test` green; T4-CODEX FINAL-VERDICT GREEN with file:line evidence; ~42 min wall-clock from inject to verdict (fastest 3+1+1 close to date).
18
+
8
19
  ## [1.2.0] — 2026-05-11
9
20
 
10
21
  ### Documentation
@@ -0,0 +1,277 @@
1
+ /**
2
+ * TermDeck pre-compact memory hook (Mnestra-direct, no rag-system dependency).
3
+ *
4
+ * Vendored into ~/.claude/hooks/memory-pre-compact.js by @jhizzard/termdeck-stack.
5
+ * Wired into ~/.claude/settings.json under hooks.PreCompact — fires BEFORE
6
+ * Claude Code compacts conversation context, capturing the in-flight session
7
+ * state into Mnestra before the harness vaporizes it.
8
+ *
9
+ * Why this hook exists (Sprint 64 Investigation 2 of
10
+ * docs/CRITICAL-READ-FIRST-2026-05-07.md): every long Claude Code session that
11
+ * crosses the auto-compaction boundary loses in-context state. The global
12
+ * CLAUDE.md "Before Context Gets Long" rule was advisory and unreliably
13
+ * followed under sprint pressure. PreCompact is the deterministic signal —
14
+ * see the canonical hooks docs at https://code.claude.com/docs/en/hooks for
15
+ * the event contract — and this hook captures a session_summary-shaped row
16
+ * with `source_type='pre_compact_snapshot'` so post-compaction `memory_recall`
17
+ * can resurface what was about to be lost.
18
+ *
19
+ * Two firing modes:
20
+ *
21
+ * 1. Claude Code PreCompact (primary). STDIN shape per the docs:
22
+ * { session_id, transcript_path, cwd, hook_event_name: "PreCompact",
23
+ * trigger: "auto"|"manual" }.
24
+ *
25
+ * 2. TermDeck server periodic-capture timer (Sprint 64 T3.4). Non-Claude
26
+ * panels (Codex/Gemini/Grok) have no PreCompact-equivalent. The TermDeck
27
+ * server spawns this hook every N minutes for each active non-Claude
28
+ * panel, draining the rolling transcript to Mnestra. STDIN shape adds:
29
+ * { sessionType, source_agent, mode: "periodic_checkpoint" }.
30
+ *
31
+ * Both modes share the rest of the pipeline:
32
+ * - Load ~/.termdeck/secrets.env on env-var gaps (Sprint 47.5 discipline).
33
+ * - Parse transcript via the adapter parser exported by
34
+ * memory-session-end.js (Sprint 38 module-export contract; no duplication).
35
+ * - Embed via OpenAI text-embedding-3-small.
36
+ * - POST ONE row to /rest/v1/memory_items with
37
+ * source_type='pre_compact_snapshot', category='workflow'.
38
+ *
39
+ * Fail-soft contract: any error (network, parse, env-var-missing, malformed
40
+ * transcript) logs and exits 0. NEVER blocks compaction — PreCompact CAN block
41
+ * via exit-2/decision:block, but losing the checkpoint is bad while blocking
42
+ * compaction would be worse (the user gets stuck). Match memory-session-end.js
43
+ * fail-soft.
44
+ *
45
+ * Version stamp (Sprint 64 T3.2 — initial cut):
46
+ * The marker `@termdeck/stack-installer-hook v<N>` below is read by both
47
+ * stack-installer's installPreCompactHook (version-aware overwrite under
48
+ * --yes) and `termdeck init --mnestra` (refreshBundledPreCompactHookIfNewer
49
+ * step). Bump the integer whenever a change here should overwrite an
50
+ * already-installed copy. Comment-only tweaks do not need a bump.
51
+ *
52
+ * @termdeck/stack-installer-hook v1
53
+ *
54
+ * Required env vars (validated at entry, after the secrets.env fallback):
55
+ * - SUPABASE_URL e.g. https://<project-ref>.supabase.co
56
+ * - SUPABASE_SERVICE_ROLE_KEY service-role key (needs INSERT on memory_items)
57
+ * - OPENAI_API_KEY sk-... for text-embedding-3-small
58
+ *
59
+ * Optional:
60
+ * - TERMDECK_HOOK_DEBUG=1 verbose logging
61
+ * - TERMDECK_PRECOMPACT_MIN_BYTES=5000 transcript size threshold (same default as
62
+ * memory-session-end.js — sub-5KB transcripts
63
+ * don't compact anyway, but the guard keeps
64
+ * synthetic test fixtures honest)
65
+ * - TERMDECK_HOOK_HELPERS_PATH=... override the memory-session-end.js path the
66
+ * hook require()s helpers from (tests use this)
67
+ *
68
+ * Co-existence with memory-session-end.js:
69
+ * - memory-session-end.js writes source_type='session_summary' (one per SessionEnd).
70
+ * - this hook writes source_type='pre_compact_snapshot' (one per PreCompact OR
71
+ * per periodic-capture tick).
72
+ * - Same source_session_id ties them together; future `memory_recall` filters
73
+ * can include or exclude pre-compact rows independently.
74
+ */
75
+
76
+ 'use strict';
77
+
78
+ const { existsSync, readFileSync, appendFileSync, statSync } = require('fs');
79
+ const path = require('path');
80
+ const os = require('os');
81
+
82
+ const LOG_FILE = path.join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
83
+
84
+ function log(msg) {
85
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [pre-compact] ${msg}\n`); }
86
+ catch (_) { /* fail-soft */ }
87
+ }
88
+ const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
89
+ function debug(msg) { if (DEBUG) log(`[debug] ${msg}`); }
90
+
91
+ // Load the SessionEnd hook's helpers via the Sprint 38 module-export contract
92
+ // (`require.main === module` ⇒ CLI; else exports object). Resolved in priority:
93
+ // 1. TERMDECK_HOOK_HELPERS_PATH env var (tests).
94
+ // 2. The installed SessionEnd hook at ~/.claude/hooks/memory-session-end.js —
95
+ // production path; vendored by installSessionEndHook from the same
96
+ // stack-installer assets dir this file lives in.
97
+ // 3. The bundled SessionEnd source sibling — used when this hook is exercised
98
+ // directly from the source tree (fence tests, dev repro).
99
+ function loadHelpers() {
100
+ const override = process.env.TERMDECK_HOOK_HELPERS_PATH;
101
+ const candidates = [
102
+ override,
103
+ path.join(os.homedir(), '.claude', 'hooks', 'memory-session-end.js'),
104
+ path.join(__dirname, 'memory-session-end.js'),
105
+ ].filter(Boolean);
106
+ for (const p of candidates) {
107
+ if (!existsSync(p)) continue;
108
+ try { return require(p); }
109
+ catch (err) {
110
+ log(`helper-load-failed: ${p} — ${err && err.message ? err.message : String(err)}`);
111
+ }
112
+ }
113
+ log('helpers-not-found: pre-compact hook needs memory-session-end.js bundled at ~/.claude/hooks/. Install via `npx @jhizzard/termdeck-stack`.');
114
+ return null;
115
+ }
116
+
117
+ const MIN_TRANSCRIPT_BYTES_PRE_COMPACT =
118
+ parseInt(process.env.TERMDECK_PRECOMPACT_MIN_BYTES || '5000', 10);
119
+
120
+ // Inline POST to /rest/v1/memory_items so we can set source_type='pre_compact_snapshot'
121
+ // without touching memory-session-end.js's postMemoryItem (which hardcodes
122
+ // source_type='session_summary'). Inlining keeps the Sprint 62 close-out path
123
+ // untouched — zero regression risk for SessionEnd flow.
124
+ async function postPreCompactSnapshot({
125
+ supabaseUrl, supabaseKey,
126
+ content, embedding,
127
+ project, sessionId,
128
+ sourceAgent,
129
+ }) {
130
+ try {
131
+ const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'apikey': supabaseKey,
136
+ 'Authorization': `Bearer ${supabaseKey}`,
137
+ 'Prefer': 'return=minimal',
138
+ },
139
+ body: JSON.stringify({
140
+ content,
141
+ embedding: `[${embedding.join(',')}]`,
142
+ source_type: 'pre_compact_snapshot',
143
+ category: 'workflow',
144
+ project,
145
+ source_session_id: sessionId || null,
146
+ source_agent: sourceAgent,
147
+ }),
148
+ });
149
+ if (!res.ok) {
150
+ const body = await res.text().catch(() => '');
151
+ log(`supabase-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
152
+ return false;
153
+ }
154
+ return true;
155
+ } catch (e) {
156
+ log(`supabase-insert-exception: ${e.message}`);
157
+ return false;
158
+ }
159
+ }
160
+
161
+ // Distinguishes the two firing modes from the STDIN payload shape:
162
+ // - { hook_event_name: "PreCompact", trigger } → Claude Code harness
163
+ // - { mode: "periodic_checkpoint", sessionType } → TermDeck server timer
164
+ // Returns { mode, trigger, sessionType, sourceAgent } resolved per mode.
165
+ function resolveFiringContext(data, helpers) {
166
+ const isClaudePreCompact = data && data.hook_event_name === 'PreCompact';
167
+ if (isClaudePreCompact) {
168
+ return {
169
+ mode: 'pre_compact',
170
+ trigger: data.trigger === 'manual' ? 'manual' : 'auto',
171
+ // Claude Code's PreCompact payload doesn't carry sessionType; default
172
+ // to the canonical adapter name so buildSummary's parser dispatch picks
173
+ // parseClaudeJsonl. Codex/Gemini/Grok never reach this branch — they
174
+ // route through the server-side periodic-capture branch below.
175
+ sessionType: 'claude-code',
176
+ sourceAgent: helpers.normalizeSourceAgent(data.source_agent || 'claude'),
177
+ };
178
+ }
179
+ return {
180
+ mode: 'periodic_checkpoint',
181
+ trigger: 'periodic',
182
+ sessionType: data.sessionType || data.session_type || 'claude-code',
183
+ sourceAgent: helpers.normalizeSourceAgent(data.source_agent || 'claude'),
184
+ };
185
+ }
186
+
187
+ async function processPreCompactPayload(input, helpers) {
188
+ let data;
189
+ try { data = JSON.parse(input); }
190
+ catch (e) { log(`parse-stdin-failed: ${e.message}`); return { status: 'parse-failed' }; }
191
+
192
+ const transcriptPath = data.transcript_path;
193
+ const cwd = data.cwd || '';
194
+ const sessionId = data.session_id || null;
195
+ if (!transcriptPath) { log('no-transcript-path: skipping'); return { status: 'no-transcript-path' }; }
196
+ if (!sessionId) { log('no-session-id: skipping'); return { status: 'no-session-id' }; }
197
+
198
+ const { mode, trigger, sessionType, sourceAgent } = resolveFiringContext(data, helpers);
199
+
200
+ let stat;
201
+ try { stat = statSync(transcriptPath); }
202
+ catch (e) {
203
+ log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`);
204
+ return { status: 'cannot-stat-transcript' };
205
+ }
206
+
207
+ if (stat.size < MIN_TRANSCRIPT_BYTES_PRE_COMPACT) {
208
+ debug(`small-transcript: ${stat.size} bytes — skipping ${mode} checkpoint`);
209
+ return { status: 'small-transcript' };
210
+ }
211
+
212
+ const env = helpers.readEnv();
213
+ if (!env) return { status: 'env-missing' };
214
+
215
+ const project = helpers.detectProject(cwd);
216
+ const built = helpers.buildSummary(transcriptPath, sessionType);
217
+ if (!built) {
218
+ debug(`buildSummary-skipped: <5 messages (parser=${sessionType}) — ${mode} hook bailing gracefully`);
219
+ return { status: 'too-few-messages' };
220
+ }
221
+ const { summary: baseSummary, messagesCount, durationMinutes, factsExtracted } = built;
222
+
223
+ // Prepend a checkpoint header so memory_recall results read clearly. Plain
224
+ // text (not JSON) so the line survives the embed roundtrip intact and is
225
+ // operator-readable in recall output. Fields chosen for at-a-glance
226
+ // recovery semantics: mode tells you what fired, trigger distinguishes
227
+ // auto-vs-manual-vs-periodic, agent says whose context this slice is from.
228
+ const header =
229
+ `[CHECKPOINT mode=${mode} trigger=${trigger} ` +
230
+ `agent=${sourceAgent} ` +
231
+ `messages=${messagesCount} ` +
232
+ `duration=${durationMinutes === null ? 'unknown' : `${durationMinutes}m`} ` +
233
+ `facts_remembered=${factsExtracted}]`;
234
+ const content = `${header}\n\n${baseSummary}`.slice(0, 7000);
235
+
236
+ const embedding = await helpers.embedText(content, env.openaiKey);
237
+ if (!embedding) return { status: 'embed-failed' };
238
+
239
+ const ok = await postPreCompactSnapshot({
240
+ supabaseUrl: env.supabaseUrl,
241
+ supabaseKey: env.supabaseKey,
242
+ content, embedding, project, sessionId, sourceAgent,
243
+ });
244
+
245
+ if (ok) {
246
+ log(`ingested-${mode}: project="${project}" session=${sessionId} trigger=${trigger} agent=${sourceAgent} bytes=${content.length} messages=${messagesCount} factsExtracted=${factsExtracted}`);
247
+ return { status: 'ingested', project, sessionId, sourceAgent, mode, trigger, messagesCount };
248
+ }
249
+ return { status: 'insert-failed' };
250
+ }
251
+
252
+ // Module-export contract — when run as a script, read stdin and process; when
253
+ // require()'d (tests + the TermDeck server's periodic-capture spawn helper),
254
+ // expose the inner functions.
255
+ if (require.main === module) {
256
+ let input = '';
257
+ process.stdin.setEncoding('utf8');
258
+ process.stdin.on('data', (chunk) => { input += chunk; });
259
+ process.stdin.on('end', () => {
260
+ const helpers = loadHelpers();
261
+ if (!helpers) { process.exit(0); return; }
262
+ processPreCompactPayload(input, helpers)
263
+ .catch((e) => log(`hook-error: ${e && e.message ? e.message : String(e)}`))
264
+ // Fail-soft: ALWAYS exit 0. Blocking compaction (exit 2 per Claude Code's
265
+ // hook contract) costs more than skipping a checkpoint.
266
+ .finally(() => process.exit(0));
267
+ });
268
+ } else {
269
+ module.exports = {
270
+ loadHelpers,
271
+ postPreCompactSnapshot,
272
+ processPreCompactPayload,
273
+ resolveFiringContext,
274
+ LOG_FILE,
275
+ MIN_TRANSCRIPT_BYTES_PRE_COMPACT,
276
+ };
277
+ }
@@ -138,6 +138,16 @@ const PROJECT_MAP = [
138
138
  ];
139
139
 
140
140
  const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
141
+ // Sprint 64 T2 (carve-out 2.2) — Sprint 63 EXIT-CAPTURE-VERIFICATION.md
142
+ // Finding #3 documented Brad's grok-canary panel silent-skipped at 4 msgs /
143
+ // 6,713 bytes of canary content because the legacy hard-coded `< 5 messages`
144
+ // gate fired before the MIN_TRANSCRIPT_BYTES floor could even matter. Codex
145
+ // audit posts are similarly content-rich-but-message-count-poor (one canonical
146
+ // turn). Default lowered to 1 — the 5 KB byte gate at `:140 + :795 + the
147
+ // `MIN_TRANSCRIPT_BYTES`-based skip at line 795` is now the sole primary noise
148
+ // filter; sub-5KB drips still get dropped. Env-configurable for operators who
149
+ // want the legacy permissive-to-zero floor or a higher cutoff than 1.
150
+ const MIN_TRANSCRIPT_MESSAGES = parseInt(process.env.TERMDECK_HOOK_MIN_MESSAGES || '1', 10);
141
151
  const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
142
152
 
143
153
  function log(msg) {
@@ -573,8 +583,10 @@ function buildSummary(transcriptPath, sessionType) {
573
583
 
574
584
  const messages = parser(raw);
575
585
 
576
- if (messages.length < 5) {
577
- debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping`);
586
+ // Sprint 64 T2 (carve-out 2.2) env-configurable floor, default 1. See
587
+ // `MIN_TRANSCRIPT_MESSAGES` declaration for the Brad grok-canary rationale.
588
+ if (messages.length < MIN_TRANSCRIPT_MESSAGES) {
589
+ debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping (TERMDECK_HOOK_MIN_MESSAGES=${MIN_TRANSCRIPT_MESSAGES})`);
578
590
  return null;
579
591
  }
580
592
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
@@ -53,6 +53,17 @@ const HOOK_DEST = path.join(HOOK_DEST_DIR, 'memory-session-end.js');
53
53
  const HOOK_SOURCE = path.join(__dirname, '..', 'assets', 'hooks', 'memory-session-end.js');
54
54
  const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
55
55
  const HOOK_TIMEOUT_SECONDS = 30;
56
+
57
+ // Sprint 64 T3 — PreCompact hook (Investigation 2 of
58
+ // docs/CRITICAL-READ-FIRST-2026-05-07.md). Fires BEFORE Claude Code compacts
59
+ // context, capturing session state into Mnestra under
60
+ // source_type='pre_compact_snapshot'. Lives alongside the SessionEnd hook in
61
+ // ~/.claude/hooks/ and re-uses memory-session-end.js helpers via the Sprint 38
62
+ // module-export contract.
63
+ const PRECOMPACT_HOOK_DEST = path.join(HOOK_DEST_DIR, 'memory-pre-compact.js');
64
+ const PRECOMPACT_HOOK_SOURCE = path.join(__dirname, '..', 'assets', 'hooks', 'memory-pre-compact.js');
65
+ const PRECOMPACT_HOOK_COMMAND = 'node ~/.claude/hooks/memory-pre-compact.js';
66
+ const PRECOMPACT_HOOK_TIMEOUT_SECONDS = 30;
56
67
  const SECRETS_PATH = path.join(HOME, '.termdeck', 'secrets.env');
57
68
 
58
69
  // Read ~/.termdeck/secrets.env into a plain object. Returns {} if the file
@@ -493,6 +504,48 @@ function _mergeSessionEndHookEntry(settings, opts = {}) {
493
504
  return { settings, status: migrated ? 'migrated-from-stop' : 'installed' };
494
505
  }
495
506
 
507
+ // Sprint 64 T3 — PreCompact entry detection + merge. Parallel to the SessionEnd
508
+ // helpers above, with the key difference that PreCompact didn't exist before
509
+ // this sprint so there's no Stop-style migration branch. Idempotent.
510
+ function _isPreCompactHookEntry(entry) {
511
+ return entry && typeof entry.command === 'string'
512
+ && entry.command.includes('memory-pre-compact.js');
513
+ }
514
+
515
+ // Pure: merges our PreCompact entry into the given settings object. Returns
516
+ // { settings, status } where status is 'already-installed' or 'installed'.
517
+ // Mutates the input. matcher='*' is the documented wildcard for PreCompact —
518
+ // fires on both auto-compact (token-limit-driven) AND manual /compact triggers.
519
+ function _mergePreCompactHookEntry(settings, opts = {}) {
520
+ const command = opts.command || PRECOMPACT_HOOK_COMMAND;
521
+ const timeout = opts.timeout != null ? opts.timeout : PRECOMPACT_HOOK_TIMEOUT_SECONDS;
522
+ const entry = { type: 'command', command, timeout };
523
+
524
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
525
+ if (!Array.isArray(settings.hooks.PreCompact)) settings.hooks.PreCompact = [];
526
+
527
+ for (const group of settings.hooks.PreCompact) {
528
+ if (!group || !Array.isArray(group.hooks)) continue;
529
+ if (group.hooks.some(_isPreCompactHookEntry)) {
530
+ return { settings, status: 'already-installed' };
531
+ }
532
+ }
533
+
534
+ // Append to a `*`-matcher group if present (covers both auto + manual); else
535
+ // create one. Hand-edited groups with specific matchers (e.g. matcher: 'auto')
536
+ // are left intact — a future user-installed hook gating on a specific trigger
537
+ // coexists with our wildcard group rather than getting overwritten.
538
+ const wildcardGroup = settings.hooks.PreCompact.find(
539
+ (g) => g && g.matcher === '*' && Array.isArray(g.hooks)
540
+ );
541
+ if (wildcardGroup) {
542
+ wildcardGroup.hooks.push(entry);
543
+ } else {
544
+ settings.hooks.PreCompact.push({ matcher: '*', hooks: [entry] });
545
+ }
546
+ return { settings, status: 'installed' };
547
+ }
548
+
496
549
  function _readSettingsJson(filePath) {
497
550
  if (!fs.existsSync(filePath)) {
498
551
  return { settings: {}, status: 'no-file' };
@@ -707,6 +760,113 @@ async function installSessionEndHook(opts = {}) {
707
760
  return { fileStatus, settingsStatus };
708
761
  }
709
762
 
763
+ // Sprint 64 T3 — install the PreCompact hook. Closes Investigation 2 of
764
+ // docs/CRITICAL-READ-FIRST-2026-05-07.md ("auto-commit on context compaction-
765
+ // near"). Mirrors installSessionEndHook closely but simpler: PreCompact is
766
+ // new in Sprint 64 so there's no Stop→SessionEnd-style legacy migration.
767
+ //
768
+ // File copy and settings.json merge are independent — a file-copy failure
769
+ // doesn't suppress the settings merge, and vice versa. Both errors fail-soft.
770
+ async function installPreCompactHook(opts = {}) {
771
+ const dryRun = !!opts.dryRun;
772
+ const sourcePath = opts.sourcePath || PRECOMPACT_HOOK_SOURCE;
773
+ const destPath = opts.destPath || PRECOMPACT_HOOK_DEST;
774
+ const settingsPath = opts.settingsPath || SETTINGS_JSON;
775
+ const promptInstall = opts.promptInstall
776
+ || (() => promptYesNo({ question: "Install TermDeck's PreCompact memory hook? (captures session state before Claude compacts)", defaultYes: true }));
777
+ const promptOverwrite = opts.promptOverwrite
778
+ || (() => promptYesNo({
779
+ question: `Existing pre-compact hook found at ${destPath}. Overwrite?`,
780
+ defaultYes: false,
781
+ }));
782
+
783
+ rule();
784
+ process.stdout.write(`${ANSI.bold}PreCompact memory hook${ANSI.reset}\n`);
785
+ process.stdout.write(`${ANSI.dim} Fires before Claude Code compacts conversation context, capturing the in-flight session state into Mnestra so long sessions don't leak findings on auto-compact.${ANSI.reset}\n\n`);
786
+
787
+ const userWantsInstall = opts.assumeYes ? true
788
+ : opts.assumeNo ? false
789
+ : await promptInstall();
790
+
791
+ if (!userWantsInstall) {
792
+ statusLine(`${ANSI.dim}─${ANSI.reset}`, 'pre-compact hook', 'skipped (user declined)');
793
+ process.stdout.write('\n');
794
+ return { fileStatus: 'declined', settingsStatus: 'declined' };
795
+ }
796
+
797
+ // 1. File copy. Reuses the version-stamp gate so future bumps of
798
+ // `@termdeck/stack-installer-hook v<N>` in the bundled file trigger an
799
+ // overwrite on `--yes` without prompting. A genuinely custom user file
800
+ // (unsigned, no TermDeck markers) is preserved — but in practice nobody
801
+ // has one of these on disk pre-Sprint-64 because PreCompact is new.
802
+ let fileStatus;
803
+ const cmp = _compareHookFiles(sourcePath, destPath);
804
+ if (cmp === 'missing-dest') {
805
+ if (dryRun) {
806
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would copy pre-compact hook to ${destPath}`);
807
+ fileStatus = 'would-copy';
808
+ } else {
809
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
810
+ fs.copyFileSync(sourcePath, destPath);
811
+ fs.chmodSync(destPath, 0o644);
812
+ statusLine(`${ANSI.green}+${ANSI.reset}`, 'pre-compact hook file', `copied to ${destPath}`);
813
+ fileStatus = 'copied';
814
+ }
815
+ } else if (cmp === 'identical') {
816
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'pre-compact hook file', 'already present, identical contents');
817
+ fileStatus = 'already-current';
818
+ } else {
819
+ const overwrite = opts.assumeYes
820
+ ? _hookSignatureUpgradeAvailable(sourcePath, destPath)
821
+ : opts.forceOverwrite ? true
822
+ : await promptOverwrite();
823
+ if (!overwrite) {
824
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'pre-compact hook file', `existing kept (differs from vendored copy)`);
825
+ fileStatus = 'kept-existing';
826
+ } else if (dryRun) {
827
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would overwrite ${destPath}`);
828
+ fileStatus = 'would-overwrite';
829
+ } else {
830
+ const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
831
+ try { fs.copyFileSync(destPath, `${destPath}.bak.${stamp}`); } catch (_) { /* best-effort */ }
832
+ fs.copyFileSync(sourcePath, destPath);
833
+ fs.chmodSync(destPath, 0o644);
834
+ statusLine(`${ANSI.green}↻${ANSI.reset}`, 'pre-compact hook file', `overwrote ${destPath}`);
835
+ fileStatus = 'overwritten';
836
+ }
837
+ }
838
+
839
+ // 2. Settings.json merge.
840
+ const read = _readSettingsJson(settingsPath);
841
+ let settingsStatus;
842
+ if (read.status === 'malformed') {
843
+ statusLine(`${ANSI.red}✗${ANSI.reset}`, 'settings.json', `malformed (${read.error}); not modified`);
844
+ settingsStatus = 'malformed';
845
+ } else {
846
+ const merged = _mergePreCompactHookEntry(read.settings);
847
+ if (merged.status === 'already-installed') {
848
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json PreCompact hook', 'already installed');
849
+ settingsStatus = 'already-installed';
850
+ } else if (dryRun) {
851
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge PreCompact hook into ${settingsPath}`);
852
+ settingsStatus = 'would-install';
853
+ } else {
854
+ _writeSettingsJson(settingsPath, merged.settings);
855
+ statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json PreCompact hook', 'merged');
856
+ settingsStatus = 'installed';
857
+ }
858
+ }
859
+
860
+ process.stdout.write('\n');
861
+ if (!dryRun && (fileStatus === 'copied' || settingsStatus === 'installed')) {
862
+ process.stdout.write(` ${ANSI.dim}PreCompact hook installed at ${destPath}.${ANSI.reset}\n`);
863
+ process.stdout.write(` ${ANSI.dim}It runs before Claude Code compacts context, writing a pre_compact_snapshot row to Mnestra.${ANSI.reset}\n`);
864
+ process.stdout.write(` ${ANSI.dim}Sprint 64 / Investigation 2 / docs/CRITICAL-READ-FIRST-2026-05-07.md.${ANSI.reset}\n\n`);
865
+ }
866
+
867
+ return { fileStatus, settingsStatus };
868
+ }
869
+
710
870
  // ── Next steps ──────────────────────────────────────────────────────
711
871
 
712
872
  function printNextSteps(plan, opts) {
@@ -838,6 +998,15 @@ async function main(argv) {
838
998
  assumeYes: args.yes,
839
999
  });
840
1000
 
1001
+ // Sprint 64 T3 — bundle the PreCompact memory hook (Investigation 2 closure).
1002
+ // Same prompt UX as SessionEnd: default-on, opt-out via prompt; --yes
1003
+ // accepts the install. The two hooks coexist — SessionEnd captures on
1004
+ // /exit, PreCompact captures before context compaction.
1005
+ await installPreCompactHook({
1006
+ dryRun: args.dryRun,
1007
+ assumeYes: args.yes,
1008
+ });
1009
+
841
1010
  printNextSteps(wantedLayers, { dryRun: args.dryRun });
842
1011
 
843
1012
  if (failures > 0) {
@@ -872,6 +1041,14 @@ module.exports.HOOK_SOURCE = HOOK_SOURCE;
872
1041
  module.exports.HOOK_DEST = HOOK_DEST;
873
1042
  module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
874
1043
  module.exports.HOOK_SOURCE = HOOK_SOURCE;
1044
+ // Sprint 64 T3 — PreCompact hook (Investigation 2 closure) exports.
1045
+ module.exports.installPreCompactHook = installPreCompactHook;
1046
+ module.exports._isPreCompactHookEntry = _isPreCompactHookEntry;
1047
+ module.exports._mergePreCompactHookEntry = _mergePreCompactHookEntry;
1048
+ module.exports.PRECOMPACT_HOOK_COMMAND = PRECOMPACT_HOOK_COMMAND;
1049
+ module.exports.PRECOMPACT_HOOK_SOURCE = PRECOMPACT_HOOK_SOURCE;
1050
+ module.exports.PRECOMPACT_HOOK_DEST = PRECOMPACT_HOOK_DEST;
1051
+ module.exports.PRECOMPACT_HOOK_TIMEOUT_SECONDS = PRECOMPACT_HOOK_TIMEOUT_SECONDS;
875
1052
  module.exports._mcpInternals = _mcpInternals;
876
1053
  module.exports.MCP_CONFIG_PATH = MCP_CONFIG;
877
1054
  module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
package/src/uninstall.js CHANGED
@@ -60,6 +60,17 @@ function _isSessionEndHookEntry(entry) {
60
60
  && entry.command.includes('memory-session-end.js');
61
61
  }
62
62
 
63
+ // Sprint 64 T3 — PreCompact entry predicate (Investigation 2 closure). Used
64
+ // alongside _isSessionEndHookEntry to splice both hook kinds during uninstall.
65
+ function _isPreCompactHookEntry(entry) {
66
+ return entry && typeof entry.command === 'string'
67
+ && entry.command.includes('memory-pre-compact.js');
68
+ }
69
+
70
+ function _isAnyTermdeckHookEntry(entry) {
71
+ return _isSessionEndHookEntry(entry) || _isPreCompactHookEntry(entry);
72
+ }
73
+
63
74
  // ── Args ────────────────────────────────────────────────────────────
64
75
 
65
76
  function parseArgs(argv) {
@@ -217,6 +228,8 @@ function _resolvePaths(home, platform) {
217
228
  claudeJson: path.join(home, '.claude.json'),
218
229
  settingsJson: path.join(home, '.claude', 'settings.json'),
219
230
  hookFile: path.join(home, '.claude', 'hooks', 'memory-session-end.js'),
231
+ // Sprint 64 T3 — PreCompact hook destination (Investigation 2 closure).
232
+ preCompactHookFile: path.join(home, '.claude', 'hooks', 'memory-pre-compact.js'),
220
233
  launchAgentsDir: path.join(home, 'Library', 'LaunchAgents'),
221
234
  launchAgentGlob: 'com.jhizzard.termdeck.', // prefix match against .plist files
222
235
  systemdUnit: path.join(home, '.config', 'systemd', 'user', 'termdeck.service'),
@@ -237,17 +250,18 @@ function _settingsJsonHasOurHook(_fs, settingsJson) {
237
250
  try { parsed = JSON.parse(_fs.readFileSync(settingsJson, 'utf8') || '{}'); }
238
251
  catch (_) { return false; }
239
252
  if (!parsed || !parsed.hooks) return false;
240
- for (const event of ['Stop', 'SessionEnd']) {
253
+ // Sprint 64 T3 also probe PreCompact wiring (Investigation 2 closure).
254
+ for (const event of ['Stop', 'SessionEnd', 'PreCompact']) {
241
255
  const arr = parsed.hooks[event];
242
256
  if (!Array.isArray(arr)) continue;
243
257
  for (const elem of arr) {
244
258
  if (!elem) continue;
245
259
  // Canonical Claude-Code group shape: { matcher, hooks: [{ type, command }] }
246
- if (Array.isArray(elem.hooks) && elem.hooks.some(_isSessionEndHookEntry)) return true;
260
+ if (Array.isArray(elem.hooks) && elem.hooks.some(_isAnyTermdeckHookEntry)) return true;
247
261
  // Legacy / hand-edited flat shape: { type, command } directly in array.
248
262
  // T3 (Sprint 61 18:50 ET) found that real-world fixtures use this
249
263
  // alternative shape, and our uninstall must handle both.
250
- if (_isSessionEndHookEntry(elem)) return true;
264
+ if (_isAnyTermdeckHookEntry(elem)) return true;
251
265
  }
252
266
  }
253
267
  return false;
@@ -288,6 +302,9 @@ function _detectInstallState(_fs, paths) {
288
302
  hasOurHookInSettings: _settingsJsonHasOurHook(_fs, paths.settingsJson),
289
303
  hasHookFile: _fs.existsSync(paths.hookFile),
290
304
  hookBakFiles: _findHookBakFiles(_fs, paths.hookFile),
305
+ // Sprint 64 T3 — PreCompact hook file detection (Investigation 2 closure).
306
+ hasPreCompactHookFile: _fs.existsSync(paths.preCompactHookFile),
307
+ preCompactHookBakFiles: _findHookBakFiles(_fs, paths.preCompactHookFile),
291
308
  launchAgents,
292
309
  systemdActive,
293
310
  };
@@ -336,6 +353,8 @@ function _isFullyClean(state) {
336
353
  && !state.hasMnestraMcpEntry
337
354
  && !state.hasOurHookInSettings
338
355
  && !state.hasHookFile
356
+ // Sprint 64 T3 — fully-clean predicate now also covers the PreCompact hook.
357
+ && !state.hasPreCompactHookFile
339
358
  && state.launchAgents.length === 0
340
359
  && !state.systemdActive;
341
360
  }
@@ -468,7 +487,10 @@ function _stepSpliceSettingsJson(_fs, paths, opts) {
468
487
  return out;
469
488
  }
470
489
  let removedCount = 0;
471
- for (const event of ['Stop', 'SessionEnd']) {
490
+ // Sprint 64 T3 added 'PreCompact' to the event-name list and switched the
491
+ // predicate to `_isAnyTermdeckHookEntry` so a single splice pass also strips
492
+ // PreCompact wirings (Investigation 2 closure).
493
+ for (const event of ['Stop', 'SessionEnd', 'PreCompact']) {
472
494
  const arr = parsed.hooks[event];
473
495
  if (!Array.isArray(arr)) continue;
474
496
  // Two shapes coexist in the wild (T3 finding 2026-05-07 18:50 ET):
@@ -480,7 +502,7 @@ function _stepSpliceSettingsJson(_fs, paths, opts) {
480
502
  for (const elem of arr) {
481
503
  if (elem && Array.isArray(elem.hooks)) {
482
504
  const before = elem.hooks.length;
483
- elem.hooks = elem.hooks.filter((e) => !_isSessionEndHookEntry(e));
505
+ elem.hooks = elem.hooks.filter((e) => !_isAnyTermdeckHookEntry(e));
484
506
  removedCount += before - elem.hooks.length;
485
507
  }
486
508
  }
@@ -488,7 +510,7 @@ function _stepSpliceSettingsJson(_fs, paths, opts) {
488
510
  for (const elem of arr) {
489
511
  if (!elem) continue;
490
512
  // Drop flat entries that match our hook.
491
- if (_isSessionEndHookEntry(elem) && !Array.isArray(elem.hooks)) {
513
+ if (_isAnyTermdeckHookEntry(elem) && !Array.isArray(elem.hooks)) {
492
514
  removedCount += 1;
493
515
  continue;
494
516
  }
@@ -544,6 +566,33 @@ function _stepBackupHookFile(_fs, paths, opts) {
544
566
  return out;
545
567
  }
546
568
 
569
+ // Sprint 64 T3 — same shape as _stepBackupHookFile but targets the PreCompact
570
+ // hook file (Investigation 2 closure). Independent of the SessionEnd backup —
571
+ // either file's absence/presence is OK; both are renamed-not-deleted so user
572
+ // customizations are recoverable.
573
+ function _stepBackupPreCompactHookFile(_fs, paths, opts) {
574
+ const out = { name: 'pre-compact-hook-file-backup', status: 'pending', detail: '' };
575
+ if (!_fs.existsSync(paths.preCompactHookFile)) {
576
+ out.status = 'skipped'; out.detail = 'not present';
577
+ return out;
578
+ }
579
+ const stamp = _isoStamp(opts._now);
580
+ const bakPath = `${paths.preCompactHookFile}.bak.${stamp}`;
581
+ if (opts.dryRun) {
582
+ out.status = 'would-rename';
583
+ out.detail = `would rename ${paths.preCompactHookFile} → ${bakPath}`;
584
+ return out;
585
+ }
586
+ try {
587
+ _fs.renameSync(paths.preCompactHookFile, bakPath);
588
+ out.status = 'renamed';
589
+ out.detail = `${paths.preCompactHookFile} → ${bakPath}`;
590
+ } catch (e) {
591
+ out.status = 'error'; out.detail = `rename failed: ${e.message}`;
592
+ }
593
+ return out;
594
+ }
595
+
547
596
  // Step 6 (darwin only): LaunchAgents — `launchctl unload` BEFORE `rm`. The
548
597
  // unload call's exit code is non-fatal: the agent may not be loaded in the
549
598
  // current session, especially in tests.
@@ -759,6 +808,7 @@ function _printPreflight(out, state, paths, opts) {
759
808
  if (state.hasMnestraMcpEntry) lines.push(` ${ANSI.cyan}•${ANSI.reset} mcpServers.mnestra in ${paths.claudeJson} ${ANSI.dim}(surgical splice — other entries preserved)${ANSI.reset}`);
760
809
  if (state.hasOurHookInSettings) lines.push(` ${ANSI.cyan}•${ANSI.reset} hooks.{Stop,SessionEnd} entries in ${paths.settingsJson} ${ANSI.dim}(surgical splice — other entries preserved)${ANSI.reset}`);
761
810
  if (state.hasHookFile) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.hookFile} ${ANSI.dim}(renamed to .bak.<timestamp>, not deleted)${ANSI.reset}`);
811
+ if (state.hasPreCompactHookFile) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.preCompactHookFile} ${ANSI.dim}(renamed to .bak.<timestamp>, not deleted)${ANSI.reset}`);
762
812
  for (const p of state.launchAgents) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${p} ${ANSI.dim}(launchctl unload + rm)${ANSI.reset}`);
763
813
  if (state.systemdActive) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.systemdUnit} ${ANSI.dim}(systemctl --user disable --now + rm)${ANSI.reset}`);
764
814
  if (opts.purgeSupabase) lines.push(` ${ANSI.red}•${ANSI.reset} ${ANSI.bold}--purge-supabase: will DROP Mnestra/Rumen tables in your Supabase project${ANSI.reset}`);
@@ -869,6 +919,10 @@ async function uninstall(opts = {}) {
869
919
  (o) => _stepSpliceClaudeJson(_fs, paths, o),
870
920
  (o) => _stepSpliceSettingsJson(_fs, paths, o),
871
921
  (o) => _stepBackupHookFile(_fs, paths, o),
922
+ // Sprint 64 T3 — PreCompact hook backup step (Investigation 2 closure).
923
+ // Runs after the SessionEnd backup so a clean install with both hooks
924
+ // present produces .bak siblings for both files with a consistent stamp.
925
+ (o) => _stepBackupPreCompactHookFile(_fs, paths, o),
872
926
  (o) => _stepRemoveLaunchAgents(_fs, _spawnSync, paths, o),
873
927
  (o) => _stepRemoveSystemdUnit(_fs, _spawnSync, paths, o),
874
928
  ]) {
@@ -921,6 +975,10 @@ module.exports = {
921
975
  parseArgs,
922
976
  // Test hooks — exposed so unit tests can drive primitives without a full run.
923
977
  _isSessionEndHookEntry,
978
+ // Sprint 64 T3 — PreCompact uninstall surface (Investigation 2 closure).
979
+ _isPreCompactHookEntry,
980
+ _isAnyTermdeckHookEntry,
981
+ _stepBackupPreCompactHookFile,
924
982
  _resolvePaths,
925
983
  _detectInstallState,
926
984
  _isFullyClean,