@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 +11 -0
- package/assets/hooks/memory-pre-compact.js +277 -0
- package/assets/hooks/memory-session-end.js +14 -2
- package/package.json +1 -1
- package/src/index.js +177 -0
- package/src/uninstall.js +64 -6
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
|
-
|
|
577
|
-
|
|
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
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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) => !
|
|
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 (
|
|
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,
|