sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal table formatter — zero dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { ISSUE_MESSAGES } from '../advice.js';
|
|
5
|
+
import { formatResetIn, formatResetClock } from '../format-time.js';
|
|
6
|
+
import { labelForKey } from '../window-labels.js';
|
|
7
|
+
|
|
8
|
+
const C = {
|
|
9
|
+
reset: '\x1b[0m',
|
|
10
|
+
bold: '\x1b[1m',
|
|
11
|
+
blink: '\x1b[5m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
cyan: '\x1b[36m',
|
|
15
|
+
};
|
|
16
|
+
// Wrap text with ANSI codes — gracefully strips to plain when NO_COLOR set.
|
|
17
|
+
const colorOk = !process.env.NO_COLOR;
|
|
18
|
+
const r = (s) => colorOk ? `${C.red}${s}${C.reset}` : s;
|
|
19
|
+
const rb = (s) => colorOk ? `${C.bold}${C.red}${s}${C.reset}` : s;
|
|
20
|
+
const rbl = (s) => colorOk ? `${C.blink}${C.bold}${C.red}${s}${C.reset}` : s;
|
|
21
|
+
const y = (s) => colorOk ? `${C.yellow}${s}${C.reset}` : s;
|
|
22
|
+
const b = (s) => colorOk ? `${C.bold}${s}${C.reset}` : s;
|
|
23
|
+
|
|
24
|
+
function pad(str, len, align = 'left') {
|
|
25
|
+
const s = String(str);
|
|
26
|
+
if (align === 'right') return s.padStart(len);
|
|
27
|
+
return s.padEnd(len);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pct(n) {
|
|
31
|
+
return (n * 100).toFixed(1) + '%';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function millions(n) {
|
|
35
|
+
return (n / 1_000_000).toFixed(2) + 'M';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function thousands(n) {
|
|
39
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
40
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
41
|
+
return String(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hr(len) {
|
|
45
|
+
return '─'.repeat(len);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tableRow(cols, widths, aligns) {
|
|
49
|
+
return (
|
|
50
|
+
'│ ' +
|
|
51
|
+
cols.map((c, i) => pad(c, widths[i], aligns[i])).join(' │ ') +
|
|
52
|
+
' │'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tableSep(widths) {
|
|
57
|
+
return '├─' + widths.map((w) => hr(w)).join('─┼─') + '─┤';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tableTop(widths) {
|
|
61
|
+
return '┌─' + widths.map((w) => hr(w)).join('─┬─') + '─┐';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tableBot(widths) {
|
|
65
|
+
return '└─' + widths.map((w) => hr(w)).join('─┴─') + '─┘';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Format the full report for terminal output.
|
|
70
|
+
*/
|
|
71
|
+
function shortSessionId(id) {
|
|
72
|
+
if (!id) return '(unknown)';
|
|
73
|
+
return id.length > 8 ? id.slice(0, 8) : id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatContextSize(n) {
|
|
77
|
+
if (!n) return '0';
|
|
78
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
|
|
79
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'k';
|
|
80
|
+
return String(n);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderSpikeSection(spikes, contextWindow) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
lines.push(rbl(' ⚠ Token spike detected'));
|
|
86
|
+
lines.push(r(` ${'─'.repeat(50)}`));
|
|
87
|
+
if (contextWindow && contextWindow.overWarn) {
|
|
88
|
+
lines.push(
|
|
89
|
+
r(` Context usage exceeded 500k (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`),
|
|
90
|
+
);
|
|
91
|
+
lines.push('');
|
|
92
|
+
}
|
|
93
|
+
for (const spike of spikes) {
|
|
94
|
+
const m = spike.metrics;
|
|
95
|
+
const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
96
|
+
lines.push(
|
|
97
|
+
r(` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] `) +
|
|
98
|
+
rb(`total input ${formatContextSize(m.totalInput)}`) +
|
|
99
|
+
r(` (${ratioLabel}, ${m.requestCount} requests)`),
|
|
100
|
+
);
|
|
101
|
+
if (m.maxContextPerRequest > 0) {
|
|
102
|
+
lines.push(
|
|
103
|
+
r(` max single-request context: ${formatContextSize(m.maxContextPerRequest)} tokens`),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
for (const issue of spike.issues) {
|
|
107
|
+
const info = ISSUE_MESSAGES[issue.code];
|
|
108
|
+
if (!info) continue;
|
|
109
|
+
lines.push(r(` · ${info.title}`));
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// De-dupe action blocks — if multiple spikes share the same issue, show
|
|
115
|
+
// the remediation once per run, not once per spike.
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const uniqueIssues = [];
|
|
118
|
+
for (const spike of spikes) {
|
|
119
|
+
for (const issue of spike.issues) {
|
|
120
|
+
if (seen.has(issue.code)) continue;
|
|
121
|
+
seen.add(issue.code);
|
|
122
|
+
uniqueIssues.push(issue);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (uniqueIssues.length > 0) {
|
|
126
|
+
lines.push(rb(' Recommended actions'));
|
|
127
|
+
lines.push(r(` ${'─'.repeat(50)}`));
|
|
128
|
+
for (const issue of uniqueIssues) {
|
|
129
|
+
const info = ISSUE_MESSAGES[issue.code];
|
|
130
|
+
if (!info) continue;
|
|
131
|
+
lines.push(rb(` ▸ ${info.title}`));
|
|
132
|
+
lines.push(r(` ${info.explain}`));
|
|
133
|
+
for (const action of info.actions()) {
|
|
134
|
+
lines.push(r(` - ${action.label}`));
|
|
135
|
+
for (const cmd of action.commands) {
|
|
136
|
+
lines.push(r(` ${cmd}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.push(rbl(' ▶ 상세 처치법: ') + rb('claude-token-saver last'));
|
|
143
|
+
lines.push('');
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderCapWarnSection(caps) {
|
|
148
|
+
if (!caps || !Array.isArray(caps.windows)) return [];
|
|
149
|
+
const warning = caps.windows.filter(
|
|
150
|
+
(w) => Number.isFinite(w.usedPct) && w.usedPct >= 90,
|
|
151
|
+
);
|
|
152
|
+
if (warning.length === 0) return [];
|
|
153
|
+
const lines = [];
|
|
154
|
+
lines.push(rbl(' 🚨 Rate-limit cap is closing in'));
|
|
155
|
+
lines.push(r(` ${'─'.repeat(50)}`));
|
|
156
|
+
for (const win of warning) {
|
|
157
|
+
const label = labelForKey(win.key).long;
|
|
158
|
+
const reset = formatResetIn(win.resetsAt);
|
|
159
|
+
const clock = formatResetClock(win.resetsAt);
|
|
160
|
+
let tail = '';
|
|
161
|
+
if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
|
|
162
|
+
else if (reset) tail = `, resets in ${reset}`;
|
|
163
|
+
else if (clock) tail = `, resets at ${clock}`;
|
|
164
|
+
lines.push(rb(` • ${label}: ${Math.round(win.usedPct)}% used${tail}`));
|
|
165
|
+
}
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(r(' Back up work before the cap hits:'));
|
|
168
|
+
lines.push(rb(' claude-token-saver handoff'));
|
|
169
|
+
lines.push(r(' (writes a HANDOFF-*.md so a fresh session can pick up.)'));
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(rbl(' ▶ 상세 처치법: ') + rb('claude-token-saver last'));
|
|
172
|
+
lines.push('');
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
|
|
179
|
+
// Header
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
|
|
182
|
+
lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
|
|
183
|
+
lines.push(` ${'═'.repeat(50)}`);
|
|
184
|
+
lines.push('');
|
|
185
|
+
|
|
186
|
+
// Cap warning leads — it's the most time-sensitive signal we can show.
|
|
187
|
+
if (caps) {
|
|
188
|
+
lines.push(...renderCapWarnSection(caps));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Spike section goes next — it's what the user acts on.
|
|
192
|
+
if (spikeReport && spikeReport.spikes.length > 0) {
|
|
193
|
+
lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Context window chip for the normal case too
|
|
197
|
+
if (contextWindow && contextWindow.size !== 'unknown') {
|
|
198
|
+
const note =
|
|
199
|
+
contextWindow.overWarn
|
|
200
|
+
? '⚠ Context exceeded 500k in recent requests — big contexts re-bill every turn and drain the 5H/7D caps. Use /compact or /clear.'
|
|
201
|
+
: contextWindow.size === '1M'
|
|
202
|
+
? '✓ 1M window in use, under the 500k warn line'
|
|
203
|
+
: '✓ 200k context (standard)';
|
|
204
|
+
lines.push(` Context window: ${contextWindow.size} ${note}`);
|
|
205
|
+
lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
|
|
206
|
+
lines.push('');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Overall summary
|
|
210
|
+
lines.push(' Summary');
|
|
211
|
+
lines.push(` Sessions: ${sum.sessions} | API calls: ${sum.apiCalls.toLocaleString()} | Model: ${cost.tier}`);
|
|
212
|
+
lines.push(` Cache hit rate: ${pct(sum.hitRate)} | Total input: ${millions(sum.totalInput)} tokens`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
|
|
215
|
+
// TTL breakdown
|
|
216
|
+
lines.push(' TTL Breakdown');
|
|
217
|
+
const ttlW = [18, 16, 16];
|
|
218
|
+
const ttlA = ['left', 'right', 'right'];
|
|
219
|
+
lines.push(' ' + tableTop(ttlW));
|
|
220
|
+
lines.push(' ' + tableRow(['', '5m Ephemeral', '1h Extended'], ttlW, ttlA));
|
|
221
|
+
lines.push(' ' + tableSep(ttlW));
|
|
222
|
+
lines.push(
|
|
223
|
+
' ' +
|
|
224
|
+
tableRow(
|
|
225
|
+
[
|
|
226
|
+
'Cache writes',
|
|
227
|
+
`${thousands(ttl.ephemeral5m)} (${pct(ttl.pct5m)})`,
|
|
228
|
+
`${thousands(ttl.ephemeral1h)} (${pct(ttl.pct1h)})`,
|
|
229
|
+
],
|
|
230
|
+
ttlW,
|
|
231
|
+
ttlA,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
lines.push(' ' + tableBot(ttlW));
|
|
235
|
+
lines.push('');
|
|
236
|
+
|
|
237
|
+
// Cost impact
|
|
238
|
+
lines.push(' Cost Impact (estimated)');
|
|
239
|
+
const costW = [24, 12];
|
|
240
|
+
const costA = ['left', 'right'];
|
|
241
|
+
lines.push(' ' + tableTop(costW));
|
|
242
|
+
lines.push(' ' + tableRow(['Actual cost', `$${cost.actual}`], costW, costA));
|
|
243
|
+
lines.push(' ' + tableRow(['Without cache', `$${cost.noCacheCost}`], costW, costA));
|
|
244
|
+
lines.push(' ' + tableSep(costW));
|
|
245
|
+
lines.push(' ' + tableRow(['Savings', `$${cost.savings} (${pct(cost.savingsRate)})`], costW, costA));
|
|
246
|
+
// Only worth asking of someone who has 1h writes to lose. Everyone else got
|
|
247
|
+
// a `+$0` that read as an endorsement of the 5m bucket they were already
|
|
248
|
+
// stuck in.
|
|
249
|
+
if (cost.extraCostIf5mApplicable === false) {
|
|
250
|
+
lines.push(' ' + tableRow(['Already 5m-only', ttl.gatewayObserved ? 'gateway' : 'yes'], costW, costA));
|
|
251
|
+
} else {
|
|
252
|
+
lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
|
|
253
|
+
}
|
|
254
|
+
lines.push(' ' + tableBot(costW));
|
|
255
|
+
lines.push('');
|
|
256
|
+
|
|
257
|
+
// Daily trend
|
|
258
|
+
lines.push(' Daily Trend');
|
|
259
|
+
const tw = [10, 8, 7, 10, 10, 5];
|
|
260
|
+
const ta = ['left', 'right', 'right', 'right', 'right', 'right'];
|
|
261
|
+
lines.push(' ' + tableTop(tw));
|
|
262
|
+
lines.push(' ' + tableRow(['Date', 'HitRate', 'Calls', 'Read', 'Write', '5m%'], tw, ta));
|
|
263
|
+
lines.push(' ' + tableSep(tw));
|
|
264
|
+
|
|
265
|
+
const recentTrend = trend.slice(-14); // last 14 days
|
|
266
|
+
for (const d of recentTrend) {
|
|
267
|
+
const ccTotal = d.ephemeral5m + d.ephemeral1h;
|
|
268
|
+
const pct5m = ccTotal > 0 ? pct(d.ephemeral5m / ccTotal) : '-';
|
|
269
|
+
lines.push(
|
|
270
|
+
' ' +
|
|
271
|
+
tableRow(
|
|
272
|
+
[d.date, pct(d.hitRate), String(d.apiCalls), millions(d.cacheRead), millions(d.cacheCreation), pct5m],
|
|
273
|
+
tw,
|
|
274
|
+
ta,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
lines.push(' ' + tableBot(tw));
|
|
279
|
+
|
|
280
|
+
if (trend.length > 14) {
|
|
281
|
+
lines.push(` ... ${trend.length - 14} earlier days omitted (use --format json for full data)`);
|
|
282
|
+
}
|
|
283
|
+
lines.push('');
|
|
284
|
+
|
|
285
|
+
// Anomalies
|
|
286
|
+
if (anomalies.length > 0) {
|
|
287
|
+
lines.push(' ⚠ Anomalies Detected');
|
|
288
|
+
for (const a of anomalies) {
|
|
289
|
+
lines.push(
|
|
290
|
+
` ${a.date}: hit rate ${pct(a.hitRate)} (7-day avg: ${pct(a.avgHitRate)}, drop: -${pct(a.drop)}) [${a.apiCalls} calls]`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
lines.push(' ✓ No anomalies detected');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push('');
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|
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
|
+
}
|