claude-token-saver 2.0.3 โ†’ 2.2.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.
@@ -49,6 +49,39 @@ function formatTimer(remainingSec) {
49
49
  return `${m}:${String(s).padStart(2, '0')}`;
50
50
  }
51
51
 
52
+ /**
53
+ * Pick the cap-warn chip ({ kind, usedPct, resetsAt, label }) that should
54
+ * surface, or null if neither window is at 90%+. When both windows are warning,
55
+ * the one that resets sooner wins (it's the more imminent block).
56
+ */
57
+ export function pickCapWarn(caps) {
58
+ if (!caps) return null;
59
+ const candidates = [];
60
+ if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
61
+ candidates.push({
62
+ kind: 'five_hour',
63
+ label: '5H',
64
+ usedPct: caps.fiveHour.usedPct,
65
+ resetsAt: caps.fiveHour.resetsAt,
66
+ });
67
+ }
68
+ if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
69
+ candidates.push({
70
+ kind: 'seven_day',
71
+ label: '7D',
72
+ usedPct: caps.sevenDay.usedPct,
73
+ resetsAt: caps.sevenDay.resetsAt,
74
+ });
75
+ }
76
+ if (candidates.length === 0) return null;
77
+ candidates.sort((a, b) => {
78
+ const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
79
+ const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
80
+ return ar - br;
81
+ });
82
+ return candidates[0];
83
+ }
84
+
52
85
  /**
53
86
  * @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
54
87
  * @param {object} [opts]
@@ -56,9 +89,10 @@ function formatTimer(remainingSec) {
56
89
  * @param {boolean} [opts.verbose=false] - longer layout with labels
57
90
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
58
91
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐Ÿง  โณ ๐Ÿ’ฐ instead of word labels.
92
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, hit, ttl, saved, ctx, period. Null/undefined = all.
59
93
  */
60
- export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
61
- const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
94
+ export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
95
+ const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps } = data;
62
96
  const { hitRate } = summary;
63
97
 
64
98
  // Hit rate โ†’ color signal
