claude-brink 0.1.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/src/brink.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // Brink core driver. Usage: node brink.js <claude|codex> <warn|pause>
3
+ // Reads usage via the adapter, decides via the shared threshold engine, then:
4
+ // warn -> fires a debounced notification (once per window/band/reset-block)
5
+ // pause -> Brink WRITES HANDOFF.md itself, then emits the adapter's native deny with a
6
+ // short, credible, non-contradictory reason (see Phase 5 / live-test findings).
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawn, spawnSync } = require('child_process');
11
+ const { decide } = require('./core/thresholds');
12
+ const { writeHandoff } = require('./core/handoff');
13
+ const { shouldArm, armArgs } = require('./core/resume');
14
+
15
+ const DIR = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
16
+ const HATCH = path.join(DIR, 'DISABLED');
17
+ const FLAG_TTL_MS = 14 * 24 * 3600 * 1000; // GC debounce/armed flags older than 14 days
18
+
19
+ const DEFAULT_CFG = {
20
+ five_hour: { warn: [75, 85], pause: Number(process.env.BRINK_PAUSE || 93) },
21
+ seven_day: { warn: [80, 90], pause: Number(process.env.BRINK_WEEKLY_PAUSE || 95) },
22
+ };
23
+
24
+ function loadRawCfg() {
25
+ try {
26
+ const raw = fs.readFileSync(path.join(DIR, 'config.json'), 'utf8');
27
+ return JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
28
+ } catch { return {}; }
29
+ }
30
+ // Deep-merge per window so a partial override ({five_hour:{pause:97}}) cannot
31
+ // silently drop the warn bands or the other window's pause (review finding).
32
+ function mergeThresholds(user) {
33
+ const out = {};
34
+ for (const w of ['five_hour', 'seven_day']) {
35
+ const d = DEFAULT_CFG[w];
36
+ const u = (user && typeof user[w] === 'object' && user[w]) || {};
37
+ out[w] = {
38
+ warn: Array.isArray(u.warn) && u.warn.every((n) => typeof n === 'number') ? u.warn : d.warn,
39
+ pause: typeof u.pause === 'number' ? u.pause : d.pause,
40
+ };
41
+ }
42
+ return out;
43
+ }
44
+ function loadAdapter(name) {
45
+ if (name === 'claude') return require('./adapters/claude');
46
+ if (name === 'codex') return require('./adapters/codex');
47
+ throw new Error('unknown adapter: ' + name);
48
+ }
49
+ function notify(msg) {
50
+ try {
51
+ const c = spawn('node', [path.join(__dirname, 'notify.js'), msg],
52
+ { detached: true, stdio: 'ignore', windowsHide: true });
53
+ c.on('error', () => {}); // async spawn failure must never crash the hook
54
+ c.unref();
55
+ } catch {}
56
+ }
57
+ function resetText(epoch) {
58
+ if (typeof epoch !== 'number') return '';
59
+ const d = new Date(epoch * 1000);
60
+ if (isNaN(d.getTime())) return '';
61
+ // A weekly reset can be days out — "resets at 02:30" alone would mislead.
62
+ const far = epoch * 1000 - Date.now() > 20 * 3600 * 1000;
63
+ return far
64
+ ? d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
65
+ : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
66
+ }
67
+ // A window whose resets_at is in the past has ROLLED — its stored pct is stale.
68
+ // Without this gate a resumed headless session (no statusline running yet) reads
69
+ // the pre-pause 99% and denies its own first tool call (review finding: the
70
+ // Phase 7 resume would self-block). 60s slack absorbs clock skew.
71
+ function dropStaleWindows(usage) {
72
+ const now = Math.floor(Date.now() / 1000);
73
+ if (typeof usage.five_reset === 'number' && usage.five_reset < now - 60) usage.five_pct = null;
74
+ if (typeof usage.week_reset === 'number' && usage.week_reset < now - 60) usage.week_pct = null;
75
+ return usage;
76
+ }
77
+ function gcFlags() {
78
+ try {
79
+ const cutoff = Date.now() - FLAG_TTL_MS;
80
+ for (const f of fs.readdirSync(DIR)) {
81
+ if (!/^(notified|armed)_/.test(f)) continue;
82
+ const p = path.join(DIR, f);
83
+ try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}
84
+ }
85
+ } catch {}
86
+ }
87
+ // Only the pause path needs the hook payload (transcript_path/cwd). Read stdin lazily.
88
+ function readStdin() {
89
+ return new Promise((resolve) => {
90
+ if (process.stdin.isTTY) return resolve({});
91
+ let s = ''; let done = false;
92
+ const fin = () => { if (done) return; done = true; try { resolve(JSON.parse(s || '{}')); } catch { resolve({}); } };
93
+ process.stdin.on('data', (d) => { s += d; });
94
+ process.stdin.on('end', fin);
95
+ process.stdin.on('error', () => resolve({}));
96
+ setTimeout(fin, 800); // fallback so a hook never hangs waiting on stdin
97
+ });
98
+ }
99
+
100
+ async function main() {
101
+ const [adapterName, mode = 'pause'] = process.argv.slice(2);
102
+ const adapter = loadAdapter(adapterName);
103
+
104
+ if (fs.existsSync(HATCH)) process.exit(0); // kill switch
105
+ const usage = adapter.readUsage();
106
+ if (!usage) process.exit(0); // no data => never act blind
107
+ dropStaleWindows(usage);
108
+
109
+ const rawCfg = loadRawCfg();
110
+ const cfg = mergeThresholds(rawCfg.thresholds);
111
+ const d = decide(usage, cfg);
112
+ if (d.action === 'allow') process.exit(0);
113
+
114
+ fs.mkdirSync(DIR, { recursive: true });
115
+ gcFlags();
116
+ const flag = path.join(DIR, `notified_${adapterName}_${d.windowKey}_${d.band}_${d.reset}`.replace(/[^\w.-]/g, '_'));
117
+ const once = (msg) => { if (!fs.existsSync(flag)) { fs.writeFileSync(flag, ''); notify(msg); } };
118
+
119
+ if (mode === 'warn' && d.action === 'warn') {
120
+ once(`Brink: ${d.window} usage at ${Math.round(d.pct)}%`);
121
+ process.exit(0);
122
+ }
123
+
124
+ if (mode === 'pause' && d.action === 'pause') {
125
+ const hook = await readStdin();
126
+ const cwd = hook.cwd || usage.cwd || process.cwd();
127
+ const rt = resetText(d.reset);
128
+ // Brink writes the handoff itself — robust even if the model won't cooperate.
129
+ const handoffPath = writeHandoff({ transcriptPath: hook.transcript_path, cwd, pct: d.pct, window: d.window, resetText: rt });
130
+ once(`Brink: paused - ${d.window} at ${Math.round(d.pct)}%`);
131
+
132
+ // Resume (Phase 7, opt-in): register a one-shot scheduler job for the reset time.
133
+ // SYNCHRONOUS on purpose — live-fire testing proved PowerShell 5.1 dies when
134
+ // spawned detached (DETACHED_PROCESS = no console) and dies with the parent's
135
+ // console when attached, so fire-and-forget silently never registers the task.
136
+ // A pause is a terminal event; blocking ~1-2s here is correct. The armed flag is
137
+ // written only AFTER the scheduler confirms, so a failure can retry next call.
138
+ // Prefer the hook's own session_id — the shared state.json may hold another
139
+ // concurrent session's id (multi-window finding).
140
+ const ctx = { session_id: hook.session_id || usage.session_id, reset: d.reset, proj: cwd };
141
+ if (shouldArm(rawCfg, ctx, os.platform())) {
142
+ const armFlag = path.join(DIR, `armed_${ctx.session_id}_${ctx.reset}`.replace(/[^\w.-]/g, '_'));
143
+ if (!fs.existsSync(armFlag)) {
144
+ try {
145
+ const ps = path.join(__dirname, 'arm-resume.ps1');
146
+ const r = spawnSync('powershell',
147
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps, ...armArgs(rawCfg, ctx)],
148
+ { stdio: 'ignore', windowsHide: true, timeout: 20000 });
149
+ if (r.status === 0) fs.writeFileSync(armFlag, '');
150
+ else notify('Brink: resume arming FAILED - will not auto-resume');
151
+ } catch { /* arming is best-effort; never block the pause */ }
152
+ }
153
+ }
154
+
155
+ const file = handoffPath ? path.basename(handoffPath) : '';
156
+ const saved = file
157
+ ? `Your progress and next steps are already saved to ${file}. `
158
+ : ''; // never claim a save that did not happen (review finding)
159
+ const reason = `Paused by Brink: you are at ${Math.round(d.pct)}% of your ${d.window} usage limit${rt ? `, which resets at ${rt}` : ''}. ${saved}Stop here and reply to the user in plain text — do not start new work; it will resume${file ? ' from the handoff' : ''} when the limit resets.`;
160
+ const out = adapter.denyOutput(reason);
161
+ if (out.stdout) process.stdout.write(out.stdout);
162
+ if (out.stderr) process.stderr.write(out.stderr);
163
+ process.exit(out.exitCode || 0);
164
+ }
165
+
166
+ process.exit(0);
167
+ }
168
+ main();
package/src/cli.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ // Brink CLI — the one command a stranger needs. Zero deps, Node >=18.
3
+ // brink init wire hooks + statusline into Claude Code (wraps install.js)
4
+ // brink off | on kill switch (create/remove the DISABLED file)
5
+ // brink uninstall surgically remove Brink from settings.json (restore statusline)
6
+ // brink doctor self-check the whole chain: sensor -> state -> hook -> notify
7
+ // brink version
8
+ // Born from the 2026-07-04 launch council: the kill switch, the doctor, and a TRUE
9
+ // npm install story collapse into this file (docs/council-2026-07-04.html).
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const SRC = __dirname;
16
+ const DIR = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
17
+ const HATCH = path.join(DIR, 'DISABLED');
18
+
19
+ const argv = process.argv.slice(2);
20
+ const cmd = argv[0] || 'help';
21
+ const getOpt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? (argv[i + 1] || true) : d; };
22
+ const settingsPath = getOpt('--settings', path.join(os.homedir(), '.claude', 'settings.json'));
23
+
24
+ const readJson = (p) => {
25
+ try { const r = fs.readFileSync(p, 'utf8'); return JSON.parse(r.charCodeAt(0) === 0xFEFF ? r.slice(1) : r); }
26
+ catch { return null; }
27
+ };
28
+ const ok = (m) => console.log(' ok ' + m);
29
+ const bad = (m) => console.log(' FAIL ' + m);
30
+ const warn = (m) => console.log(' warn ' + m);
31
+
32
+ // ---- init: delegate to install.js (it owns idempotency, backup, abort-on-bad-JSON) ----
33
+ function init() {
34
+ const args = [path.join(SRC, 'install.js'), '--settings', settingsPath];
35
+ if (!argv.includes('--no-statusline')) args.push('--statusline');
36
+ const r = spawnSync('node', args, { encoding: 'utf8' });
37
+ process.stdout.write(r.stdout || '');
38
+ process.stderr.write(r.stderr || '');
39
+ if (r.status !== 0) process.exit(r.status || 1);
40
+ console.log('Brink installed. Takes effect on your next Claude Code session.');
41
+ console.log('Verify anytime: brink doctor Kill switch: brink off');
42
+ }
43
+
44
+ // ---- kill switch ----
45
+ function off() {
46
+ fs.mkdirSync(DIR, { recursive: true });
47
+ fs.writeFileSync(HATCH, 'created by `brink off` ' + new Date().toISOString() + '\n');
48
+ console.log('Brink DISABLED (kill switch on). Hooks stay installed but do nothing.');
49
+ console.log('Re-enable: brink on');
50
+ }
51
+ function on() {
52
+ try { fs.unlinkSync(HATCH); console.log('Brink enabled.'); }
53
+ catch { console.log('Brink was not disabled (no kill-switch file).'); }
54
+ }
55
+
56
+ // ---- uninstall: surgical — remove ONLY Brink's entries, keep everything else ----
57
+ function uninstall() {
58
+ const s = readJson(settingsPath);
59
+ if (s === null) {
60
+ console.error(`Cannot parse ${settingsPath} - nothing changed. Fix or remove it manually.`);
61
+ process.exit(1);
62
+ }
63
+ const removed = [];
64
+ for (const event of ['PreToolUse', 'PostToolUse']) {
65
+ const groups = (s.hooks && s.hooks[event]) || [];
66
+ const kept = groups.filter((g) => !(g.hooks || []).some((h) => /brink\.js/.test(h.command || '')));
67
+ if (kept.length !== groups.length) { s.hooks[event] = kept; removed.push(event); }
68
+ if (s.hooks && Array.isArray(s.hooks[event]) && s.hooks[event].length === 0) delete s.hooks[event];
69
+ }
70
+ if (s.statusLine && /statusline-brink\.js/.test(JSON.stringify(s.statusLine))) {
71
+ const bak = readJson(settingsPath + '.brink-bak');
72
+ if (bak && bak.statusLine) { s.statusLine = bak.statusLine; removed.push('statusLine(restored from backup)'); }
73
+ else { delete s.statusLine; removed.push('statusLine(removed)'); }
74
+ }
75
+ fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n');
76
+ console.log(removed.length
77
+ ? `Brink removed from ${settingsPath}: ${removed.join(', ')}`
78
+ : `Nothing to remove - Brink was not installed in ${settingsPath}.`);
79
+ console.log(`State dir kept at ${DIR} (delete it yourself, or run: brink uninstall --purge)`);
80
+ if (argv.includes('--purge')) {
81
+ try { fs.rmSync(DIR, { recursive: true, force: true }); console.log('State dir purged.'); } catch {}
82
+ }
83
+ }
84
+
85
+ // ---- doctor: verify the WHOLE chain on THIS machine. Exists because the detached-
86
+ // PowerShell bug passed every unit test for weeks while never working in reality —
87
+ // only end-to-end, on-machine checks catch environment-dependent silent failure. ----
88
+ function doctor() {
89
+ let fails = 0;
90
+ const check = (name, pass, failMsg, warnOnly) => {
91
+ if (pass) ok(name);
92
+ else if (warnOnly) warn(name + ' - ' + failMsg);
93
+ else { bad(name + ' - ' + failMsg); fails++; }
94
+ return pass;
95
+ };
96
+ console.log(`brink doctor (v${version()}, ${os.platform()} ${os.release()}, node ${process.version})`);
97
+
98
+ // 1. config surface
99
+ const s = readJson(settingsPath);
100
+ check('settings.json parses', s !== null, `cannot read/parse ${settingsPath}`);
101
+ const hookCmds = s ? JSON.stringify(s.hooks || {}) : '';
102
+ check('PreToolUse pause hook installed', /brink\.js\\?" claude pause|brink\.js" claude pause/.test(hookCmds) || /brink\.js/.test(hookCmds) && /pause/.test(hookCmds), 'run: brink init');
103
+ check('PostToolUse warn hook installed', /brink\.js/.test(hookCmds) && /warn/.test(hookCmds), 'run: brink init');
104
+ const sensorWired = s && s.statusLine && /statusline-brink\.js|state\.json/.test(JSON.stringify(s.statusLine));
105
+ check('statusline sensor wired', !!sensorWired,
106
+ 'no Brink-aware statusLine found - the hooks are BLIND without it (run: brink init, or merge the sensor into your custom statusline)', !s ? false : !sensorWired && !!s.statusLine);
107
+
108
+ // 2. kill switch
109
+ check('kill switch not active', !fs.existsSync(HATCH), 'DISABLED file present - run: brink on', true);
110
+
111
+ // 3. live state freshness (only meaningful during/after a real session)
112
+ const statePath = path.join(DIR, 'state.json');
113
+ const state = readJson(statePath);
114
+ if (state === null) warn('state.json not written yet - start a Claude Code session, then re-run doctor');
115
+ else {
116
+ ok('state.json exists and parses');
117
+ const age = (Date.now() - fs.statSync(statePath).mtimeMs) / 60000;
118
+ check('state is fresh (<30 min)', age < 30, `last write ${Math.round(age)} min ago - no active session, or the sensor is not running`, true);
119
+ check('usage data present', typeof state.five_pct === 'number' || typeof state.week_pct === 'number',
120
+ 'sensor runs but rate_limits are empty - API-key/Bedrock setups may not receive usage data; Brink cannot arm', true);
121
+ }
122
+
123
+ // 4. end-to-end pause simulation in a sandbox (never touches your real state)
124
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'brink-doctor-'));
125
+ try {
126
+ const future = Math.floor(Date.now() / 1000) + 3600;
127
+ fs.writeFileSync(path.join(sb, 'state.json'),
128
+ JSON.stringify({ five_pct: 99, week_pct: 10, five_reset: future, week_reset: future + 500000, session_id: 'doctor', cwd: sb.replace(/\\/g, '/') }));
129
+ const env = { ...process.env, BRINK_DIR: sb, BRINK_SILENT: '1' };
130
+ const r = spawnSync('node', [path.join(SRC, 'brink.js'), 'claude', 'pause'],
131
+ { encoding: 'utf8', env, input: JSON.stringify({ cwd: sb.replace(/\\/g, '/') }), timeout: 15000 });
132
+ check('pause fires at threshold (sandbox)', (r.stdout || '').includes('"permissionDecision":"deny"'), 'deny did not fire: ' + (r.stderr || r.stdout || 'no output').slice(0, 200));
133
+ check('HANDOFF.md written (sandbox)', fs.existsSync(path.join(sb, 'HANDOFF.md')), 'handoff writer failed');
134
+ fs.writeFileSync(path.join(sb, 'DISABLED'), '');
135
+ const r2 = spawnSync('node', [path.join(SRC, 'brink.js'), 'claude', 'pause'], { encoding: 'utf8', env, input: '{}', timeout: 15000 });
136
+ check('kill switch blocks the pause (sandbox)', (r2.stdout || '').trim() === '', 'DISABLED file did not stop the hook');
137
+ } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
138
+
139
+ // 5. live notification (real toast on Windows) — the step unit tests can never prove
140
+ if (argv.includes('--no-toast') || process.env.BRINK_SILENT) warn('notification test skipped (--no-toast/BRINK_SILENT)');
141
+ else {
142
+ const n = spawnSync('node', [path.join(SRC, 'notify.js'), 'Brink doctor: notifications work'], { encoding: 'utf8', timeout: 15000 });
143
+ let res = {}; try { res = JSON.parse((n.stdout || '{}').trim()); } catch {}
144
+ check('desktop notification fired', res.desktop === 'win-toast' || String(res.desktop || '').startsWith('desktop-todo'),
145
+ 'toast chain failed: ' + JSON.stringify(res), String(res.desktop || '').startsWith('desktop-todo'));
146
+ if (String(res.desktop) === 'win-toast') console.log(' (a toast should be on your screen right now)');
147
+ if (res.push && !/skipped/.test(res.push)) ok('phone push: ' + res.push);
148
+ }
149
+
150
+ console.log(fails ? `\n${fails} check(s) FAILED - paste this whole output into a GitHub issue.` : '\nAll critical checks passed.');
151
+ process.exit(fails ? 1 : 0);
152
+ }
153
+
154
+ function version() { return (readJson(path.join(SRC, '..', 'package.json')) || {}).version || '?'; }
155
+
156
+ function help() {
157
+ console.log(`brink v${version()} - graceful auto-pause + handoff for Claude Code
158
+ brink init [--no-statusline] [--settings <path>] install hooks + sensor
159
+ brink doctor [--no-toast] verify the whole chain end-to-end
160
+ brink off | on kill switch (instant disable/enable)
161
+ brink uninstall [--purge] remove cleanly (restores your statusline)
162
+ brink version`);
163
+ }
164
+
165
+ ({ init, off, on, uninstall, doctor, version: () => console.log(version()), '--version': () => console.log(version()), help }[cmd] || (() => { console.error(`unknown command: ${cmd}`); help(); process.exit(1); }))();
@@ -0,0 +1,72 @@
1
+ // Brink core — handoff writer (Phase 5).
2
+ // Brink writes HANDOFF.md ITSELF (deterministically, from the session transcript) rather
3
+ // than instructing the model to — because the live test (2026-06-26) showed the model may
4
+ // distrust a tool-result that orders it around and refuse. The handoff must survive even if
5
+ // the model won't cooperate. Best-effort transcript parse; degrades gracefully.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function readTranscript(p) {
10
+ try {
11
+ let raw = fs.readFileSync(p, 'utf8'); if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
12
+ return raw.split(/\r?\n/).filter(Boolean)
13
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
14
+ } catch { return []; }
15
+ }
16
+
17
+ function summarize(events) {
18
+ let lastUser = '';
19
+ const actions = [];
20
+ for (const e of events) {
21
+ const msg = e.message || e;
22
+ const role = msg.role || e.type;
23
+ const content = msg.content;
24
+ if (role === 'user' && content) {
25
+ const txt = typeof content === 'string'
26
+ ? content
27
+ : (Array.isArray(content) && (content.find((c) => c.type === 'text') || {}).text);
28
+ // skip tool_result echoes; keep real user prose
29
+ if (txt && !(Array.isArray(content) && content.some((c) => c.type === 'tool_result'))) lastUser = txt;
30
+ }
31
+ if (role === 'assistant' && Array.isArray(content)) {
32
+ for (const c of content) {
33
+ if (c.type === 'tool_use') {
34
+ const tgt = (c.input && (c.input.file_path || c.input.path || c.input.command)) || '';
35
+ actions.push(`${c.name}${tgt ? ' -> ' + String(tgt).slice(0, 80) : ''}`);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ return { lastUser: String(lastUser || '').slice(0, 800), actions: actions.slice(-8) };
41
+ }
42
+
43
+ function writeHandoff({ transcriptPath, cwd, pct, window, resetText }) {
44
+ const events = transcriptPath ? readTranscript(transcriptPath) : [];
45
+ const { lastUser, actions } = summarize(events);
46
+ const dir = cwd || '.';
47
+ const isGit = fs.existsSync(path.join(dir, '.git'));
48
+ const lines = [
49
+ '# HANDOFF - paused by Brink',
50
+ '',
51
+ `Paused at **${Math.round(pct)}%** of your ${window} usage limit${resetText ? ` (resets ${resetText})` : ''}.`,
52
+ 'This file was written automatically so no progress is lost. To resume, read it and continue the task.',
53
+ '',
54
+ '## The task',
55
+ '',
56
+ lastUser ? '> ' + lastUser.replace(/\n/g, '\n> ') : '_(could not read the original request from the transcript)_',
57
+ '',
58
+ '## Recent actions before the pause',
59
+ '',
60
+ actions.length ? actions.map((a) => '- ' + a).join('\n') : '_(no recent tool actions captured)_',
61
+ '',
62
+ '## Next steps',
63
+ '',
64
+ '- Continue the task above from where it stopped.',
65
+ isGit ? '- This IS a git repo - review uncommitted changes before continuing.' : '- (not a git repo - nothing to commit)',
66
+ '',
67
+ ];
68
+ const out = path.join(dir, 'HANDOFF.md');
69
+ try { fs.writeFileSync(out, lines.join('\n')); return out; } catch { return null; }
70
+ }
71
+
72
+ module.exports = { writeHandoff, summarize, readTranscript };
@@ -0,0 +1,46 @@
1
+ // Brink core — reset-ping detector (pure, no I/O). Phase 6.
2
+ // Detects when a usage window has ROLLED OVER (its resets_at epoch jumped forward)
3
+ // so Brink can fire a "your budget is back" ping. Only meaningful for a window you
4
+ // were actually near the limit on — a floor gate keeps a reset from 12% silent
5
+ // (that is not budget you were waiting on, it is noise).
6
+ //
7
+ // prev / cur: { five_pct, week_pct, five_reset, week_reset } (last-seen + current state)
8
+ // cfg.reset_ping: { five_hour:{enabled,floor}, seven_day:{enabled,floor} }
9
+ //
10
+ // Signal = resets_at advancing. Within a window resets_at is fixed; on rollover it
11
+ // jumps to the next window's end (hours for 5h, days for weekly). GUARD kills float
12
+ // jitter so only a genuine rollover counts.
13
+
14
+ const GUARD = 60; // seconds
15
+
16
+ const WINDOWS = [
17
+ { key: 'five_hour', label: '5h', pct: 'five_pct', reset: 'five_reset' },
18
+ { key: 'seven_day', label: 'weekly', pct: 'week_pct', reset: 'week_reset' },
19
+ ];
20
+
21
+ function detectResets(prev, cur, cfg) {
22
+ if (!prev || !cur) return []; // first run (no prior state) never pings
23
+ const rp = (cfg && cfg.reset_ping) || {};
24
+ const events = [];
25
+ for (const w of WINDOWS) {
26
+ const spec = rp[w.key] || {};
27
+ if (!spec.enabled) continue;
28
+ const pr = prev[w.reset];
29
+ const cr = cur[w.reset];
30
+ if (typeof pr !== 'number' || typeof cr !== 'number') continue;
31
+ if (cr <= pr + GUARD) continue; // didn't roll over
32
+ const prevPct = prev[w.pct];
33
+ const floor = typeof spec.floor === 'number' ? spec.floor : 0;
34
+ if (typeof prevPct === 'number' && prevPct < floor) continue; // weren't near the limit
35
+ events.push({
36
+ windowKey: w.key,
37
+ label: w.label,
38
+ prevPct: typeof prevPct === 'number' ? Math.round(prevPct) : null,
39
+ message: `Brink: ${w.label} limit reset - full quota again`
40
+ + (typeof prevPct === 'number' ? ` (was ${Math.round(prevPct)}%)` : ''),
41
+ });
42
+ }
43
+ return events;
44
+ }
45
+
46
+ module.exports = { detectResets, GUARD };
@@ -0,0 +1,48 @@
1
+ // Brink core — resume arming decision + arg builder (pure, no I/O). Phase 7.
2
+ // A paused Claude can't wake itself, so resume is EXTERNAL: on pause, Brink (opt-in)
3
+ // registers a one-shot Windows Task Scheduler job for the window's reset time that
4
+ // relaunches `claude --resume` from HANDOFF.md. This module decides WHETHER to arm
5
+ // and builds the PowerShell args; the actual scheduling lives in arm-resume.ps1.
6
+ //
7
+ // cfg.resume: { enabled:false, buffer_seconds:90, skip_permissions:false }
8
+
9
+ const DEFAULT_RESUME = { enabled: false, buffer_seconds: 90, skip_permissions: false };
10
+
11
+ function resumeCfg(cfg) {
12
+ const r = (cfg && cfg.resume) || {};
13
+ const out = { ...DEFAULT_RESUME, ...r };
14
+ // coerce / sanity-clamp
15
+ out.enabled = out.enabled === true;
16
+ out.buffer_seconds = Number.isFinite(out.buffer_seconds) && out.buffer_seconds >= 0
17
+ ? Math.floor(out.buffer_seconds) : DEFAULT_RESUME.buffer_seconds;
18
+ out.skip_permissions = out.skip_permissions === true;
19
+ return out;
20
+ }
21
+
22
+ // Arm only when: resume is enabled, we're on a platform with a scheduler (Windows in v1),
23
+ // and we actually have the session id + reset epoch the resume needs.
24
+ function shouldArm(cfg, ctx, platform) {
25
+ if (!resumeCfg(cfg).enabled) return false;
26
+ if (platform !== 'win32') return false; // mac/Linux (launchd/cron) documented, not v1
27
+ if (!ctx || !ctx.session_id) return false;
28
+ if (typeof ctx.reset !== 'number' || !isFinite(ctx.reset)) return false;
29
+ return true;
30
+ }
31
+
32
+ // PowerShell args for arm-resume.ps1. Strings only (spawn args).
33
+ // Proj is stripped of trailing (back)slashes: the scheduled task embeds it inside
34
+ // an escaped-quoted -Argument string, where a trailing backslash would escape the
35
+ // closing quote and mangle the whole command line (review finding).
36
+ function armArgs(cfg, ctx) {
37
+ const r = resumeCfg(cfg);
38
+ const proj = String(ctx.proj || '').replace(/[\\/]+$/, '');
39
+ return [
40
+ '-ResetsAt', String(ctx.reset),
41
+ '-Sid', String(ctx.session_id || ''),
42
+ '-Proj', proj,
43
+ '-Buffer', String(r.buffer_seconds),
44
+ '-Skip', r.skip_permissions ? '1' : '0',
45
+ ];
46
+ }
47
+
48
+ module.exports = { resumeCfg, shouldArm, armArgs, DEFAULT_RESUME };
@@ -0,0 +1,44 @@
1
+ // Brink core — threshold decision engine (pure, no I/O). Phase 3.
2
+ // Shared by every adapter (Claude, Codex, ...). Given normalized usage + config,
3
+ // decide allow | warn | pause for the most-urgent window.
4
+ //
5
+ // usage: { five_pct, week_pct, five_reset, week_reset } (pct = number 0-100 or null)
6
+ // cfg: { five_hour:{warn:[75,85], pause:93}, seven_day:{warn:[80,90], pause:95} }
7
+
8
+ function bandFor(pct, spec) {
9
+ // highest band crossed: 'pause' | 'warn:<n>' | null
10
+ if (typeof pct !== 'number') return null;
11
+ if (typeof spec.pause === 'number' && pct >= spec.pause) return 'pause';
12
+ const warns = (spec.warn || []).slice().sort((a, b) => b - a); // high -> low
13
+ for (const w of warns) if (pct >= w) return `warn:${w}`;
14
+ return null;
15
+ }
16
+
17
+ function decide(usage, cfg) {
18
+ const windows = [
19
+ { key: 'five_hour', label: '5h', pct: usage.five_pct, reset: usage.five_reset },
20
+ { key: 'seven_day', label: 'weekly', pct: usage.week_pct, reset: usage.week_reset },
21
+ ];
22
+ const hit = windows
23
+ .map((w) => ({ ...w, band: bandFor(w.pct, cfg[w.key] || {}) }))
24
+ .filter((w) => w.band);
25
+
26
+ // pause beats warn; then higher pct wins
27
+ hit.sort((a, b) => {
28
+ const rank = (x) => (x.band === 'pause' ? 2 : 1);
29
+ return rank(b) - rank(a) || b.pct - a.pct;
30
+ });
31
+
32
+ const top = hit[0];
33
+ if (!top) return { action: 'allow' };
34
+ return {
35
+ action: top.band === 'pause' ? 'pause' : 'warn',
36
+ band: top.band,
37
+ window: top.label,
38
+ windowKey: top.key,
39
+ pct: top.pct,
40
+ reset: top.reset,
41
+ };
42
+ }
43
+
44
+ module.exports = { decide, bandFor };
package/src/install.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // Brink installer — Claude Code adapter (the `brink init` for Claude).
3
+ // Usage: node install.js [--settings <path>] [--statusline]
4
+ // Registers the Brink hooks into the target Claude Code settings.json:
5
+ // PreToolUse -> node brink.js claude pause (graceful auto-pause before the limit)
6
+ // PostToolUse -> node brink.js claude warn (threshold warning notifications)
7
+ // --statusline also points statusLine -> node statusline-brink.js (the usage sensor)
8
+ // Idempotent (won't duplicate the brink hook for an event). Backs up the original once.
9
+ //
10
+ // SAFE BY DEFAULT for testing: pass --settings <sandbox> to target a throwaway file.
11
+ // Bare `node` is used (not an absolute path) so the command works whether Claude Code
12
+ // runs the hook in bash or PowerShell — node is on PATH wherever Claude Code runs.
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const SRC = __dirname;
18
+ const q = (p) => `"${String(p).replace(/\\/g, '/')}"`;
19
+ const cmd = (script, ...args) => ['node', q(path.join(SRC, script)), ...args].join(' ');
20
+
21
+ const argv = process.argv.slice(2);
22
+ const getOpt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? (argv[i + 1] || true) : d; };
23
+ const settingsPath = getOpt('--settings', path.join(os.homedir(), '.claude', 'settings.json'));
24
+ const withStatusline = argv.includes('--statusline');
25
+
26
+ const PAUSE = cmd('brink.js', 'claude', 'pause');
27
+ const WARN = cmd('brink.js', 'claude', 'warn');
28
+ const STATUS = cmd('statusline-brink.js');
29
+
30
+ // BOM-tolerant read. Returns null (NOT {}) on a parse failure so the caller can
31
+ // ABORT — silently proceeding here wiped the user's whole settings.json down to
32
+ // Brink-only content (review finding: a UTF-8 BOM was enough to trigger it).
33
+ const read = (p) => {
34
+ try {
35
+ const raw = fs.readFileSync(p, 'utf8');
36
+ return JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
37
+ } catch { return null; }
38
+ };
39
+ const hasBrinkHook = (s, event) =>
40
+ ((s.hooks && s.hooks[event]) || []).some((g) => (g.hooks || []).some((h) => /brink\.js/.test(h.command || '')));
41
+
42
+ function addHook(s, event, command) {
43
+ s.hooks = s.hooks || {};
44
+ s.hooks[event] = s.hooks[event] || [];
45
+ if (hasBrinkHook(s, event)) return false;
46
+ s.hooks[event].push({ hooks: [{ type: 'command', command }] });
47
+ return true;
48
+ }
49
+
50
+ function main() {
51
+ const existed = fs.existsSync(settingsPath);
52
+ let s = {};
53
+ if (existed) {
54
+ s = read(settingsPath);
55
+ if (s === null) {
56
+ console.error(`Brink install ABORTED: ${settingsPath} exists but is not valid JSON. ` +
57
+ 'Fix it (or remove it) and re-run - refusing to overwrite your settings.');
58
+ process.exit(1);
59
+ }
60
+ if (!fs.existsSync(settingsPath + '.brink-bak')) {
61
+ fs.copyFileSync(settingsPath, settingsPath + '.brink-bak'); // one-time backup
62
+ }
63
+ }
64
+ const changed = [];
65
+ if (addHook(s, 'PreToolUse', PAUSE)) changed.push('PreToolUse(pause)');
66
+ if (addHook(s, 'PostToolUse', WARN)) changed.push('PostToolUse(warn)');
67
+ if (withStatusline && !/brink/.test(JSON.stringify(s.statusLine || ''))) {
68
+ if (s.statusLine) {
69
+ // be loud, not silent: the user had a custom statusline (review finding)
70
+ console.error(`note: replacing existing statusLine (original kept in ${settingsPath}.brink-bak)`);
71
+ }
72
+ s.statusLine = { type: 'command', command: STATUS };
73
+ changed.push('statusLine');
74
+ }
75
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
76
+ fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n');
77
+ console.log(JSON.stringify({ settingsPath, changed, alreadyInstalled: changed.length === 0 }));
78
+ }
79
+ main();