claude-token-saver 2.1.0 → 2.5.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.
@@ -17,12 +17,34 @@
17
17
  * }
18
18
  */
19
19
 
20
+ import { formatResetClock } from '../format-time.js';
21
+ import { labelForKey } from '../window-labels.js';
22
+
23
+ // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
24
+ // next to each other — terminal palettes set them with unbalanced perceptual
25
+ // brightness, so the line ends up feeling loud. We emit a Tailwind-inspired
26
+ // muted palette via 24-bit truecolor when the terminal advertises support
27
+ // (`COLORTERM=truecolor`/`24bit`), and gracefully fall back to the legacy
28
+ // 8-color codes on terminals that don't.
29
+ //
30
+ // GREEN → emerald-400 #34D399 (calm, balanced with the others)
31
+ // YELLOW → amber-400 #FBBF24 (warm, not screamy)
32
+ // RED → rose-400 #FB7185 (alarm without the eye-burn of pure red)
33
+ // CYAN → cyan-400 #22D3EE
34
+ // MAGENTA → violet-400 #A78BFA (model identity tone)
35
+ // GRAY → slate-500 #64748B (recedes for the gauge track / period footer)
36
+ const TRUECOLOR =
37
+ process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit';
38
+ const fg = (r, g, b, fallback) =>
39
+ TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : fallback;
40
+
20
41
  const RESET = '\x1b[0m';
21
- const RED = '\x1b[31m';
22
- const GREEN = '\x1b[32m';
23
- const YELLOW = '\x1b[33m';
24
- const CYAN = '\x1b[36m';
25
- const GRAY = '\x1b[90m';
42
+ const RED = fg(251, 113, 133, '\x1b[31m');
43
+ const GREEN = fg(52, 211, 153, '\x1b[32m');
44
+ const YELLOW = fg(251, 191, 36, '\x1b[33m');
45
+ const CYAN = fg(34, 211, 238, '\x1b[36m');
46
+ const MAGENTA = fg(167, 139, 250, '\x1b[35m');
47
+ const GRAY = fg(100, 116, 139, '\x1b[90m');
26
48
  const BOLD = '\x1b[1m';
27
49
 
