crbro-memory 2.7.0 → 2.7.1

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.
@@ -1,608 +1,608 @@
1
- #!/usr/bin/env node
2
- // CRBRO — Claude Code lifecycle hook: compact without losing the thread
3
- //
4
- // Two modes, picked by the first argument:
5
- //
6
- // pre-compact PreCompact. Before the context is summarised, writes a
7
- // mechanical checkpoint of the session — the last two things
8
- // the user asked, the last state of the task list, the open
9
- // items of the brain, the folder and its git remote — to
10
- // <brain>/checkpoints/<session_id>.json. No model is called:
11
- // it is a read of the transcript, nothing more. Then prints
12
- // the "keep what is not saved yet" reminder the hand-written
13
- // PreCompact hooks print today, so installing this is a
14
- // superset of what was there.
15
- //
16
- // session-start SessionStart. Prints the boot notice — the one
17
- // `install-boot` writes (BOOT_NOTICE below is the one copy of
18
- // it; the CLI imports it from here), here with the folder
19
- // name filled in as project=… — a "Project: … · git: …"
20
- // line, and — only when the session restarts after a
21
- // compaction and a checkpoint of that session younger than
22
- // 24 h exists — a "Resuming after compaction" block, never
23
- // longer than 1,500 characters.
24
- //
25
- // Flags: `--no-boot` (session-start) and `--no-reminder` (pre-compact) leave
26
- // out the text a hand-written hook of the user already prints, so the
27
- // installer never makes the model read the same instruction twice.
28
- //
29
- // Nothing reaches disk unredacted, and every text is redacted whole before it
30
- // is cut to length: a cut first could leave half a token the patterns no
31
- // longer recognise. The secret patterns are a copy of
32
- // src/engine/secrets.ts, because this file is copied to ~/.claude/crbro-hooks
33
- // and cannot import the package. tests/lifecycle.hook.test.ts compares both
34
- // lists pattern by pattern: they cannot drift without a red test.
35
- //
36
- // Failure contract: dependency-free, never blocks, never asks. stdin that
37
- // never ends (the Windows PowerShell wrapper can swallow it), a missing
38
- // transcript, an unreadable brain, bad JSON — all degrade to printing what can
39
- // be printed. A watchdog ends the process at WATCHDOG_MS. Always exit 0.
40
-
41
- import {
42
- readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, unlinkSync,
43
- openSync, readSync, closeSync, fstatSync, writeSync, existsSync,
44
- } from 'node:fs';
45
- import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
46
- import { homedir } from 'node:os';
47
-
48
- // ─── Texts ───────────────────────────────────────────────────────
49
-
50
- /** What install-boot prints for Claude Code, and what session-start prints when it knows no folder. One copy. */
51
- export const BOOT_NOTICE =
52
- 'CRBRO: call mcp__crbro__crbro_boot as your FIRST tool action, before answering, ' +
53
- 'with project=<name of the working folder or repo>, ' +
54
- 'unless this session already contains its result. Discover deferred CRBRO tools first if needed. ' +
55
- 'Apply the protocol_enforcement block it returns for the rest of the session.';
56
-
57
- /** The folder name as it goes into project="…": one line, no quotes, bounded. */
58
- function projectName(cwd) {
59
- return String(basename(cwd || '') || '').replace(/["\r\n\t]/g, '').slice(0, 120);
60
- }
61
-
62
- /** The boot notice with the folder already filled in, so P5 (project_neurons) is used. */
63
- export function bootNotice(project) {
64
- const name = projectName(project ? `/${project}` : '');
65
- if (!name) return BOOT_NOTICE;
66
- return BOOT_NOTICE.replace('with project=<name of the working folder or repo>', `with project="${name}"`);
67
- }
68
-
69
- /** The PreCompact reminder: the same job as the hand-written crbro-precompact.txt. */
70
- export const PRECOMPACT_REMINDER = [
71
- 'CRBRO — compaction is about to happen. When writing the summary, keep explicitly:',
72
- '1. Every fact, decision, piece of architecture or work done in this session that has NOT been saved to CRBRO yet ' +
73
- '(mcp__crbro__crbro_learn / mcp__crbro__crbro_consolidate). Mark it "⚠️ PENDING CRBRO SAVE" with enough detail to save it later without loss.',
74
- '2. The CRBRO state of the session: what was already saved and what was not.',
75
- 'After the compaction, the FIRST action is mcp__crbro__crbro_learn with everything marked as pending.',
76
- ].join('\n');
77
-
78
- export const RESUME_CAP = 1500;
79
- const RESUME_MAX_AGE_MS = 24 * 3600 * 1000;
80
- const PRUNE_AGE_MS = 7 * 24 * 3600 * 1000;
81
- const TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024;
82
- const REQUEST_MAX = 500;
83
- const TASK_MAX = 200;
84
- const TASKS_KEPT = 40;
85
- const OPEN_ITEMS_KEPT = 10;
86
- const STDIN_WAIT_MS = 1500;
87
- const WATCHDOG_MS = 5000;
88
-
89
- // ─── Redaction (mirror of src/engine/secrets.ts) ─────────────────
90
-
91
- /** Same kinds, same sources, same flags as PATTERNS in src/engine/secrets.ts. Tested. */
92
- export const SECRET_PATTERNS = [
93
- { kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
94
- { kind: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
95
- { kind: 'OpenAI key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
96
- { kind: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
97
- { kind: 'Google API key', re: /\bAIza[A-Za-z0-9_-]{35}\b/g },
98
- { kind: 'Supabase token', re: /\bsbp_[a-f0-9]{40,}\b/g },
99
- { kind: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
100
- { kind: 'Stripe key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b/g },
101
- { kind: 'AWS access key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
102
- {
103
- kind: 'AWS secret key',
104
- re: /\b(?:[Aa][Ww][Ss][ _-]?)?[Ss]ecret[ _-]?(?:[Aa]ccess[ _-]?)?[Kk]ey\b[^\n]{0,15}?(?<![A-Za-z0-9/+])((?=[A-Za-z0-9/+]*[A-Z])(?=[A-Za-z0-9/+]*[a-z])(?=[A-Za-z0-9/+]*[0-9])[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+])/g,
105
- },
106
- { kind: 'Twilio account SID', re: /\bAC[0-9a-fA-F]{32,34}\b/g },
107
- {
108
- kind: 'auth token (hex)',
109
- re: /\bauth[ _-]?token\b[^\S\n]{0,3}(?:[:=]|es|is)?[^\S\n]{0,3}["'`]?([0-9a-fA-F]{30,34})\b/gi,
110
- },
111
- { kind: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
112
- { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
113
- {
114
- kind: 'WordPress application password',
115
- re: /\b(?:application|app)[ _-]?password\b[^\n]{0,80}?\b([A-Za-z0-9]{4}(?: [A-Za-z0-9]{4}){5})\b/gi,
116
- },
117
- {
118
- kind: 'password',
119
- re: /\b(?:password|passwd|contrase[nñ]a|clave)\s*(?:es|is)?\s*[:=]\s*["'`]?([^\s"'`,;]{8,})/gi,
120
- },
121
- {
122
- kind: 'password (prose)',
123
- re: /\b(?:password|passwd|contrase[nñ]a|clave)\b(?:\s+(?:de|del|of|for)\s+[^\s]{1,30})?\s+(?:es|is)\s+["'`]?((?=[^\s"'`,;]*[A-Za-z])(?=[^\s"'`,;]*[0-9])[^\s"'`,;]{8,})/gi,
124
- },
125
- {
126
- kind: 'split credential',
127
- re: /\b(?:token|key|clave|password|contrase[nñ]a|secret|secreto)\b[^.?!\n]{0,40}?\b(?:empieza|comienza|starts?)\s+(?:por|con|with)\s+(\S{2,15}[^.?!\n]{0,30}?\b(?:sigue con|contin[uú]a con|y luego|followed by|then|continues with)\s+["'`]?(?=[A-Za-z0-9_\-+/=]*[A-Za-z])(?=[A-Za-z0-9_\-+/=]*[0-9])[A-Za-z0-9_\-+/=]{16,})/gi,
128
- },
129
- {
130
- kind: 'connection string credentials',
131
- re: /\b[a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+:[^\s:@/]+@[^\s/]+/gi,
132
- },
133
- {
134
- kind: 'generic API key/secret',
135
- re: /\b(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|secret|token)\s*[:=]\s*["'`]?(?!\/)((?=[A-Za-z0-9_\-+]*[A-Za-z])(?=[A-Za-z0-9_\-+]*[0-9])[A-Za-z0-9_\-+]{24,})/gi,
136
- },
137
- { kind: 'SendGrid key', re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
138
- { kind: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g },
139
- ];
140
-
141
- /** Same algorithm as redact() in src/engine/secrets.ts: left to right, overlaps skipped, longer span wins a tie. */
142
- export function redact(text) {
143
- if (typeof text !== 'string' || !text) return '';
144
- const hits = [];
145
- for (const { kind, re } of SECRET_PATTERNS) {
146
- re.lastIndex = 0;
147
- let m;
148
- while ((m = re.exec(text)) !== null) {
149
- const value = m[1] !== undefined ? m[1] : m[0];
150
- const at = m[1] !== undefined ? m.index + m[0].indexOf(m[1]) : m.index;
151
- hits.push({ kind, index: at, length: value.length });
152
- if (m[0].length === 0) re.lastIndex++;
153
- }
154
- }
155
- if (hits.length === 0) return text;
156
- hits.sort((a, b) => a.index - b.index || b.length - a.length);
157
- let out = '';
158
- let cursor = 0;
159
- for (const h of hits) {
160
- if (h.index < cursor) continue;
161
- out += text.slice(cursor, h.index) + `[REDACTED: ${h.kind}]`;
162
- cursor = h.index + h.length;
163
- }
164
- return out + text.slice(cursor);
165
- }
166
-
167
- /** Every string in a plain JSON value, redacted. What gets written is only ever the output of this. */
168
- export function redactDeep(value) {
169
- if (typeof value === 'string') return redact(value);
170
- if (Array.isArray(value)) return value.map(redactDeep);
171
- if (value && typeof value === 'object') {
172
- const out = {};
173
- for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v);
174
- return out;
175
- }
176
- return value;
177
- }
178
-
179
- // ─── Where things are ────────────────────────────────────────────
180
-
181
- /** Mirror of resolveBrainDir() in src/engine/brain.ts (tested), without its warnings. */
182
- export function brainDir(env = process.env) {
183
- const home = env.HOME || env.USERPROFILE || homedir();
184
- const fallback = join(home, '.crbro');
185
- const raw = (env.CRBRO_PATH || '').trim();
186
- if (!raw) return fallback;
187
- if (/\$\{|%[A-Za-z_][A-Za-z0-9_]*%/.test(raw)) return fallback;
188
- if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) return join(home, raw.slice(1));
189
- if (!isAbsolute(raw)) return join(home, raw);
190
- return raw;
191
- }
192
-
193
- export function checkpointDir(env = process.env) {
194
- return join(brainDir(env), 'checkpoints');
195
- }
196
-
197
- function safeSessionId(id) {
198
- return String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 100);
199
- }
200
-
201
- function clip(text, max) {
202
- const t = String(text || '').replace(/\s+/g, ' ').trim();
203
- return t.length > max ? t.slice(0, max - 1) + '…' : t;
204
- }
205
-
206
- /**
207
- * The remote of the repository `cwd` is in, read from .git/config. No `git`
208
- * process: spawning one can hang on a credential helper, and this is a hook.
209
- * Credentials embedded in the URL are dropped before anything else sees it.
210
- */
211
- export function gitRemote(cwd) {
212
- try {
213
- let dir = resolve(cwd || process.cwd());
214
- for (let i = 0; i < 40; i++) {
215
- const dotGit = join(dir, '.git');
216
- if (existsSync(dotGit)) {
217
- let gitDir = dotGit;
218
- if (statSync(dotGit).isFile()) {
219
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'));
220
- if (!m) return null;
221
- gitDir = resolve(dir, m[1].trim());
222
- }
223
- // A worktree keeps its config in the common dir.
224
- try {
225
- const common = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
226
- if (common) gitDir = resolve(gitDir, common);
227
- } catch { /* not a worktree */ }
228
- return remoteFromConfig(readFileSync(join(gitDir, 'config'), 'utf8'));
229
- }
230
- const up = dirname(dir);
231
- if (up === dir) return null;
232
- dir = up;
233
- }
234
- } catch { /* no repo, or unreadable: no remote */ }
235
- return null;
236
- }
237
-
238
- function remoteFromConfig(config) {
239
- let section = null;
240
- const urls = new Map();
241
- for (const line of config.split(/\r?\n/)) {
242
- const head = /^\s*\[\s*remote\s+"([^"]+)"\s*\]/.exec(line);
243
- if (head) { section = head[1]; continue; }
244
- if (/^\s*\[/.test(line)) { section = null; continue; }
245
- const url = section && /^\s*url\s*=\s*(.+?)\s*$/.exec(line);
246
- if (url && !urls.has(section)) urls.set(section, url[1]);
247
- }
248
- const raw = urls.get('origin') ?? [...urls.values()][0];
249
- if (!raw) return null;
250
- return stripUrlCredentials(raw);
251
- }
252
-
253
- /**
254
- * A remote without what should never be printed: user, password, query and
255
- * fragment. A URL with a scheme is parsed; an scp-like "user@host:path" loses
256
- * everything up to the LAST '@' before the host, so a password with an '@' in
257
- * it does not leave half of itself behind.
258
- */
259
- export function stripUrlCredentials(raw) {
260
- const text = String(raw || '').trim();
261
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
262
- try {
263
- const u = new URL(text);
264
- return `${u.protocol}//${u.host}${u.pathname}`;
265
- } catch {
266
- // Not parseable (e.g. a raw '@' in the password): cut by hand.
267
- const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(.*)$/i.exec(text);
268
- if (!m) return null;
269
- const host = m[2].slice(m[2].lastIndexOf('@') + 1);
270
- return `${m[1]}${host}${m[3].replace(/[?#].*$/, '')}`;
271
- }
272
- }
273
- // scp-like: [user@]host:path — no password possible, but drop the user too.
274
- const colon = text.search(/:(?!\/\/)/);
275
- const at = text.lastIndexOf('@', colon === -1 ? text.length : colon);
276
- return (at === -1 ? text : text.slice(at + 1)).replace(/[?#].*$/, '');
277
- }
278
-
279
- // ─── Reading the transcript ──────────────────────────────────────
280
-
281
- /** JSONL entries, tolerant of a huge file (tail only), a torn last line and junk. */
282
- export function readTranscript(path) {
283
- if (!path || typeof path !== 'string') return [];
284
- let text;
285
- try {
286
- const fd = openSync(path, 'r');
287
- try {
288
- const size = fstatSync(fd).size;
289
- const start = Math.max(0, size - TRANSCRIPT_MAX_BYTES);
290
- const buf = Buffer.alloc(size - start);
291
- readSync(fd, buf, 0, buf.length, start);
292
- text = buf.toString('utf8');
293
- if (start > 0) text = text.slice(text.indexOf('\n') + 1);
294
- } finally {
295
- closeSync(fd);
296
- }
297
- } catch {
298
- return [];
299
- }
300
- const out = [];
301
- for (const line of text.split('\n')) {
302
- if (!line.trim()) continue;
303
- try { out.push(JSON.parse(line)); } catch { /* torn or foreign line */ }
304
- }
305
- return out;
306
- }
307
-
308
- function contentOf(entry) {
309
- const msg = entry && entry.message;
310
- if (!msg) return null;
311
- return msg.content;
312
- }
313
-
314
- /** Lines the client writes on the person's behalf. Same list as NOT_A_REQUEST in src/engine/postmortem.ts (tested). */
315
- export const NOT_A_REQUEST = /^(?:<local-command-|<system-reminder>|<command-stdout>|<command-message>|<bash-|<user-prompt-submit-hook>|<task-notification>|\[Request interrupted|Caveat: )/;
316
-
317
- /**
318
- * Slash commands that configure the client instead of asking for work. What
319
- * they carry is not "the last request" and must not come back as one.
320
- */
321
- export const NOT_WORK_COMMANDS = /^\/(?:compact|model|clear|config|effort|fast|cost|usage|status|help|login|logout|theme|permissions|resume|exit|quit|vim|output-style|statusline|terminal-setup|doctor|context|mcp|hooks|agents|ide|rename|export|release-notes|upgrade|privacy-settings|add-dir|memory|plugin|skills|tasks|bashes|todos|feedback|bug|keybindings|sandbox|remote-control|rewind)\b/i;
322
-
323
- /** The text a person typed in one user entry, or '' when it is a tool result, a meta line or a reminder. */
324
- function userText(entry) {
325
- if (!entry || entry.type !== 'user' || entry.isMeta || entry.isSidechain || entry.isCompactSummary) return '';
326
- // A background task's notification or another agent's message arrives as a
327
- // user entry. Its text can come from a web page or a third-party repo: it
328
- // is never presented back as something the user asked.
329
- if (entry.origin && typeof entry.origin.kind === 'string' && entry.origin.kind !== 'human') return '';
330
- const content = contentOf(entry);
331
- let text = '';
332
- if (typeof content === 'string') text = content;
333
- else if (Array.isArray(content)) {
334
- if (content.some(b => b && b.type === 'tool_result')) return '';
335
- text = content.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
336
- }
337
- text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
338
- // A slash command arrives as tags; keep what was typed.
339
- const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
340
- if (name) {
341
- const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
342
- text = `${name[1].trim()} ${args ? args[1].trim() : ''}`.trim();
343
- // The command that triggers the compaction, /model and the like are not what was being worked on.
344
- if (NOT_WORK_COMMANDS.test(text)) return '';
345
- }
346
- if (!text || NOT_A_REQUEST.test(text)) return '';
347
- return text;
348
- }
349
-
350
- /** The last `n` things the user asked, newest first. */
351
- export function lastUserRequests(entries, n = 2) {
352
- const out = [];
353
- for (let i = entries.length - 1; i >= 0 && out.length < n; i--) {
354
- const t = userText(entries[i]);
355
- if (t) out.push(clip(redact(t), REQUEST_MAX));
356
- }
357
- return out;
358
- }
359
-
360
- function taskText(input) {
361
- if (!input || typeof input !== 'object') return '';
362
- return String(input.subject ?? input.content ?? input.title ?? input.description ?? input.activeForm ?? '');
363
- }
364
-
365
- /**
366
- * The last state of the task list. Two shapes are known — TodoWrite, which
367
- * rewrites the whole list each time, and TaskCreate/TaskUpdate, which edit it
368
- * one task at a time — and they are replayed in transcript order, so whichever
369
- * came last wins. Anything not recognised is skipped, never guessed.
370
- */
371
- export function lastTasks(entries) {
372
- let tasks = [];
373
- const pendingIds = new Map(); // tool_use_id → task waiting for the id its result announces
374
- let seq = 0;
375
- for (const entry of entries) {
376
- if (!entry || entry.isSidechain) continue;
377
- const content = contentOf(entry);
378
- if (!Array.isArray(content)) continue;
379
- for (const b of content) {
380
- if (!b || typeof b !== 'object') continue;
381
- if (b.type === 'tool_use') {
382
- const input = b.input || {};
383
- if (b.name === 'TodoWrite' && Array.isArray(input.todos)) {
384
- tasks = input.todos.map((t, i) => ({ id: String(t.id ?? i + 1), text: taskText(t), status: String(t.status || 'pending') }));
385
- pendingIds.clear();
386
- } else if (b.name === 'TaskCreate') {
387
- seq++;
388
- const task = { id: String(seq), text: taskText(input), status: String(input.status || 'pending') };
389
- tasks.push(task);
390
- if (b.id) pendingIds.set(b.id, task);
391
- } else if (b.name === 'TaskUpdate') {
392
- const id = String(input.taskId ?? input.task_id ?? input.id ?? '');
393
- const task = tasks.find(t => t.id === id);
394
- if (!task) continue;
395
- if (input.status) task.status = String(input.status);
396
- const text = String(input.subject ?? input.content ?? input.title ?? '');
397
- if (text) task.text = text;
398
- }
399
- } else if (b.type === 'tool_result' && pendingIds.has(b.tool_use_id)) {
400
- const raw = typeof b.content === 'string' ? b.content
401
- : Array.isArray(b.content) ? b.content.map(c => (c && c.text) || '').join(' ') : '';
402
- const m = /#(\d+)/.exec(raw);
403
- if (m) {
404
- const task = pendingIds.get(b.tool_use_id);
405
- task.id = m[1];
406
- seq = Math.max(seq, Number(m[1]));
407
- }
408
- pendingIds.delete(b.tool_use_id);
409
- }
410
- }
411
- }
412
- return tasks
413
- .filter(t => t.text && t.status !== 'deleted')
414
- .slice(-TASKS_KEPT)
415
- .map(t => ({ id: t.id, text: clip(redact(t.text), TASK_MAX), status: t.status }));
416
- }
417
-
418
- /** Open items of the brain, from the prefrontal context crbro_boot reads. */
419
- export function openItems(env = process.env) {
420
- try {
421
- const raw = readFileSync(join(brainDir(env), 'prefrontal', 'active_context.json'), 'utf8');
422
- const ctx = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
423
- const list = Array.isArray(ctx.pending_tasks) ? ctx.pending_tasks : [];
424
- return list
425
- .filter(t => typeof t === 'string' || (t && !t.closed))
426
- .map(t => clip(redact(typeof t === 'string' ? t : t.text), TASK_MAX))
427
- .filter(Boolean)
428
- .slice(0, OPEN_ITEMS_KEPT);
429
- } catch {
430
- return [];
431
- }
432
- }
433
-
434
- // ─── pre-compact ─────────────────────────────────────────────────
435
-
436
- export function buildCheckpoint(input, env = process.env, now = new Date()) {
437
- const entries = readTranscript(input.transcript_path);
438
- const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
439
- return redactDeep({
440
- v: 1,
441
- session_id: String(input.session_id || ''),
442
- created: now.toISOString(),
443
- trigger: String(input.trigger || ''),
444
- cwd,
445
- project: basename(cwd),
446
- git_remote: gitRemote(cwd),
447
- requests: lastUserRequests(entries, 2),
448
- tasks: lastTasks(entries),
449
- open_items: openItems(env),
450
- });
451
- }
452
-
453
- /** Write-then-rename, so a reader never sees half a checkpoint. */
454
- export function writeCheckpoint(dir, sessionId, checkpoint) {
455
- const id = safeSessionId(sessionId);
456
- if (!id) return null;
457
- mkdirSync(dir, { recursive: true });
458
- const file = join(dir, `${id}.json`);
459
- const tmp = `${file}.${process.pid}.tmp`;
460
- try {
461
- writeFileSync(tmp, JSON.stringify(redactDeep(checkpoint), null, 2), 'utf8');
462
- renameSync(tmp, file);
463
- } catch (e) {
464
- try { unlinkSync(tmp); } catch { /* nothing to clean */ }
465
- throw e;
466
- }
467
- return file;
468
- }
469
-
470
- /** Checkpoints older than seven days, and temp files a killed run left, go. */
471
- export function pruneCheckpoints(dir, nowMs = Date.now()) {
472
- let removed = 0;
473
- let names;
474
- try { names = readdirSync(dir); } catch { return 0; }
475
- for (const name of names) {
476
- if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
477
- const file = join(dir, name);
478
- try {
479
- if (nowMs - statSync(file).mtimeMs > PRUNE_AGE_MS) { unlinkSync(file); removed++; }
480
- } catch { /* gone already, or locked: next time */ }
481
- }
482
- return removed;
483
- }
484
-
485
- export function preCompact(input, env = process.env, flags = {}) {
486
- let saved = null;
487
- try {
488
- const dir = checkpointDir(env);
489
- const cp = buildCheckpoint(input, env);
490
- saved = writeCheckpoint(dir, input.session_id, cp);
491
- pruneCheckpoints(dir);
492
- } catch { /* no checkpoint this time; the reminder still goes out */ }
493
- const lines = [];
494
- if (!flags.noReminder) lines.push(PRECOMPACT_REMINDER);
495
- if (saved) lines.push('CRBRO saved a checkpoint of this session (last requests, task list, open items); it comes back on its own after the compaction.');
496
- return lines.join('\n');
497
- }
498
-
499
- // ─── session-start ───────────────────────────────────────────────
500
-
501
- function readCheckpoint(dir, sessionId, nowMs) {
502
- const id = safeSessionId(sessionId);
503
- if (!id) return null;
504
- try {
505
- const file = join(dir, `${id}.json`);
506
- const cp = JSON.parse(readFileSync(file, 'utf8'));
507
- const created = Date.parse(cp.created) || statSync(file).mtimeMs;
508
- if (!(nowMs - created < RESUME_MAX_AGE_MS)) return null;
509
- return cp;
510
- } catch {
511
- return null;
512
- }
513
- }
514
-
515
- /** The "Resuming after compaction" block, whole lines only, never over RESUME_CAP characters. */
516
- export function resumeBlock(cp) {
517
- const lines = [`Resuming after compaction (CRBRO checkpoint, ${String(cp.created || '').slice(0, 16).replace('T', ' ')} UTC):`];
518
- const req = Array.isArray(cp.requests) ? cp.requests : [];
519
- if (req[0]) lines.push(`· Last request: ${clip(redact(req[0]), REQUEST_MAX)}`);
520
- const tasks = (Array.isArray(cp.tasks) ? cp.tasks : []).filter(t => t && t.status !== 'completed');
521
- if (tasks.length) {
522
- lines.push('· Pending tasks:');
523
- for (const t of tasks) lines.push(` - [${t.status}] ${clip(redact(t.text), TASK_MAX)}`);
524
- }
525
- const open = Array.isArray(cp.open_items) ? cp.open_items : [];
526
- if (open.length) {
527
- lines.push('· Open items in CRBRO:');
528
- for (const o of open) lines.push(` - ${clip(redact(o), TASK_MAX)}`);
529
- }
530
- if (req[1]) lines.push(`· Previous request: ${clip(redact(req[1]), REQUEST_MAX)}`);
531
- if (lines.length === 1) return '';
532
-
533
- const tail = ' … (trimmed)';
534
- let out = '';
535
- for (let i = 0; i < lines.length; i++) {
536
- const next = (out ? out + '\n' : '') + redact(lines[i]);
537
- if (next.length > RESUME_CAP - tail.length - 1) {
538
- out = out ? `${out}\n${tail}` : clip(lines[i], RESUME_CAP);
539
- break;
540
- }
541
- out = next;
542
- }
543
- return out.slice(0, RESUME_CAP);
544
- }
545
-
546
- export function sessionStart(input, env = process.env, flags = {}, nowMs = Date.now()) {
547
- const lines = [];
548
- const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
549
- const name = projectName(cwd);
550
- if (!flags.noBoot) lines.push(bootNotice(name));
551
- const remote = gitRemote(cwd);
552
- lines.push(redact(`Project: ${name || cwd} · git: ${remote || 'none'}`));
553
- // A hand-written hook of the user prints its own notice; the folder still has to reach crbro_boot.
554
- if (flags.noBoot && name) lines.push(`CRBRO: pass project="${name}" to crbro_boot.`);
555
- if (input.source === 'compact') {
556
- const cp = readCheckpoint(checkpointDir(env), input.session_id, nowMs);
557
- const block = cp ? resumeBlock(cp) : '';
558
- if (block) lines.push('', block);
559
- }
560
- return lines.join('\n');
561
- }
562
-
563
- // ─── Run as a hook ───────────────────────────────────────────────
564
-
565
- function emit(text) {
566
- // Synchronous on purpose: process.exit() can cut an async pipe write short.
567
- if (text) try { writeSync(1, text.endsWith('\n') ? text : text + '\n'); } catch { /* nobody listening */ }
568
- }
569
-
570
- const isMain = process.argv[1] && /crbro-lifecycle\.mjs$/i.test(process.argv[1]);
571
- if (isMain) {
572
- const mode = process.argv[2];
573
- const flags = { noBoot: process.argv.includes('--no-boot'), noReminder: process.argv.includes('--no-reminder') };
574
- let raw = '';
575
- let finished = false;
576
- const done = () => {
577
- if (finished) return;
578
- finished = true;
579
- try {
580
- let input = {};
581
- try {
582
- const parsed = JSON.parse((raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw) || '{}');
583
- if (parsed && typeof parsed === 'object') input = parsed;
584
- } catch { /* not JSON: run with nothing */ }
585
- if (mode === 'pre-compact') emit(preCompact(input, process.env, flags));
586
- else if (mode === 'session-start') emit(sessionStart(input, process.env, flags));
587
- } catch { /* silence */ }
588
- process.exit(0);
589
- };
590
- // Whatever happens, the hook ends: the reminder must never become a hang.
591
- setTimeout(() => {
592
- if (!finished) {
593
- if (mode === 'pre-compact' && !flags.noReminder) emit(PRECOMPACT_REMINDER);
594
- else if (mode === 'session-start' && !flags.noBoot) emit(BOOT_NOTICE);
595
- }
596
- process.exit(0);
597
- }, WATCHDOG_MS);
598
- const timer = setTimeout(done, STDIN_WAIT_MS);
599
- try {
600
- process.stdin.setEncoding('utf8');
601
- process.stdin.on('data', c => { raw += c; });
602
- process.stdin.on('end', () => { clearTimeout(timer); done(); });
603
- process.stdin.on('error', () => { clearTimeout(timer); done(); });
604
- } catch {
605
- clearTimeout(timer);
606
- done();
607
- }
608
- }
1
+ #!/usr/bin/env node
2
+ // CRBRO — Claude Code lifecycle hook: compact without losing the thread
3
+ //
4
+ // Two modes, picked by the first argument:
5
+ //
6
+ // pre-compact PreCompact. Before the context is summarised, writes a
7
+ // mechanical checkpoint of the session — the last two things
8
+ // the user asked, the last state of the task list, the open
9
+ // items of the brain, the folder and its git remote — to
10
+ // <brain>/checkpoints/<session_id>.json. No model is called:
11
+ // it is a read of the transcript, nothing more. Then prints
12
+ // the "keep what is not saved yet" reminder the hand-written
13
+ // PreCompact hooks print today, so installing this is a
14
+ // superset of what was there.
15
+ //
16
+ // session-start SessionStart. Prints the boot notice — the one
17
+ // `install-boot` writes (BOOT_NOTICE below is the one copy of
18
+ // it; the CLI imports it from here), here with the folder
19
+ // name filled in as project=… — a "Project: … · git: …"
20
+ // line, and — only when the session restarts after a
21
+ // compaction and a checkpoint of that session younger than
22
+ // 24 h exists — a "Resuming after compaction" block, never
23
+ // longer than 1,500 characters.
24
+ //
25
+ // Flags: `--no-boot` (session-start) and `--no-reminder` (pre-compact) leave
26
+ // out the text a hand-written hook of the user already prints, so the
27
+ // installer never makes the model read the same instruction twice.
28
+ //
29
+ // Nothing reaches disk unredacted, and every text is redacted whole before it
30
+ // is cut to length: a cut first could leave half a token the patterns no
31
+ // longer recognise. The secret patterns are a copy of
32
+ // src/engine/secrets.ts, because this file is copied to ~/.claude/crbro-hooks
33
+ // and cannot import the package. tests/lifecycle.hook.test.ts compares both
34
+ // lists pattern by pattern: they cannot drift without a red test.
35
+ //
36
+ // Failure contract: dependency-free, never blocks, never asks. stdin that
37
+ // never ends (the Windows PowerShell wrapper can swallow it), a missing
38
+ // transcript, an unreadable brain, bad JSON — all degrade to printing what can
39
+ // be printed. A watchdog ends the process at WATCHDOG_MS. Always exit 0.
40
+
41
+ import {
42
+ readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, unlinkSync,
43
+ openSync, readSync, closeSync, fstatSync, writeSync, existsSync,
44
+ } from 'node:fs';
45
+ import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
46
+ import { homedir } from 'node:os';
47
+
48
+ // ─── Texts ───────────────────────────────────────────────────────
49
+
50
+ /** What install-boot prints for Claude Code, and what session-start prints when it knows no folder. One copy. */
51
+ export const BOOT_NOTICE =
52
+ 'CRBRO: call mcp__crbro__crbro_boot as your FIRST tool action, before answering, ' +
53
+ 'with project=<name of the working folder or repo>, ' +
54
+ 'unless this session already contains its result. Discover deferred CRBRO tools first if needed. ' +
55
+ 'Apply the protocol_enforcement block it returns for the rest of the session.';
56
+
57
+ /** The folder name as it goes into project="…": one line, no quotes, bounded. */
58
+ function projectName(cwd) {
59
+ return String(basename(cwd || '') || '').replace(/["\r\n\t]/g, '').slice(0, 120);
60
+ }
61
+
62
+ /** The boot notice with the folder already filled in, so P5 (project_neurons) is used. */
63
+ export function bootNotice(project) {
64
+ const name = projectName(project ? `/${project}` : '');
65
+ if (!name) return BOOT_NOTICE;
66
+ return BOOT_NOTICE.replace('with project=<name of the working folder or repo>', `with project="${name}"`);
67
+ }
68
+
69
+ /** The PreCompact reminder: the same job as the hand-written crbro-precompact.txt. */
70
+ export const PRECOMPACT_REMINDER = [
71
+ 'CRBRO — compaction is about to happen. When writing the summary, keep explicitly:',
72
+ '1. Every fact, decision, piece of architecture or work done in this session that has NOT been saved to CRBRO yet ' +
73
+ '(mcp__crbro__crbro_learn / mcp__crbro__crbro_consolidate). Mark it "⚠️ PENDING CRBRO SAVE" with enough detail to save it later without loss.',
74
+ '2. The CRBRO state of the session: what was already saved and what was not.',
75
+ 'After the compaction, the FIRST action is mcp__crbro__crbro_learn with everything marked as pending.',
76
+ ].join('\n');
77
+
78
+ export const RESUME_CAP = 1500;
79
+ const RESUME_MAX_AGE_MS = 24 * 3600 * 1000;
80
+ const PRUNE_AGE_MS = 7 * 24 * 3600 * 1000;
81
+ const TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024;
82
+ const REQUEST_MAX = 500;
83
+ const TASK_MAX = 200;
84
+ const TASKS_KEPT = 40;
85
+ const OPEN_ITEMS_KEPT = 10;
86
+ const STDIN_WAIT_MS = 1500;
87
+ const WATCHDOG_MS = 5000;
88
+
89
+ // ─── Redaction (mirror of src/engine/secrets.ts) ─────────────────
90
+
91
+ /** Same kinds, same sources, same flags as PATTERNS in src/engine/secrets.ts. Tested. */
92
+ export const SECRET_PATTERNS = [
93
+ { kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
94
+ { kind: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
95
+ { kind: 'OpenAI key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
96
+ { kind: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
97
+ { kind: 'Google API key', re: /\bAIza[A-Za-z0-9_-]{35}\b/g },
98
+ { kind: 'Supabase token', re: /\bsbp_[a-f0-9]{40,}\b/g },
99
+ { kind: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
100
+ { kind: 'Stripe key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b/g },
101
+ { kind: 'AWS access key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
102
+ {
103
+ kind: 'AWS secret key',
104
+ re: /\b(?:[Aa][Ww][Ss][ _-]?)?[Ss]ecret[ _-]?(?:[Aa]ccess[ _-]?)?[Kk]ey\b[^\n]{0,15}?(?<![A-Za-z0-9/+])((?=[A-Za-z0-9/+]*[A-Z])(?=[A-Za-z0-9/+]*[a-z])(?=[A-Za-z0-9/+]*[0-9])[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+])/g,
105
+ },
106
+ { kind: 'Twilio account SID', re: /\bAC[0-9a-fA-F]{32,34}\b/g },
107
+ {
108
+ kind: 'auth token (hex)',
109
+ re: /\bauth[ _-]?token\b[^\S\n]{0,3}(?:[:=]|es|is)?[^\S\n]{0,3}["'`]?([0-9a-fA-F]{30,34})\b/gi,
110
+ },
111
+ { kind: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
112
+ { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
113
+ {
114
+ kind: 'WordPress application password',
115
+ re: /\b(?:application|app)[ _-]?password\b[^\n]{0,80}?\b([A-Za-z0-9]{4}(?: [A-Za-z0-9]{4}){5})\b/gi,
116
+ },
117
+ {
118
+ kind: 'password',
119
+ re: /\b(?:password|passwd|contrase[nñ]a|clave)\s*(?:es|is)?\s*[:=]\s*["'`]?([^\s"'`,;]{8,})/gi,
120
+ },
121
+ {
122
+ kind: 'password (prose)',
123
+ re: /\b(?:password|passwd|contrase[nñ]a|clave)\b(?:\s+(?:de|del|of|for)\s+[^\s]{1,30})?\s+(?:es|is)\s+["'`]?((?=[^\s"'`,;]*[A-Za-z])(?=[^\s"'`,;]*[0-9])[^\s"'`,;]{8,})/gi,
124
+ },
125
+ {
126
+ kind: 'split credential',
127
+ re: /\b(?:token|key|clave|password|contrase[nñ]a|secret|secreto)\b[^.?!\n]{0,40}?\b(?:empieza|comienza|starts?)\s+(?:por|con|with)\s+(\S{2,15}[^.?!\n]{0,30}?\b(?:sigue con|contin[uú]a con|y luego|followed by|then|continues with)\s+["'`]?(?=[A-Za-z0-9_\-+/=]*[A-Za-z])(?=[A-Za-z0-9_\-+/=]*[0-9])[A-Za-z0-9_\-+/=]{16,})/gi,
128
+ },
129
+ {
130
+ kind: 'connection string credentials',
131
+ re: /\b[a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+:[^\s:@/]+@[^\s/]+/gi,
132
+ },
133
+ {
134
+ kind: 'generic API key/secret',
135
+ re: /\b(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|secret|token)\s*[:=]\s*["'`]?(?!\/)((?=[A-Za-z0-9_\-+]*[A-Za-z])(?=[A-Za-z0-9_\-+]*[0-9])[A-Za-z0-9_\-+]{24,})/gi,
136
+ },
137
+ { kind: 'SendGrid key', re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
138
+ { kind: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g },
139
+ ];
140
+
141
+ /** Same algorithm as redact() in src/engine/secrets.ts: left to right, overlaps skipped, longer span wins a tie. */
142
+ export function redact(text) {
143
+ if (typeof text !== 'string' || !text) return '';
144
+ const hits = [];
145
+ for (const { kind, re } of SECRET_PATTERNS) {
146
+ re.lastIndex = 0;
147
+ let m;
148
+ while ((m = re.exec(text)) !== null) {
149
+ const value = m[1] !== undefined ? m[1] : m[0];
150
+ const at = m[1] !== undefined ? m.index + m[0].indexOf(m[1]) : m.index;
151
+ hits.push({ kind, index: at, length: value.length });
152
+ if (m[0].length === 0) re.lastIndex++;
153
+ }
154
+ }
155
+ if (hits.length === 0) return text;
156
+ hits.sort((a, b) => a.index - b.index || b.length - a.length);
157
+ let out = '';
158
+ let cursor = 0;
159
+ for (const h of hits) {
160
+ if (h.index < cursor) continue;
161
+ out += text.slice(cursor, h.index) + `[REDACTED: ${h.kind}]`;
162
+ cursor = h.index + h.length;
163
+ }
164
+ return out + text.slice(cursor);
165
+ }
166
+
167
+ /** Every string in a plain JSON value, redacted. What gets written is only ever the output of this. */
168
+ export function redactDeep(value) {
169
+ if (typeof value === 'string') return redact(value);
170
+ if (Array.isArray(value)) return value.map(redactDeep);
171
+ if (value && typeof value === 'object') {
172
+ const out = {};
173
+ for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v);
174
+ return out;
175
+ }
176
+ return value;
177
+ }
178
+
179
+ // ─── Where things are ────────────────────────────────────────────
180
+
181
+ /** Mirror of resolveBrainDir() in src/engine/brain.ts (tested), without its warnings. */
182
+ export function brainDir(env = process.env) {
183
+ const home = env.HOME || env.USERPROFILE || homedir();
184
+ const fallback = join(home, '.crbro');
185
+ const raw = (env.CRBRO_PATH || '').trim();
186
+ if (!raw) return fallback;
187
+ if (/\$\{|%[A-Za-z_][A-Za-z0-9_]*%/.test(raw)) return fallback;
188
+ if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) return join(home, raw.slice(1));
189
+ if (!isAbsolute(raw)) return join(home, raw);
190
+ return raw;
191
+ }
192
+
193
+ export function checkpointDir(env = process.env) {
194
+ return join(brainDir(env), 'checkpoints');
195
+ }
196
+
197
+ function safeSessionId(id) {
198
+ return String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 100);
199
+ }
200
+
201
+ function clip(text, max) {
202
+ const t = String(text || '').replace(/\s+/g, ' ').trim();
203
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
204
+ }
205
+
206
+ /**
207
+ * The remote of the repository `cwd` is in, read from .git/config. No `git`
208
+ * process: spawning one can hang on a credential helper, and this is a hook.
209
+ * Credentials embedded in the URL are dropped before anything else sees it.
210
+ */
211
+ export function gitRemote(cwd) {
212
+ try {
213
+ let dir = resolve(cwd || process.cwd());
214
+ for (let i = 0; i < 40; i++) {
215
+ const dotGit = join(dir, '.git');
216
+ if (existsSync(dotGit)) {
217
+ let gitDir = dotGit;
218
+ if (statSync(dotGit).isFile()) {
219
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'));
220
+ if (!m) return null;
221
+ gitDir = resolve(dir, m[1].trim());
222
+ }
223
+ // A worktree keeps its config in the common dir.
224
+ try {
225
+ const common = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
226
+ if (common) gitDir = resolve(gitDir, common);
227
+ } catch { /* not a worktree */ }
228
+ return remoteFromConfig(readFileSync(join(gitDir, 'config'), 'utf8'));
229
+ }
230
+ const up = dirname(dir);
231
+ if (up === dir) return null;
232
+ dir = up;
233
+ }
234
+ } catch { /* no repo, or unreadable: no remote */ }
235
+ return null;
236
+ }
237
+
238
+ function remoteFromConfig(config) {
239
+ let section = null;
240
+ const urls = new Map();
241
+ for (const line of config.split(/\r?\n/)) {
242
+ const head = /^\s*\[\s*remote\s+"([^"]+)"\s*\]/.exec(line);
243
+ if (head) { section = head[1]; continue; }
244
+ if (/^\s*\[/.test(line)) { section = null; continue; }
245
+ const url = section && /^\s*url\s*=\s*(.+?)\s*$/.exec(line);
246
+ if (url && !urls.has(section)) urls.set(section, url[1]);
247
+ }
248
+ const raw = urls.get('origin') ?? [...urls.values()][0];
249
+ if (!raw) return null;
250
+ return stripUrlCredentials(raw);
251
+ }
252
+
253
+ /**
254
+ * A remote without what should never be printed: user, password, query and
255
+ * fragment. A URL with a scheme is parsed; an scp-like "user@host:path" loses
256
+ * everything up to the LAST '@' before the host, so a password with an '@' in
257
+ * it does not leave half of itself behind.
258
+ */
259
+ export function stripUrlCredentials(raw) {
260
+ const text = String(raw || '').trim();
261
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
262
+ try {
263
+ const u = new URL(text);
264
+ return `${u.protocol}//${u.host}${u.pathname}`;
265
+ } catch {
266
+ // Not parseable (e.g. a raw '@' in the password): cut by hand.
267
+ const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(.*)$/i.exec(text);
268
+ if (!m) return null;
269
+ const host = m[2].slice(m[2].lastIndexOf('@') + 1);
270
+ return `${m[1]}${host}${m[3].replace(/[?#].*$/, '')}`;
271
+ }
272
+ }
273
+ // scp-like: [user@]host:path — no password possible, but drop the user too.
274
+ const colon = text.search(/:(?!\/\/)/);
275
+ const at = text.lastIndexOf('@', colon === -1 ? text.length : colon);
276
+ return (at === -1 ? text : text.slice(at + 1)).replace(/[?#].*$/, '');
277
+ }
278
+
279
+ // ─── Reading the transcript ──────────────────────────────────────
280
+
281
+ /** JSONL entries, tolerant of a huge file (tail only), a torn last line and junk. */
282
+ export function readTranscript(path) {
283
+ if (!path || typeof path !== 'string') return [];
284
+ let text;
285
+ try {
286
+ const fd = openSync(path, 'r');
287
+ try {
288
+ const size = fstatSync(fd).size;
289
+ const start = Math.max(0, size - TRANSCRIPT_MAX_BYTES);
290
+ const buf = Buffer.alloc(size - start);
291
+ readSync(fd, buf, 0, buf.length, start);
292
+ text = buf.toString('utf8');
293
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1);
294
+ } finally {
295
+ closeSync(fd);
296
+ }
297
+ } catch {
298
+ return [];
299
+ }
300
+ const out = [];
301
+ for (const line of text.split('\n')) {
302
+ if (!line.trim()) continue;
303
+ try { out.push(JSON.parse(line)); } catch { /* torn or foreign line */ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function contentOf(entry) {
309
+ const msg = entry && entry.message;
310
+ if (!msg) return null;
311
+ return msg.content;
312
+ }
313
+
314
+ /** Lines the client writes on the person's behalf. Same list as NOT_A_REQUEST in src/engine/postmortem.ts (tested). */
315
+ export const NOT_A_REQUEST = /^(?:<local-command-|<system-reminder>|<command-stdout>|<command-message>|<bash-|<user-prompt-submit-hook>|<task-notification>|\[Request interrupted|Caveat: |Alcancé mi límite de uso mientras trabajabas)/;
316
+
317
+ /**
318
+ * Slash commands that configure the client instead of asking for work. What
319
+ * they carry is not "the last request" and must not come back as one.
320
+ */
321
+ export const NOT_WORK_COMMANDS = /^\/(?:compact|model|clear|config|effort|fast|cost|usage|status|help|login|logout|theme|permissions|resume|exit|quit|vim|output-style|statusline|terminal-setup|doctor|context|mcp|hooks|agents|ide|rename|export|release-notes|upgrade|privacy-settings|add-dir|memory|plugin|skills|tasks|bashes|todos|feedback|bug|keybindings|sandbox|remote-control|rewind)\b/i;
322
+
323
+ /** The text a person typed in one user entry, or '' when it is a tool result, a meta line or a reminder. */
324
+ function userText(entry) {
325
+ if (!entry || entry.type !== 'user' || entry.isMeta || entry.isSidechain || entry.isCompactSummary) return '';
326
+ // A background task's notification or another agent's message arrives as a
327
+ // user entry. Its text can come from a web page or a third-party repo: it
328
+ // is never presented back as something the user asked.
329
+ if (entry.origin && typeof entry.origin.kind === 'string' && entry.origin.kind !== 'human') return '';
330
+ const content = contentOf(entry);
331
+ let text = '';
332
+ if (typeof content === 'string') text = content;
333
+ else if (Array.isArray(content)) {
334
+ if (content.some(b => b && b.type === 'tool_result')) return '';
335
+ text = content.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
336
+ }
337
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
338
+ // A slash command arrives as tags; keep what was typed.
339
+ const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
340
+ if (name) {
341
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
342
+ text = `${name[1].trim()} ${args ? args[1].trim() : ''}`.trim();
343
+ // The command that triggers the compaction, /model and the like are not what was being worked on.
344
+ if (NOT_WORK_COMMANDS.test(text)) return '';
345
+ }
346
+ if (!text || NOT_A_REQUEST.test(text)) return '';
347
+ return text;
348
+ }
349
+
350
+ /** The last `n` things the user asked, newest first. */
351
+ export function lastUserRequests(entries, n = 2) {
352
+ const out = [];
353
+ for (let i = entries.length - 1; i >= 0 && out.length < n; i--) {
354
+ const t = userText(entries[i]);
355
+ if (t) out.push(clip(redact(t), REQUEST_MAX));
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function taskText(input) {
361
+ if (!input || typeof input !== 'object') return '';
362
+ return String(input.subject ?? input.content ?? input.title ?? input.description ?? input.activeForm ?? '');
363
+ }
364
+
365
+ /**
366
+ * The last state of the task list. Two shapes are known — TodoWrite, which
367
+ * rewrites the whole list each time, and TaskCreate/TaskUpdate, which edit it
368
+ * one task at a time — and they are replayed in transcript order, so whichever
369
+ * came last wins. Anything not recognised is skipped, never guessed.
370
+ */
371
+ export function lastTasks(entries) {
372
+ let tasks = [];
373
+ const pendingIds = new Map(); // tool_use_id → task waiting for the id its result announces
374
+ let seq = 0;
375
+ for (const entry of entries) {
376
+ if (!entry || entry.isSidechain) continue;
377
+ const content = contentOf(entry);
378
+ if (!Array.isArray(content)) continue;
379
+ for (const b of content) {
380
+ if (!b || typeof b !== 'object') continue;
381
+ if (b.type === 'tool_use') {
382
+ const input = b.input || {};
383
+ if (b.name === 'TodoWrite' && Array.isArray(input.todos)) {
384
+ tasks = input.todos.map((t, i) => ({ id: String(t.id ?? i + 1), text: taskText(t), status: String(t.status || 'pending') }));
385
+ pendingIds.clear();
386
+ } else if (b.name === 'TaskCreate') {
387
+ seq++;
388
+ const task = { id: String(seq), text: taskText(input), status: String(input.status || 'pending') };
389
+ tasks.push(task);
390
+ if (b.id) pendingIds.set(b.id, task);
391
+ } else if (b.name === 'TaskUpdate') {
392
+ const id = String(input.taskId ?? input.task_id ?? input.id ?? '');
393
+ const task = tasks.find(t => t.id === id);
394
+ if (!task) continue;
395
+ if (input.status) task.status = String(input.status);
396
+ const text = String(input.subject ?? input.content ?? input.title ?? '');
397
+ if (text) task.text = text;
398
+ }
399
+ } else if (b.type === 'tool_result' && pendingIds.has(b.tool_use_id)) {
400
+ const raw = typeof b.content === 'string' ? b.content
401
+ : Array.isArray(b.content) ? b.content.map(c => (c && c.text) || '').join(' ') : '';
402
+ const m = /#(\d+)/.exec(raw);
403
+ if (m) {
404
+ const task = pendingIds.get(b.tool_use_id);
405
+ task.id = m[1];
406
+ seq = Math.max(seq, Number(m[1]));
407
+ }
408
+ pendingIds.delete(b.tool_use_id);
409
+ }
410
+ }
411
+ }
412
+ return tasks
413
+ .filter(t => t.text && t.status !== 'deleted')
414
+ .slice(-TASKS_KEPT)
415
+ .map(t => ({ id: t.id, text: clip(redact(t.text), TASK_MAX), status: t.status }));
416
+ }
417
+
418
+ /** Open items of the brain, from the prefrontal context crbro_boot reads. */
419
+ export function openItems(env = process.env) {
420
+ try {
421
+ const raw = readFileSync(join(brainDir(env), 'prefrontal', 'active_context.json'), 'utf8');
422
+ const ctx = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
423
+ const list = Array.isArray(ctx.pending_tasks) ? ctx.pending_tasks : [];
424
+ return list
425
+ .filter(t => typeof t === 'string' || (t && !t.closed))
426
+ .map(t => clip(redact(typeof t === 'string' ? t : t.text), TASK_MAX))
427
+ .filter(Boolean)
428
+ .slice(0, OPEN_ITEMS_KEPT);
429
+ } catch {
430
+ return [];
431
+ }
432
+ }
433
+
434
+ // ─── pre-compact ─────────────────────────────────────────────────
435
+
436
+ export function buildCheckpoint(input, env = process.env, now = new Date()) {
437
+ const entries = readTranscript(input.transcript_path);
438
+ const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
439
+ return redactDeep({
440
+ v: 1,
441
+ session_id: String(input.session_id || ''),
442
+ created: now.toISOString(),
443
+ trigger: String(input.trigger || ''),
444
+ cwd,
445
+ project: basename(cwd),
446
+ git_remote: gitRemote(cwd),
447
+ requests: lastUserRequests(entries, 2),
448
+ tasks: lastTasks(entries),
449
+ open_items: openItems(env),
450
+ });
451
+ }
452
+
453
+ /** Write-then-rename, so a reader never sees half a checkpoint. */
454
+ export function writeCheckpoint(dir, sessionId, checkpoint) {
455
+ const id = safeSessionId(sessionId);
456
+ if (!id) return null;
457
+ mkdirSync(dir, { recursive: true });
458
+ const file = join(dir, `${id}.json`);
459
+ const tmp = `${file}.${process.pid}.tmp`;
460
+ try {
461
+ writeFileSync(tmp, JSON.stringify(redactDeep(checkpoint), null, 2), 'utf8');
462
+ renameSync(tmp, file);
463
+ } catch (e) {
464
+ try { unlinkSync(tmp); } catch { /* nothing to clean */ }
465
+ throw e;
466
+ }
467
+ return file;
468
+ }
469
+
470
+ /** Checkpoints older than seven days, and temp files a killed run left, go. */
471
+ export function pruneCheckpoints(dir, nowMs = Date.now()) {
472
+ let removed = 0;
473
+ let names;
474
+ try { names = readdirSync(dir); } catch { return 0; }
475
+ for (const name of names) {
476
+ if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
477
+ const file = join(dir, name);
478
+ try {
479
+ if (nowMs - statSync(file).mtimeMs > PRUNE_AGE_MS) { unlinkSync(file); removed++; }
480
+ } catch { /* gone already, or locked: next time */ }
481
+ }
482
+ return removed;
483
+ }
484
+
485
+ export function preCompact(input, env = process.env, flags = {}) {
486
+ let saved = null;
487
+ try {
488
+ const dir = checkpointDir(env);
489
+ const cp = buildCheckpoint(input, env);
490
+ saved = writeCheckpoint(dir, input.session_id, cp);
491
+ pruneCheckpoints(dir);
492
+ } catch { /* no checkpoint this time; the reminder still goes out */ }
493
+ const lines = [];
494
+ if (!flags.noReminder) lines.push(PRECOMPACT_REMINDER);
495
+ if (saved) lines.push('CRBRO saved a checkpoint of this session (last requests, task list, open items); it comes back on its own after the compaction.');
496
+ return lines.join('\n');
497
+ }
498
+
499
+ // ─── session-start ───────────────────────────────────────────────
500
+
501
+ function readCheckpoint(dir, sessionId, nowMs) {
502
+ const id = safeSessionId(sessionId);
503
+ if (!id) return null;
504
+ try {
505
+ const file = join(dir, `${id}.json`);
506
+ const cp = JSON.parse(readFileSync(file, 'utf8'));
507
+ const created = Date.parse(cp.created) || statSync(file).mtimeMs;
508
+ if (!(nowMs - created < RESUME_MAX_AGE_MS)) return null;
509
+ return cp;
510
+ } catch {
511
+ return null;
512
+ }
513
+ }
514
+
515
+ /** The "Resuming after compaction" block, whole lines only, never over RESUME_CAP characters. */
516
+ export function resumeBlock(cp) {
517
+ const lines = [`Resuming after compaction (CRBRO checkpoint, ${String(cp.created || '').slice(0, 16).replace('T', ' ')} UTC):`];
518
+ const req = Array.isArray(cp.requests) ? cp.requests : [];
519
+ if (req[0]) lines.push(`· Last request: ${clip(redact(req[0]), REQUEST_MAX)}`);
520
+ const tasks = (Array.isArray(cp.tasks) ? cp.tasks : []).filter(t => t && t.status !== 'completed');
521
+ if (tasks.length) {
522
+ lines.push('· Pending tasks:');
523
+ for (const t of tasks) lines.push(` - [${t.status}] ${clip(redact(t.text), TASK_MAX)}`);
524
+ }
525
+ const open = Array.isArray(cp.open_items) ? cp.open_items : [];
526
+ if (open.length) {
527
+ lines.push('· Open items in CRBRO:');
528
+ for (const o of open) lines.push(` - ${clip(redact(o), TASK_MAX)}`);
529
+ }
530
+ if (req[1]) lines.push(`· Previous request: ${clip(redact(req[1]), REQUEST_MAX)}`);
531
+ if (lines.length === 1) return '';
532
+
533
+ const tail = ' … (trimmed)';
534
+ let out = '';
535
+ for (let i = 0; i < lines.length; i++) {
536
+ const next = (out ? out + '\n' : '') + redact(lines[i]);
537
+ if (next.length > RESUME_CAP - tail.length - 1) {
538
+ out = out ? `${out}\n${tail}` : clip(lines[i], RESUME_CAP);
539
+ break;
540
+ }
541
+ out = next;
542
+ }
543
+ return out.slice(0, RESUME_CAP);
544
+ }
545
+
546
+ export function sessionStart(input, env = process.env, flags = {}, nowMs = Date.now()) {
547
+ const lines = [];
548
+ const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
549
+ const name = projectName(cwd);
550
+ if (!flags.noBoot) lines.push(bootNotice(name));
551
+ const remote = gitRemote(cwd);
552
+ lines.push(redact(`Project: ${name || cwd} · git: ${remote || 'none'}`));
553
+ // A hand-written hook of the user prints its own notice; the folder still has to reach crbro_boot.
554
+ if (flags.noBoot && name) lines.push(`CRBRO: pass project="${name}" to crbro_boot.`);
555
+ if (input.source === 'compact') {
556
+ const cp = readCheckpoint(checkpointDir(env), input.session_id, nowMs);
557
+ const block = cp ? resumeBlock(cp) : '';
558
+ if (block) lines.push('', block);
559
+ }
560
+ return lines.join('\n');
561
+ }
562
+
563
+ // ─── Run as a hook ───────────────────────────────────────────────
564
+
565
+ function emit(text) {
566
+ // Synchronous on purpose: process.exit() can cut an async pipe write short.
567
+ if (text) try { writeSync(1, text.endsWith('\n') ? text : text + '\n'); } catch { /* nobody listening */ }
568
+ }
569
+
570
+ const isMain = process.argv[1] && /crbro-lifecycle\.mjs$/i.test(process.argv[1]);
571
+ if (isMain) {
572
+ const mode = process.argv[2];
573
+ const flags = { noBoot: process.argv.includes('--no-boot'), noReminder: process.argv.includes('--no-reminder') };
574
+ let raw = '';
575
+ let finished = false;
576
+ const done = () => {
577
+ if (finished) return;
578
+ finished = true;
579
+ try {
580
+ let input = {};
581
+ try {
582
+ const parsed = JSON.parse((raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw) || '{}');
583
+ if (parsed && typeof parsed === 'object') input = parsed;
584
+ } catch { /* not JSON: run with nothing */ }
585
+ if (mode === 'pre-compact') emit(preCompact(input, process.env, flags));
586
+ else if (mode === 'session-start') emit(sessionStart(input, process.env, flags));
587
+ } catch { /* silence */ }
588
+ process.exit(0);
589
+ };
590
+ // Whatever happens, the hook ends: the reminder must never become a hang.
591
+ setTimeout(() => {
592
+ if (!finished) {
593
+ if (mode === 'pre-compact' && !flags.noReminder) emit(PRECOMPACT_REMINDER);
594
+ else if (mode === 'session-start' && !flags.noBoot) emit(BOOT_NOTICE);
595
+ }
596
+ process.exit(0);
597
+ }, WATCHDOG_MS);
598
+ const timer = setTimeout(done, STDIN_WAIT_MS);
599
+ try {
600
+ process.stdin.setEncoding('utf8');
601
+ process.stdin.on('data', c => { raw += c; });
602
+ process.stdin.on('end', () => { clearTimeout(timer); done(); });
603
+ process.stdin.on('error', () => { clearTimeout(timer); done(); });
604
+ } catch {
605
+ clearTimeout(timer);
606
+ done();
607
+ }
608
+ }