@@ -87,25 +121,31 @@ export function formatReport(data, { color = true, verbose = false, timer = true
87
121
  : 'Cache hit';
88
122
  const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
89
123
 
90
- // text: "Cost saved $1.5K" | same in verbose
91
- // icon: "๐Ÿ’ฐ $1.5K" | verbose: "๐Ÿ’ฐ Cost saved $1.5K"
124
+ // text: "Cache saved $1.5K" | same in verbose
125
+ // icon: "๐Ÿ’ฐ $1.5K" | verbose: "๐Ÿ’ฐ Cache saved $1.5K"
92
126
  const saveLabel = isIcon
93
- ? (verbose ? '๐Ÿ’ฐ Cost saved' : '๐Ÿ’ฐ')
94
- : 'Cost saved';
127
+ ? (verbose ? '๐Ÿ’ฐ Cache saved' : '๐Ÿ’ฐ')
128
+ : 'Cache saved';
95
129
  const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
96
130
 
131
+ // Period label honors hour-precision configs (`mode 6h` โ†’ "6h", `mode 1d` โ†’ "1d").
132
+ // Fall back to legacy `${days}d` when callers haven't supplied a label.
133
+ const periodLabel = options.windowLabel || `${options.days}d`;
97
134
  const periodSeg = verbose
98
- ? `${c(GRAY)}last ${options.days}d${c(RESET)}`
99
- : `${c(GRAY)}${options.days}d${c(RESET)}`;
135
+ ? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
136
+ : `${c(GRAY)}${periodLabel}${c(RESET)}`;
100
137
 
101
138
  // TTL countdown โ€” how much time is left on the last API call's cache entry.
102
139
  // Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
103
140
  // TTL window, and the next call (hit) within that window resets it. So the
104
141
  // countdown visibly ticks down between prompts, and "resets" happens as a
105
142
  // jump back toward the bucket max the moment you send another message.
106
- // text compact: "Expires 1h 59:58"
143
+ // Compact modes drop the bucket label โ€” it's read as part of the clock
144
+ // ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
145
+ // is plan-determined and rarely changes, so verbose mode is where it belongs.
146
+ // text compact: "Expires 59:58"
107
147
  // text verbose: "1h bucket ยท expires in 59:58"
108
- // icon compact: "โณ 1h 59:58"
148
+ // icon compact: "โณ 59:58"
109
149
  // icon verbose: "โณ Expires 1h 59:58"
110
150
  let ttlSeg;
111
151
  if (timer && lastActivity) {
@@ -119,22 +159,28 @@ export function formatReport(data, { color = true, verbose = false, timer = true
119
159
  pct > 0.10 ? YELLOW :
120
160
  RED;
121
161
 
122
- if (isIcon) {
123
- const prefix = verbose ? 'โณ Expires ' : 'โณ ';
124
- ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
162
+ if (isIcon && verbose) {
163
+ // Drop bucket here too โ€” `โณ Expires 1h 57:20` reads as "1h 57m 20s left"
164
+ // for the same reason the compact form did. The bucket lives in the
165
+ // text-verbose layout where the "bucket" word + `ยท` separator make it
166
+ // unambiguous.
167
+ ttlSeg = `${c(timerColor)}โณ Cache expires ${text}${c(RESET)}`;
168
+ } else if (isIcon) {
169
+ ttlSeg = `${c(timerColor)}โณ ${text}${c(RESET)}`;
125
170
  } else if (verbose) {
126
- ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)} ยท ${c(timerColor)}expires in ${text}${c(RESET)}`;
171
+ ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} ยท ${c(timerColor)}expires in ${text}${c(RESET)}`;
127
172
  } else {
128
- ttlSeg = `${c(bucketColor)}Expires ${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
173
+ ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
129
174
  }
130
175
  } else {
176
+ // No-timer fallback: only the bucket is available, so we show just that.
131
177
  if (isIcon) {
132
- const prefix = verbose ? 'โณ Bucket ' : 'โณ ';
178
+ const prefix = verbose ? 'โณ Cache bucket ' : 'โณ ';
133
179
  ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
134
180
  } else if (verbose) {
135
- ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)}`;
181
+ ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
136
182
  } else {
137
- ttlSeg = `${c(bucketColor)}Bucket ${bucketLabel}${c(RESET)}`;
183
+ ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
138
184
  }
139
185
  }
140
186
 
@@ -144,7 +190,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
144
190
  if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
145
191
  const label = contextWindow.size === '1M' ? '1M' : '200k';
146
192
  const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
147
- if (isIcon) {
193
+ if (isIcon && verbose) {
194
+ ctxSeg = `${c(ctxColor)}๐Ÿ“ฆ Context ${label}${c(RESET)}`;
195
+ } else if (isIcon) {
148
196
  ctxSeg = `${c(ctxColor)}๐Ÿ“ฆ ${label}${c(RESET)}`;
149
197
  } else if (verbose) {
150
198
  ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
@@ -156,9 +204,40 @@ export function formatReport(data, { color = true, verbose = false, timer = true
156
204
  // Spike chip โ€” one word only, keeps the statusline single-line.
157
205
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
158
206
 
159
- const segs = [hitSeg, ttlSeg, saveSeg];
160
- if (ctxSeg) segs.push(ctxSeg);
161
- if (spikeSeg) segs.push(spikeSeg);
162
- segs.push(periodSeg);
207
+ // Cap-warn chip โ€” leads everything when ANY rate-limit window is at 90%+.
208
+ // It's the most actionable signal we can show: no point optimizing cache
209
+ // hits if you're about to be rate-limited anyway. The chip body matches the
210
+ // English shape `๐Ÿšจ 5H 94%` / `๐Ÿšจ 7D 92%` so history parsers can dedupe on it.
211
+ const capWarn = pickCapWarn(caps);
212
+ let capWarnSeg = null;
213
+ if (capWarn) {
214
+ const pct = Math.round(capWarn.usedPct);
215
+ if (isIcon && verbose) {
216
+ capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} cap ${pct}%${c(RESET)}`;
217
+ } else if (isIcon) {
218
+ capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} ${pct}%${c(RESET)}`;
219
+ } else if (verbose) {
220
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${c(RESET)}`;
221
+ } else {
222
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} ${pct}%${c(RESET)}`;
223
+ }
224
+ }
225
+
226
+ // Warning chip leads โ€” a glance at the statusline catches "something's wrong"
227
+ // before parsing any numbers. Healthy states have no chip and look unchanged.
228
+ // Cap-warn outranks spike: an imminent rate-limit block is more urgent than
229
+ // a single spiking session.
230
+ const allow = segments && segments.length
231
+ ? new Set(segments.map((s) => s.toLowerCase()))
232
+ : null;
233
+ const want = (name) => !allow || allow.has(name);
234
+ const segs = [];
235
+ if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
236
+ if (spikeSeg && want('spike')) segs.push(spikeSeg);
237
+ if (want('hit')) segs.push(hitSeg);
238
+ if (want('ttl')) segs.push(ttlSeg);
239
+ if (want('saved')) segs.push(saveSeg);
240
+ if (ctxSeg && want('ctx')) segs.push(ctxSeg);
241
+ if (want('period')) segs.push(periodSeg);
163
242
  return segs.join(' ยท ');
164
243
  }
@@ -64,11 +64,11 @@ function formatContextSize(n) {
64
64
 
65
65
  function renderSpikeSection(spikes, contextWindow) {
66
66
  const lines = [];
67
- lines.push(' โš  ํ† ํฐ ๊ธ‰์ฆ ๊ฐ์ง€');
67
+ lines.push(' โš  Token spike detected');
68
68
  lines.push(` ${'โ”€'.repeat(50)}`);
69
69
  if (contextWindow && contextWindow.size === '1M') {
70
70
  lines.push(
71
- ` ์ปจํ…์ŠคํŠธ ๋ชจ๋“œ ์ถ”์ •: 1M (์ตœ๊ทผ ๋‹จ์ผ ์š”์ฒญ ์ตœ๋Œ€ ${formatContextSize(contextWindow.maxContext)} ํ† ํฐ)`,
71
+ ` Context mode: 1M (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`,
72
72
  );
73
73
  lines.push('');
74
74
  }
@@ -77,11 +77,11 @@ function renderSpikeSection(spikes, contextWindow) {
77
77
  const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}ร— p95` : 'single-request > 250k';
78
78
  lines.push(
79
79
  ` โ€ข ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] ` +
80
- `์ด ์ž…๋ ฅ ${formatContextSize(m.totalInput)} (${ratioLabel}, ์š”์ฒญ ${m.requestCount}ํšŒ)`,
80
+ `total input ${formatContextSize(m.totalInput)} (${ratioLabel}, ${m.requestCount} requests)`,
81
81
  );
82
82
  if (m.maxContextPerRequest > 0) {
83
83
  lines.push(
84
- ` ๋‹จ์ผ ์š”์ฒญ ์ตœ๋Œ€ ์ปจํ…์ŠคํŠธ: ${formatContextSize(m.maxContextPerRequest)} ํ† ํฐ`,
84
+ ` max single-request context: ${formatContextSize(m.maxContextPerRequest)} tokens`,
85
85
  );
86
86
  }
87
87
  for (const issue of spike.issues) {
@@ -104,7 +104,7 @@ function renderSpikeSection(spikes, contextWindow) {
104
104
  }
105
105
  }
106
106
  if (uniqueIssues.length > 0) {
107
- lines.push(' ๊ถŒ์žฅ ์•ก์…˜');
107
+ lines.push(' Recommended actions');
108
108
  lines.push(` ${'โ”€'.repeat(50)}`);
109
109
  for (const issue of uniqueIssues) {
110
110
  const info = ISSUE_MESSAGES[issue.code];
@@ -123,17 +123,57 @@ function renderSpikeSection(spikes, contextWindow) {
123
123
  return lines;
124
124
  }
125
125
 
126
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow }) {
126
+ function formatResetIn(resetsAt, now = new Date()) {
127
+ if (!Number.isFinite(resetsAt)) return null;
128
+ const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
129
+ if (remaining <= 0) return '0m';
130
+ const h = Math.floor(remaining / 3600);
131
+ const m = Math.floor((remaining % 3600) / 60);
132
+ if (h > 0) return `${h}h ${m}m`;
133
+ return `${m}m`;
134
+ }
135
+
136
+ function renderCapWarnSection(caps) {
137
+ const warning = [];
138
+ if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
139
+ warning.push({ label: '5-hour window', info: caps.fiveHour });
140
+ }
141
+ if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
142
+ warning.push({ label: '7-day window', info: caps.sevenDay });
143
+ }
144
+ if (warning.length === 0) return [];
145
+ const lines = [];
146
+ lines.push(' ๐Ÿšจ Rate-limit cap is closing in');
147
+ lines.push(` ${'โ”€'.repeat(50)}`);
148
+ for (const { label, info } of warning) {
149
+ const reset = formatResetIn(info.resetsAt);
150
+ const tail = reset ? `, resets in ${reset}` : '';
151
+ lines.push(` โ€ข ${label}: ${Math.round(info.usedPct)}% used${tail}`);
152
+ }
153
+ lines.push('');
154
+ lines.push(' Back up work before the cap hits:');
155
+ lines.push(' claude-token-saver handoff');
156
+ lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
157
+ lines.push('');
158
+ return lines;
159
+ }
160
+
161
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
127
162
  const lines = [];
128
163
 
129
164
  // Header
130
165
  lines.push('');
131
- lines.push(` Claude ํ† ํฐ ์•„๊ปด์“ฐ๊ธฐ โ€” Last ${options.days} days`);
166
+ lines.push(` Claude Token Saver โ€” Last ${options.days} day${options.days === 1 ? '' : 's'}`);
132
167
  lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
133
168
  lines.push(` ${'โ•'.repeat(50)}`);
134
169
  lines.push('');
135
170
 
136
- // Spike section goes FIRST โ€” it's what the user acts on.
171
+ // Cap warning leads โ€” it's the most time-sensitive signal we can show.
172
+ if (caps) {
173
+ lines.push(...renderCapWarnSection(caps));
174
+ }
175
+
176
+ // Spike section goes next โ€” it's what the user acts on.
137
177
  if (spikeReport && spikeReport.spikes.length > 0) {
138
178
  lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
139
179
  }
@@ -142,10 +182,10 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
142
182
  if (contextWindow && contextWindow.size !== 'unknown') {
143
183
  const note =
144
184
  contextWindow.size === '1M'
145
- ? 'โš  1M ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ ์ค‘ (Opus 4.7+ Max ๊ธฐ๋ณธ๊ฐ’). ํ•„์š” ์—†์œผ๋ฉด CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
146
- : 'โœ“ 200k ์ปจํ…์ŠคํŠธ (ํ‘œ์ค€)';
185
+ ? 'โš  1M context active (Opus 4.7+ Max default). Disable with CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
186
+ : 'โœ“ 200k context (standard)';
147
187
  lines.push(` Context window: ${contextWindow.size} ${note}`);
148
- lines.push(` (์ตœ๊ทผ ๋‹จ์ผ ์š”์ฒญ ์ตœ๋Œ€ ${formatContextSize(contextWindow.maxContext)} ํ† ํฐ)`);
188
+ lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
149
189
  lines.push('');
150
190
  }
151
191
 
package/src/handoff.js ADDED
@@ -0,0 +1,162 @@
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
+ function pad(n) {
20
+ return String(n).padStart(2, '0');
21
+ }
22
+
23
+ function ymd(d = new Date()) {
24
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
25
+ }
26
+
27
+ function hhmm(d = new Date()) {
28
+ return `${pad(d.getHours())}${pad(d.getMinutes())}`;
29
+ }
30
+
31
+ function safeGit(cmd, cwd) {
32
+ try {
33
+ return execSync(`git ${cmd}`, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ function gitSnapshot(cwd) {
44
+ // `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
45
+ // with no commits yet (where `rev-parse HEAD` would fail). We use it as the
46
+ // "is this a repo?" probe.
47
+ const gitDir = safeGit('rev-parse --git-dir', cwd);
48
+ if (!gitDir) return null;
49
+ const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
50
+ const head = safeGit('rev-parse --short HEAD', cwd);
51
+ const status = safeGit('status --short', cwd);
52
+ return { branch, head, status };
53
+ }
54
+
55
+ function formatResetIn(resetsAt, now = new Date()) {
56
+ if (!Number.isFinite(resetsAt)) return null;
57
+ const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
58
+ if (remaining <= 0) return '0m';
59
+ const h = Math.floor(remaining / 3600);
60
+ const m = Math.floor((remaining % 3600) / 60);
61
+ if (h > 0) return `${h}h ${m}m`;
62
+ return `${m}m`;
63
+ }
64
+
65
+ function pickPath(cwd, now) {
66
+ const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
67
+ const direct = join(cwd, `${stem}.md`);
68
+ if (!existsSync(direct)) return direct;
69
+ for (let i = 2; i < 100; i++) {
70
+ const candidate = join(cwd, `${stem}-${i}.md`);
71
+ if (!existsSync(candidate)) return candidate;
72
+ }
73
+ return join(cwd, `${stem}-${Date.now()}.md`);
74
+ }
75
+
76
+ function renderTemplate({ now, cwd, git, caps }) {
77
+ const lines = [];
78
+ lines.push(`# Handoff โ€” ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
79
+ lines.push('');
80
+ lines.push(`Generated by \`claude-token-saver handoff\`.`);
81
+ lines.push('');
82
+ lines.push('## Context');
83
+ lines.push('');
84
+ lines.push(`- cwd: \`${cwd}\``);
85
+ if (git) {
86
+ lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
87
+ if (git.status) {
88
+ lines.push('- dirty files:');
89
+ lines.push(' ```');
90
+ for (const line of git.status.split('\n')) lines.push(` ${line}`);
91
+ lines.push(' ```');
92
+ } else {
93
+ lines.push('- working tree: clean');
94
+ }
95
+ } else {
96
+ lines.push('- git: (not a repo)');
97
+ }
98
+ lines.push('');
99
+
100
+ lines.push('## Cap snapshot');
101
+ lines.push('');
102
+ if (caps) {
103
+ const fmtRow = (label, info) => {
104
+ if (!info) return `- ${label}: (unknown โ€” stdin had no rate-limit info)`;
105
+ const reset = formatResetIn(info.resetsAt, now);
106
+ const tail = reset ? `, resets in ${reset}` : '';
107
+ return `- ${label}: ${Math.round(info.usedPct)}%${tail}`;
108
+ };
109
+ lines.push(fmtRow('5-hour window', caps.fiveHour));
110
+ lines.push(fmtRow('7-day window', caps.sevenDay));
111
+ } else {
112
+ lines.push('- (no cap data โ€” run `handoff` from a Claude Code session for live numbers)');
113
+ }
114
+ lines.push('');
115
+
116
+ lines.push('## What I just did');
117
+ lines.push('');
118
+ lines.push('- _(fill in: 1โ€“3 bullets describing the most recent work)_');
119
+ lines.push('');
120
+
121
+ lines.push('## What\'s left (TODO)');
122
+ lines.push('');
123
+ lines.push('- [ ] _(fill in)_');
124
+ lines.push('');
125
+
126
+ lines.push('## Where to pick up next');
127
+ lines.push('');
128
+ lines.push('- _(file paths, function names, the exact next step)_');
129
+ lines.push('');
130
+
131
+ lines.push('## Watch out for');
132
+ lines.push('');
133
+ lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
134
+ lines.push('');
135
+
136
+ lines.push('## Resume prompt for the next Claude Code session');
137
+ lines.push('');
138
+ lines.push('```');
139
+ lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
140
+ lines.push('```');
141
+ lines.push('');
142
+
143
+ return lines.join('\n') + '\n';
144
+ }
145
+
146
+ /**
147
+ * Write a handoff file in the given cwd.
148
+ *
149
+ * @param {object} [opts]
150
+ * @param {string} [opts.cwd=process.cwd()]
151
+ * @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
152
+ * @param {Date} [opts.now=new Date()]
153
+ * @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
154
+ */
155
+ export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
156
+ const absCwd = resolve(cwd);
157
+ const git = gitSnapshot(absCwd);
158
+ const path = pickPath(absCwd, now);
159
+ const body = renderTemplate({ now, cwd: absCwd, git, caps });
160
+ writeFileSync(path, body);
161
+ return { path, git };
162
+ }