28
50
  function formatMoney(usd) {
@@ -36,6 +58,44 @@ function formatPct(v) {
36
58
  return `${(v * 100).toFixed(1)}%`;
37
59
  }
38
60
 
61
+ /**
62
+ * Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
63
+ * the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
64
+ * → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
65
+ * gradient instead of an awkward step.
66
+ *
67
+ * Earlier we used partial-fill glyphs (`▏▎▍▌▋▊▉`) for sub-cell precision, but
68
+ * those have transparent halves that clash visually with the `░` track next
69
+ * to them (the eye sees "solid edge | gap | dotted track" — three zones).
70
+ * Density chars are the same shape, just darker/lighter, so the boundary
71
+ * cell reads as a single smooth fade.
72
+ *
73
+ * Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
74
+ * for ~4% effective precision around the fill edge. Stable monospace width
75
+ * across all terminal fonts that ship Block Elements (U+2580–U+259F).
76
+ */
77
+ function gaugeBar(pct) {
78
+ const cells = 6;
79
+ const clamped = Math.max(0, Math.min(100, pct));
80
+ const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
81
+ const fullCells = Math.floor(filled);
82
+ const remainder = filled - fullCells; // 0..1 — fill fraction of the boundary cell
83
+ // Boundary cell: ░ (empty), ▒ (1/3), ▓ (2/3), or roll over to a full █.
84
+ let partial = '';
85
+ let extra = 0;
86
+ if (remainder >= 0.83) {
87
+ extra = 1; // round up — fill the boundary cell completely
88
+ } else if (remainder >= 0.5) {
89
+ partial = '▓';
90
+ } else if (remainder >= 0.16) {
91
+ partial = '▒';
92
+ } // else: remainder is too small to show — leave the cell empty
93
+ const totalFull = Math.min(cells, fullCells + extra);
94
+ const usedCells = totalFull + (partial ? 1 : 0);
95
+ const empty = '░'.repeat(Math.max(0, cells - usedCells));
96
+ return '█'.repeat(totalFull) + partial + empty;
97
+ }
98
+
39
99
  /**
40
100
  * Format a remaining-seconds countdown as MM:SS (or H:MM when ≥ 1h).
41
101
  */
@@ -49,6 +109,25 @@ function formatTimer(remainingSec) {
49
109
  return `${m}:${String(s).padStart(2, '0')}`;
50
110
  }
51
111
 
112
+ /**
113
+ * Pick the cap-warn chip that should surface from any of the rate-limit
114
+ * windows, or null when none are at 90%+. When multiple windows are warning,
115
+ * the one that resets sooner wins (it's the more imminent block).
116
+ */
117
+ export function pickCapWarn(caps) {
118
+ if (!caps || !Array.isArray(caps.windows)) return null;
119
+ const candidates = caps.windows
120
+ .filter((w) => Number.isFinite(w.usedPct) && w.usedPct >= 90)
121
+ .map((w) => ({ ...w, label: labelForKey(w.key).short }));
122
+ if (candidates.length === 0) return null;
123
+ candidates.sort((a, b) => {
124
+ const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
125
+ const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
126
+ return ar - br;
127
+ });
128
+ return candidates[0];
129
+ }
130
+
52
131
  /**
53
132
  * @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
54
133
  * @param {object} [opts]
@@ -56,9 +135,10 @@ function formatTimer(remainingSec) {
56
135
  * @param {boolean} [opts.verbose=false] - longer layout with labels
57
136
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
58
137
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
138
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
59
139
  */
60
- export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
61
- const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
140
+ export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
141
+ const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
62
142
  const { hitRate } = summary;
63
143
 
64
144
  // Hit rate → color signal
@@ -156,12 +236,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
156
236
  if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
157
237
  const label = contextWindow.size === '1M' ? '1M' : '200k';
158
238
  const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
239
+ // `Ctx` (not the full word `Context`) across every mode — the icon already
240
+ // tells the eye what the chip is, and the short form fits the same cadence
241
+ // as `Hit`/`Saved` peers when we eventually shorten those too.
159
242
  if (isIcon && verbose) {
160
- ctxSeg = `${c(ctxColor)}📦 Context ${label}${c(RESET)}`;
243
+ ctxSeg = `${c(ctxColor)}📦 Ctx ${label}${c(RESET)}`;
161
244
  } else if (isIcon) {
162
245
  ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
163
- } else if (verbose) {
164
- ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
165
246
  } else {
166
247
  ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
167
248
  }
@@ -170,12 +251,131 @@ export function formatReport(data, { color = true, verbose = false, timer = true
170
251
  // Spike chip — one word only, keeps the statusline single-line.
171
252
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
172
253
 
254
+ // Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
255
+ // Cheap identity context: useful when the user toggles between Sonnet/Opus
256
+ // mid-session and wants to confirm at a glance which one is answering.
257
+ let modelSeg = null;
258
+ if (typeof model === 'string' && model.length > 0) {
259
+ // 🤖 + name is enough — the emoji disambiguates so the literal word "Model"
260
+ // is dead weight in icon mode. Text modes keep the bare name; the magenta
261
+ // tone marks it as identity context.
262
+ if (isIcon) {
263
+ modelSeg = `${c(MAGENTA)}🤖 ${model}${c(RESET)}`;
264
+ } else {
265
+ modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
266
+ }
267
+ }
268
+
269
+ // Always-on usage segments — what /usage shows in Claude Code, mirrored
270
+ // to the statusline so the user doesn't have to slash-command for it.
271
+ // Today the stdin payload exposes the 5h ("Current session") and 7-day
272
+ // rolling ("Current week") windows; if Anthropic ships more (e.g. a
273
+ // Sonnet-only weekly), they render automatically with derived labels.
274
+ // Each renders as `{label} {pct}% · {countdown}`. When a window is at >=90%
275
+ // the cap-warn chip already shouts about it, so we suppress the always-on
276
+ // segment to avoid duplicate noise.
277
+ function buildUsageSeg({ labels, info, color: tone }) {
278
+ if (!info || !Number.isFinite(info.usedPct)) return null;
279
+ if (info.usedPct >= 90) return null; // cap-warn chip handles this case
280
+ const pct = Math.round(info.usedPct);
281
+ // Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
282
+ // doesn't tick second-by-second so the statusline reads stable, and the
283
+ // 🔄 icon itself separates the percent from the clock — no extra `·` needed.
284
+ const clock = formatResetClock(info.resetsAt);
285
+ const tail = clock ? ` 🔄 ${clock}` : '';
286
+ // `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
287
+ // leans on the icon to identify the window (✦ = session/now, 📅 = week),
288
+ // so the 5H label is empty while the 7D label spells out "weekly". Text and
289
+ // verbose modes keep the `5H`/`7D` short label since they have no icon.
290
+ // Icon mode renders an inline ▰▱ gauge instead of the literal "cap used" —
291
+ // a glance at the bar conveys urgency faster than parsing a percent number,
292
+ // and the gauge stays the same width as the percent climbs.
293
+ if (isIcon) {
294
+ const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
295
+ const bar = gaugeBar(pct);
296
+ return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${tail}${c(RESET)}`;
297
+ }
298
+ if (verbose) {
299
+ return `${c(tone)}${labels.short} cap ${pct}% used${tail}${c(RESET)}`;
300
+ }
301
+ return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
302
+ }
303
+ // Color tone: green when <70%, yellow 70-89% (the segment is suppressed at
304
+ // 90+% in favor of cap-warn). Lets the user spot "I'm getting close" without
305
+ // waiting for the alarm chip.
306
+ function usageTone(info) {
307
+ if (!info || !Number.isFinite(info.usedPct)) return GRAY;
308
+ if (info.usedPct >= 70) return YELLOW;
309
+ return GREEN;
310
+ }
311
+ const usageSegs = [];
312
+ if (caps && Array.isArray(caps.windows)) {
313
+ for (const win of caps.windows) {
314
+ const labels = labelForKey(win.key);
315
+ const seg = buildUsageSeg({
316
+ labels,
317
+ info: win,
318
+ color: usageTone(win),
319
+ });
320
+ if (seg) usageSegs.push({ key: win.key, seg });
321
+ }
322
+ }
323
+
324
+ // Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
325
+ // It's the most actionable signal we can show: no point optimizing cache
326
+ // hits if you're about to be rate-limited anyway. The chip body matches the
327
+ // English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
328
+ const capWarn = pickCapWarn(caps);
329
+ let capWarnSeg = null;
330
+ if (capWarn) {
331
+ const pct = Math.round(capWarn.usedPct);
332
+ // At 90%+ the user wants to know "when can I send again" — wall-clock is
333
+ // the actionable bit. Same `🔄 HH:MM` shape as the always-on segments so
334
+ // the icon's meaning carries over to the alarm chip.
335
+ const clock = formatResetClock(capWarn.resetsAt);
336
+ const clockTail = clock ? ` 🔄 ${clock}` : '';
337
+ if (isIcon) {
338
+ // Gauge keeps shape parity with the always-on usage segment — the
339
+ // cap-warn is just the same gauge "filled to alarm". Visual continuity
340
+ // helps the eye understand "this is the 5H bar I was watching, just red now."
341
+ const bar = gaugeBar(pct);
342
+ capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
343
+ } else {
344
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
345
+ }
346
+ }
347
+
173
348
  // Warning chip leads — a glance at the statusline catches "something's wrong"
174
349
  // before parsing any numbers. Healthy states have no chip and look unchanged.
350
+ // Cap-warn outranks spike: an imminent rate-limit block is more urgent than
351
+ // a single spiking session.
352
+ const allow = segments && segments.length
353
+ ? new Set(segments.map((s) => s.toLowerCase()))
354
+ : null;
355
+ const want = (name) => !allow || allow.has(name);
356
+ // Legacy whitelist aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`. So
357
+ // existing `--segments` configs keep working after the generic refactor.
358
+ const usageWant = (key) => {
359
+ if (!allow) return true;
360
+ if (allow.has(key.toLowerCase())) return true;
361
+ if (key === 'five_hour' && allow.has('5h')) return true;
362
+ if (key === 'seven_day' && allow.has('7d')) return true;
363
+ return false;
364
+ };
175
365
  const segs = [];
176
- if (spikeSeg) segs.push(spikeSeg);
177
- segs.push(hitSeg, ttlSeg, saveSeg);
178
- if (ctxSeg) segs.push(ctxSeg);
179
- segs.push(periodSeg);
366
+ if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
367
+ if (spikeSeg && want('spike')) segs.push(spikeSeg);
368
+ if (modelSeg && want('model')) segs.push(modelSeg);
369
+ if (want('hit')) segs.push(hitSeg);
370
+ if (want('ttl')) segs.push(ttlSeg);
371
+ for (const { key, seg } of usageSegs) {
372
+ if (usageWant(key)) segs.push(seg);
373
+ }
374
+ if (ctxSeg && want('ctx')) segs.push(ctxSeg);
375
+ // Cache saved is the "lifetime brag" stat — useful but not actionable, so
376
+ // it sits near the tail. The period label closes the line as a quiet
377
+ // timeframe footer.
378
+ if (want('saved')) segs.push(saveSeg);
379
+ if (want('period')) segs.push(periodSeg);
180
380
  return segs.join(' · ');
181
381
  }
@@ -2,6 +2,8 @@
2
2
  * Terminal table formatter — zero dependencies.
3
3
  */
4
4
  import { ISSUE_MESSAGES } from '../advice.js';
5
+ import { formatResetIn, formatResetClock } from '../format-time.js';
6
+ import { labelForKey } from '../window-labels.js';
5
7
 
6
8
  function pad(str, len, align = 'left') {
7
9
  const s = String(str);
@@ -123,7 +125,34 @@ function renderSpikeSection(spikes, contextWindow) {
123
125
  return lines;
124
126
  }
125
127
 
126
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow }) {
128
+ function renderCapWarnSection(caps) {
129
+ if (!caps || !Array.isArray(caps.windows)) return [];
130
+ const warning = caps.windows.filter(
131
+ (w) => Number.isFinite(w.usedPct) && w.usedPct >= 90,
132
+ );
133
+ if (warning.length === 0) return [];
134
+ const lines = [];
135
+ lines.push(' 🚨 Rate-limit cap is closing in');
136
+ lines.push(` ${'─'.repeat(50)}`);
137
+ for (const win of warning) {
138
+ const label = labelForKey(win.key).long;
139
+ const reset = formatResetIn(win.resetsAt);
140
+ const clock = formatResetClock(win.resetsAt);
141
+ let tail = '';
142
+ if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
143
+ else if (reset) tail = `, resets in ${reset}`;
144
+ else if (clock) tail = `, resets at ${clock}`;
145
+ lines.push(` • ${label}: ${Math.round(win.usedPct)}% used${tail}`);
146
+ }
147
+ lines.push('');
148
+ lines.push(' Back up work before the cap hits:');
149
+ lines.push(' claude-token-saver handoff');
150
+ lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
151
+ lines.push('');
152
+ return lines;
153
+ }
154
+
155
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
127
156
  const lines = [];
128
157
 
129
158
  // Header
@@ -133,7 +162,12 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
133
162
  lines.push(` ${'═'.repeat(50)}`);
134
163
  lines.push('');
135
164
 
136
- // Spike section goes FIRST — it's what the user acts on.
165
+ // Cap warning leads — it's the most time-sensitive signal we can show.
166
+ if (caps) {
167
+ lines.push(...renderCapWarnSection(caps));
168
+ }
169
+
170
+ // Spike section goes next — it's what the user acts on.
137
171
  if (spikeReport && spikeReport.spikes.length > 0) {
138
172
  lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
139
173
  }
package/src/handoff.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Handoff template — captures enough state at session-cap time that a fresh
3
+ * Claude Code session can pick up the work without a long prelude.
4
+ *
5
+ * Output: `./HANDOFF-YYYY-MM-DD-HHMM.md` in the caller's cwd. We never
6
+ * overwrite — if the path is taken we add a `-N` suffix.
7
+ *
8
+ * What goes in:
9
+ * - Header: timestamp, cwd, git branch / HEAD / dirty file list
10
+ * - Cap snapshot: 5h/7d % and resets-in (when known)
11
+ * - Empty fillable sections the user pastes context into
12
+ * - A one-line resume prompt for the next session
13
+ */
14
+
15
+ import { writeFileSync, existsSync } from 'node:fs';
16
+ import { execSync } from 'node:child_process';
17
+ import { join, resolve } from 'node:path';
18
+
19
+ import { formatResetIn, formatResetClock } from './format-time.js';
20
+ import { labelForKey } from './window-labels.js';
21
+
22
+ function pad(n) {
23
+ return String(n).padStart(2, '0');
24
+ }
25
+
26
+ function ymd(d = new Date()) {
27
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
28
+ }
29
+
30
+ function hhmm(d = new Date()) {
31
+ return `${pad(d.getHours())}${pad(d.getMinutes())}`;
32
+ }
33
+
34
+ function safeGit(cmd, cwd) {
35
+ try {
36
+ return execSync(`git ${cmd}`, {
37
+ cwd,
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ }).trim();
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ function gitSnapshot(cwd) {
47
+ // `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
48
+ // with no commits yet (where `rev-parse HEAD` would fail). We use it as the
49
+ // "is this a repo?" probe.
50
+ const gitDir = safeGit('rev-parse --git-dir', cwd);
51
+ if (!gitDir) return null;
52
+ const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
53
+ const head = safeGit('rev-parse --short HEAD', cwd);
54
+ const status = safeGit('status --short', cwd);
55
+ return { branch, head, status };
56
+ }
57
+
58
+ function pickPath(cwd, now) {
59
+ const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
60
+ const direct = join(cwd, `${stem}.md`);
61
+ if (!existsSync(direct)) return direct;
62
+ for (let i = 2; i < 100; i++) {
63
+ const candidate = join(cwd, `${stem}-${i}.md`);
64
+ if (!existsSync(candidate)) return candidate;
65
+ }
66
+ return join(cwd, `${stem}-${Date.now()}.md`);
67
+ }
68
+
69
+ function renderTemplate({ now, cwd, git, caps }) {
70
+ const lines = [];
71
+ lines.push(`# Handoff — ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
72
+ lines.push('');
73
+ lines.push(`Generated by \`claude-token-saver handoff\`.`);
74
+ lines.push('');
75
+ lines.push('## Context');
76
+ lines.push('');
77
+ lines.push(`- cwd: \`${cwd}\``);
78
+ if (git) {
79
+ lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
80
+ if (git.status) {
81
+ lines.push('- dirty files:');
82
+ lines.push(' ```');
83
+ for (const line of git.status.split('\n')) lines.push(` ${line}`);
84
+ lines.push(' ```');
85
+ } else {
86
+ lines.push('- working tree: clean');
87
+ }
88
+ } else {
89
+ lines.push('- git: (not a repo)');
90
+ }
91
+ lines.push('');
92
+
93
+ lines.push('## Cap snapshot');
94
+ lines.push('');
95
+ if (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
96
+ for (const win of caps.windows) {
97
+ const label = labelForKey(win.key).long;
98
+ if (!Number.isFinite(win.usedPct)) {
99
+ lines.push(`- ${label}: (unknown)`);
100
+ continue;
101
+ }
102
+ const reset = formatResetIn(win.resetsAt, now);
103
+ const clock = formatResetClock(win.resetsAt, now);
104
+ let tail = '';
105
+ if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
106
+ else if (reset) tail = `, resets in ${reset}`;
107
+ else if (clock) tail = `, resets at ${clock}`;
108
+ lines.push(`- ${label}: ${Math.round(win.usedPct)}%${tail}`);
109
+ }
110
+ } else {
111
+ lines.push('- (no cap data — run `handoff` from a Claude Code session for live numbers)');
112
+ }
113
+ lines.push('');
114
+
115
+ lines.push('## What I just did');
116
+ lines.push('');
117
+ lines.push('- _(fill in: 1–3 bullets describing the most recent work)_');
118
+ lines.push('');
119
+
120
+ lines.push('## What\'s left (TODO)');
121
+ lines.push('');
122
+ lines.push('- [ ] _(fill in)_');
123
+ lines.push('');
124
+
125
+ lines.push('## Where to pick up next');
126
+ lines.push('');
127
+ lines.push('- _(file paths, function names, the exact next step)_');
128
+ lines.push('');
129
+
130
+ lines.push('## Watch out for');
131
+ lines.push('');
132
+ lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
133
+ lines.push('');
134
+
135
+ lines.push('## Resume prompt for the next Claude Code session');
136
+ lines.push('');
137
+ lines.push('```');
138
+ lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
139
+ lines.push('```');
140
+ lines.push('');
141
+
142
+ return lines.join('\n') + '\n';
143
+ }
144
+
145
+ /**
146
+ * Write a handoff file in the given cwd.
147
+ *
148
+ * @param {object} [opts]
149
+ * @param {string} [opts.cwd=process.cwd()]
150
+ * @param {object|null} [opts.caps] - { windows: [...] } from extractCaps
151
+ * @param {Date} [opts.now=new Date()]
152
+ * @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
153
+ */
154
+ export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
155
+ const absCwd = resolve(cwd);
156
+ const git = gitSnapshot(absCwd);
157
+ const path = pickPath(absCwd, now);
158
+ const body = renderTemplate({ now, cwd: absCwd, git, caps });
159
+ writeFileSync(path, body);
160
+ return { path, git };
161
+ }