claude-rpc 1.0.3 → 1.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/README.md +2 -0
- package/package.json +1 -1
- package/src/cli.js +30 -0
- package/src/hook.js +4 -87
- package/src/mcp.js +25 -4
- package/src/recap.js +205 -0
- package/src/scanner.js +42 -2
- package/src/ships.js +91 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -133,6 +133,7 @@ claude-rpc today (today's stats, focused)
|
|
|
133
133
|
claude-rpc week (weekday breakdown)
|
|
134
134
|
claude-rpc preview (every rotation frame rendered with real data)
|
|
135
135
|
claude-rpc insights (3–5 auto-generated lines: trend, peak, hotspot)
|
|
136
|
+
claude-rpc recap --md (standup-ready: yesterday's projects, ships, churn)
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
The web dashboard pushes updates via SSE; the TUI refreshes on a 3-second tick.
|
|
@@ -309,6 +310,7 @@ It's a thin bootstrapper — on the first session it just runs `npx claude-rpc@l
|
|
|
309
310
|
| `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
|
|
310
311
|
| `backfill <dir>` | Import transcripts from any folder (backup, other machine) |
|
|
311
312
|
| `insights` | Print 3–5 auto-generated lines about your week |
|
|
313
|
+
| `recap` | Standup summary — yesterday's work, paste-ready (`today\|week\|date`, `--md`) |
|
|
312
314
|
| `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
|
|
313
315
|
| `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
|
|
314
316
|
| `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -858,6 +858,34 @@ function showInsights() {
|
|
|
858
858
|
console.log('');
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
+
// `claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]` — the
|
|
862
|
+
// standup answer. Defaults to yesterday (with Monday-covers-Friday fallback,
|
|
863
|
+
// see recap.js). --md prints paste-ready markdown; --json the raw numbers.
|
|
864
|
+
async function doRecap(argv) {
|
|
865
|
+
const { buildRecap, renderRecapMarkdown, renderRecapLines, recapTitle } = await import('./recap.js');
|
|
866
|
+
let spec = 'yesterday', md = false, json = false;
|
|
867
|
+
for (const a of argv) {
|
|
868
|
+
if (a === '--md' || a === '--markdown') md = true;
|
|
869
|
+
else if (a === '--json') json = true;
|
|
870
|
+
else if (!a.startsWith('-')) spec = a;
|
|
871
|
+
else return fail(`unknown flag ${a}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
872
|
+
}
|
|
873
|
+
const aggregate = readAggregate();
|
|
874
|
+
if (!aggregate) return fail('no stats yet', { hint: 'run `claude-rpc scan` to build your history first', code: EX_BAD_STATE });
|
|
875
|
+
const r = buildRecap(aggregate, spec);
|
|
876
|
+
if (!r) return fail(`unknown range: ${spec}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
877
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
878
|
+
if (md) { console.log(renderRecapMarkdown(r)); return; }
|
|
879
|
+
console.log('');
|
|
880
|
+
console.log(` ${c.bold}${c.magenta}◆ Recap${c.reset} ${c.dim}— ${recapTitle(r)}${c.reset}`);
|
|
881
|
+
console.log('');
|
|
882
|
+
box('recap', renderRecapLines(r, c));
|
|
883
|
+
console.log('');
|
|
884
|
+
if (process.stdout.isTTY && !r.empty) {
|
|
885
|
+
console.log(` ${c.dim}↗ standup-ready markdown: claude-rpc recap${spec === 'yesterday' ? '' : ` ${spec}`} --md${c.reset}\n`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
861
889
|
function parseBadgeArgs(argv) {
|
|
862
890
|
const out = { metric: 'hours', range: '7d', out: '', gist: false };
|
|
863
891
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -2005,6 +2033,7 @@ function help() {
|
|
|
2005
2033
|
['rescan', 'Force re-parse every transcript (ignores cache)'],
|
|
2006
2034
|
['backfill', 'Import transcripts from any folder (e.g. a backup)'],
|
|
2007
2035
|
['insights', 'Auto-generated insights from your history'],
|
|
2036
|
+
['recap', 'Standup summary (yesterday by default; today|week|date, --md for markdown)'],
|
|
2008
2037
|
['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
|
|
2009
2038
|
['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
|
|
2010
2039
|
['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
|
|
@@ -2161,6 +2190,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2161
2190
|
case 'rescan': doScan(true); break;
|
|
2162
2191
|
case 'backfill': doBackfill(process.argv.slice(3)); break;
|
|
2163
2192
|
case 'insights': showInsights(); break;
|
|
2193
|
+
case 'recap': await doRecap(process.argv.slice(3)); break;
|
|
2164
2194
|
case 'badge': await doBadge(process.argv.slice(3)); break;
|
|
2165
2195
|
case 'card': await doCard(process.argv.slice(3)); break;
|
|
2166
2196
|
case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
|
package/src/hook.js
CHANGED
|
@@ -6,93 +6,10 @@ import { detectLastCommitSubject, detectGitBranch } from './git.js';
|
|
|
6
6
|
import { EVENTS_LOG_PATH } from './paths.js';
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { ensureDaemonRunning } from './ensure-daemon.js';
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Tokenize one command segment the way a shell roughly would for our purposes:
|
|
15
|
-
// strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
|
|
16
|
-
// from the leading binary (/usr/bin/git → git), lowercase it.
|
|
17
|
-
function tokenizeSegment(seg) {
|
|
18
|
-
const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
|
|
19
|
-
let toks = stripped.split(/\s+/).filter(Boolean);
|
|
20
|
-
while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
|
|
21
|
-
if (toks.length) {
|
|
22
|
-
const slash = toks[0].lastIndexOf('/');
|
|
23
|
-
if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
|
|
24
|
-
toks[0] = toks[0].toLowerCase();
|
|
25
|
-
}
|
|
26
|
-
return toks;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// First real git subcommand, skipping global flags and their values
|
|
30
|
-
// (`git -C /repo -c k=v push` → push).
|
|
31
|
-
function gitSubcommand(args) {
|
|
32
|
-
for (let i = 0; i < args.length; i++) {
|
|
33
|
-
const a = args[i];
|
|
34
|
-
if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
|
|
35
|
-
if (a.startsWith('-')) continue;
|
|
36
|
-
return a.toLowerCase();
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// First two gh positionals (noun, verb), skipping global flags so the canonical
|
|
42
|
-
// targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
|
|
43
|
-
// value; other globals here don't precede a create, so skipping the rest is safe.
|
|
44
|
-
function ghSubcommand(args) {
|
|
45
|
-
const pos = [];
|
|
46
|
-
for (let i = 0; i < args.length; i++) {
|
|
47
|
-
const a = args[i];
|
|
48
|
-
if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
|
|
49
|
-
if (a.startsWith('-')) continue;
|
|
50
|
-
pos.push(a.toLowerCase());
|
|
51
|
-
if (pos.length === 2) break;
|
|
52
|
-
}
|
|
53
|
-
return { noun: pos[0], verb: pos[1] };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function shipKindForSegment(seg) {
|
|
57
|
-
const toks = tokenizeSegment(seg);
|
|
58
|
-
if (!toks.length) return null;
|
|
59
|
-
if (toks[0] === 'git') {
|
|
60
|
-
const sub = gitSubcommand(toks.slice(1));
|
|
61
|
-
if (sub === 'push') return 'push';
|
|
62
|
-
if (sub === 'commit') return 'commit';
|
|
63
|
-
} else if (toks[0] === 'gh') {
|
|
64
|
-
const { noun, verb } = ghSubcommand(toks.slice(1));
|
|
65
|
-
if (noun === 'pr' && verb === 'create') return 'pr';
|
|
66
|
-
if (noun === 'issue' && verb === 'create') return 'issue';
|
|
67
|
-
if (noun === 'release' && verb === 'create') return 'tag';
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Return the "shipped" kind for a shell command, or null. Exported for tests.
|
|
73
|
-
// Splits on shell separators and only classifies a segment whose *actual*
|
|
74
|
-
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
75
|
-
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
76
|
-
// sudo/time, chained commands, and git global flags.
|
|
77
|
-
//
|
|
78
|
-
// Quoted spans are blanked BEFORE splitting: separators inside quotes
|
|
79
|
-
// (`echo "run git push && rejoice"`) used to create a fake segment whose
|
|
80
|
-
// leading command was git. The real command's own quoted args (`git commit
|
|
81
|
-
// -m "msg"`) classify the same with or without the message text, so blanking
|
|
82
|
-
// is lossless for detection. An unbalanced quote leaves the string untouched.
|
|
83
|
-
export function classifyShip(cmd) {
|
|
84
|
-
const blanked = String(cmd || '')
|
|
85
|
-
.replace(/'[^']*'/g, ' ')
|
|
86
|
-
.replace(/"(?:\\.|[^"\\])*"/g, ' ');
|
|
87
|
-
const segments = blanked.split(/[;&|\n]+/);
|
|
88
|
-
const found = new Set();
|
|
89
|
-
for (const seg of segments) {
|
|
90
|
-
const k = shipKindForSegment(seg);
|
|
91
|
-
if (k) found.add(k);
|
|
92
|
-
}
|
|
93
|
-
for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
9
|
+
// Ship classification lives in ships.js (shared with the scanner); re-exported
|
|
10
|
+
// here so existing importers/tests keep working.
|
|
11
|
+
import { classifyShip } from './ships.js';
|
|
12
|
+
export { classifyShip };
|
|
96
13
|
|
|
97
14
|
const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
98
15
|
|
package/src/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// without standing up the transport.
|
|
9
9
|
|
|
10
10
|
import { readAggregate, dayKey } from './scanner.js';
|
|
11
|
+
import { buildRecap, renderRecapMarkdown } from './recap.js';
|
|
11
12
|
import { VERSION } from './version.js';
|
|
12
13
|
|
|
13
14
|
function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
|
|
@@ -75,6 +76,25 @@ export const TOOLS = {
|
|
|
75
76
|
return split.map((m) => `${m.model}: $${(m.cost || 0).toFixed(2)} (${Math.round((m.costPct || 0) * 100)}%) · ${fmtN(m.tokens)} tokens · ${m.turns} turns`).join('\n');
|
|
76
77
|
},
|
|
77
78
|
},
|
|
79
|
+
get_recap: {
|
|
80
|
+
description: 'Standup-ready recap of a day or week of Claude Code work: active time, sessions, projects touched, ships (commits/pushes/PRs), code churn, tokens/cost. Answers "what did I work on yesterday/this week?".',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
range: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'today | yesterday | week | YYYY-MM-DD (default yesterday; an inactive yesterday falls back to the most recent active day, so Monday covers Friday)',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
handler(agg, args) {
|
|
92
|
+
const spec = args?.range || 'yesterday';
|
|
93
|
+
const r = buildRecap(agg, spec);
|
|
94
|
+
if (!r) return `Unknown range "${spec}" — use today, yesterday, week, or YYYY-MM-DD.`;
|
|
95
|
+
return renderRecapMarkdown(r);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
78
98
|
};
|
|
79
99
|
|
|
80
100
|
// Build a tools/list payload from the registry.
|
|
@@ -82,7 +102,7 @@ export function toolList() {
|
|
|
82
102
|
return Object.entries(TOOLS).map(([name, t]) => ({
|
|
83
103
|
name,
|
|
84
104
|
description: t.description,
|
|
85
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
105
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {}, additionalProperties: false },
|
|
86
106
|
}));
|
|
87
107
|
}
|
|
88
108
|
|
|
@@ -90,10 +110,11 @@ export function toolList() {
|
|
|
90
110
|
* Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
|
|
91
111
|
* @param {string} name - Tool name (a key of TOOLS).
|
|
92
112
|
* @param {() => object} [getAgg] - Aggregate provider; defaults to readAggregate (injectable for tests).
|
|
113
|
+
* @param {object} [args] - Tool arguments (params.arguments from tools/call).
|
|
93
114
|
* @returns {string} The tool's text result.
|
|
94
115
|
* @throws {Error} If the tool name is unknown.
|
|
95
116
|
*/
|
|
96
|
-
export function callTool(name, getAgg = readAggregate) {
|
|
117
|
+
export function callTool(name, getAgg = readAggregate, args = {}) {
|
|
97
118
|
const t = TOOLS[name];
|
|
98
119
|
if (!t) throw new Error(`unknown tool: ${name}`);
|
|
99
120
|
const agg = getAgg();
|
|
@@ -101,7 +122,7 @@ export function callTool(name, getAgg = readAggregate) {
|
|
|
101
122
|
// a `|| {}` here would render all-zeros, indistinguishable from a genuinely
|
|
102
123
|
// idle day. Surface the same hint every other surface uses instead.
|
|
103
124
|
if (agg == null) return 'No stats yet — run `claude-rpc scan` to build your history.';
|
|
104
|
-
return t.handler(agg);
|
|
125
|
+
return t.handler(agg, args);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
/**
|
|
@@ -144,7 +165,7 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
|
|
|
144
165
|
if (!name) return replyErr(id, -32602, 'missing tool name');
|
|
145
166
|
if (!TOOLS[name]) return replyErr(id, -32602, `unknown tool: ${name}`);
|
|
146
167
|
try {
|
|
147
|
-
const text = callTool(name);
|
|
168
|
+
const text = callTool(name, undefined, params?.arguments || {});
|
|
148
169
|
return reply(id, { content: [{ type: 'text', text }], isError: false });
|
|
149
170
|
} catch (e) {
|
|
150
171
|
return reply(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
package/src/recap.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Standup recap — "what did I do yesterday?" answered from aggregate.json.
|
|
2
|
+
// Pure functions of (aggregate, range spec, now): buildRecap assembles the
|
|
3
|
+
// numbers, renderRecapMarkdown/renderRecapLines format them for pasting into
|
|
4
|
+
// a standup message or printing in the terminal. Consumed by `claude-rpc
|
|
5
|
+
// recap` and the MCP `get_recap` tool.
|
|
6
|
+
//
|
|
7
|
+
// Range semantics:
|
|
8
|
+
// today — the current local day
|
|
9
|
+
// yesterday — the previous local day; if it had no activity (weekend,
|
|
10
|
+
// day off), falls back to the most recent active day before
|
|
11
|
+
// today and says so — Monday standups cover Friday.
|
|
12
|
+
// week — the last 7 local days, ending today
|
|
13
|
+
// YYYY-MM-DD — one explicit local day
|
|
14
|
+
import { dayKey } from './scanner.js';
|
|
15
|
+
|
|
16
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
17
|
+
const SHIP_LABELS = [
|
|
18
|
+
['commit', 'commit', 'commits'],
|
|
19
|
+
['push', 'push', 'pushes'],
|
|
20
|
+
['pr', 'PR', 'PRs'],
|
|
21
|
+
['issue', 'issue', 'issues'],
|
|
22
|
+
['tag', 'release', 'releases'],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function fmtDur(ms) {
|
|
26
|
+
const m = Math.round((ms || 0) / 60_000);
|
|
27
|
+
if (m < 1) return '0m';
|
|
28
|
+
if (m < 60) return `${m}m`;
|
|
29
|
+
return `${(m / 60).toFixed(1)}h`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtN(n) {
|
|
33
|
+
if (!n) return '0';
|
|
34
|
+
if (n < 1000) return String(Math.round(n));
|
|
35
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
|
|
36
|
+
if (n < 1e9) return `${(n / 1e6).toFixed(2)}M`;
|
|
37
|
+
return `${(n / 1e9).toFixed(2)}B`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dayKeyAt(now, offsetDays) {
|
|
41
|
+
const d = new Date(now);
|
|
42
|
+
d.setHours(0, 0, 0, 0);
|
|
43
|
+
d.setDate(d.getDate() - offsetDays);
|
|
44
|
+
return dayKey(d.getTime());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// "Tue 2026-07-01" — manual weekday names so output is locale-stable.
|
|
48
|
+
function prettyDay(key) {
|
|
49
|
+
const [y, m, d] = String(key).split('-').map(Number);
|
|
50
|
+
if (!y || !m || !d) return String(key);
|
|
51
|
+
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]} ${key}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A day "has activity" when real work landed — active time, prompts, tool
|
|
55
|
+
// calls, sessions, or tokens. Notification-only days (the daemon logging a
|
|
56
|
+
// permission ping) don't count.
|
|
57
|
+
function hasActivity(bucket) {
|
|
58
|
+
if (!bucket) return false;
|
|
59
|
+
return (bucket.activeMs || 0) > 0 || (bucket.userMessages || 0) > 0
|
|
60
|
+
|| (bucket.toolCalls || 0) > 0 || (bucket.sessions || 0) > 0
|
|
61
|
+
|| (bucket.inputTokens || 0) + (bucket.outputTokens || 0)
|
|
62
|
+
+ (bucket.cacheReadTokens || 0) + (bucket.cacheWriteTokens || 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve a range spec to { label, days, note }. Exported for tests.
|
|
66
|
+
export function resolveRecapRange(spec, agg, now = Date.now()) {
|
|
67
|
+
const byDay = agg?.byDay || {};
|
|
68
|
+
const s = String(spec || 'yesterday').toLowerCase();
|
|
69
|
+
if (s === 'today') return { label: 'today', days: [dayKeyAt(now, 0)] };
|
|
70
|
+
if (s === 'week') {
|
|
71
|
+
const days = [];
|
|
72
|
+
for (let i = 6; i >= 0; i--) days.push(dayKeyAt(now, i));
|
|
73
|
+
return { label: 'last 7 days', days };
|
|
74
|
+
}
|
|
75
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { label: prettyDay(s), days: [s] };
|
|
76
|
+
if (s !== 'yesterday') return null; // unknown spec — caller reports usage
|
|
77
|
+
const yesterday = dayKeyAt(now, 1);
|
|
78
|
+
if (hasActivity(byDay[yesterday])) return { label: 'yesterday', days: [yesterday] };
|
|
79
|
+
// Walk back for the most recent active day before today (bounded so a
|
|
80
|
+
// fresh install doesn't loop through a year of nothing).
|
|
81
|
+
for (let i = 2; i <= 366; i++) {
|
|
82
|
+
const k = dayKeyAt(now, i);
|
|
83
|
+
if (hasActivity(byDay[k])) {
|
|
84
|
+
return {
|
|
85
|
+
label: 'yesterday',
|
|
86
|
+
days: [k],
|
|
87
|
+
note: `no activity yesterday — showing ${prettyDay(k)}, your most recent active day`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { label: 'yesterday', days: [yesterday] }; // genuinely nothing — renders as empty
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assemble a recap for a range of days.
|
|
96
|
+
* @param {object} agg - aggregate.json contents
|
|
97
|
+
* @param {string} [spec] - today|yesterday|week|YYYY-MM-DD (default yesterday)
|
|
98
|
+
* @param {number} [now] - injectable clock for tests
|
|
99
|
+
* @returns {object|null} recap data, or null for an unknown spec
|
|
100
|
+
*/
|
|
101
|
+
export function buildRecap(agg, spec = 'yesterday', now = Date.now()) {
|
|
102
|
+
const range = resolveRecapRange(spec, agg, now);
|
|
103
|
+
if (!range) return null;
|
|
104
|
+
const byDay = agg?.byDay || {};
|
|
105
|
+
const r = {
|
|
106
|
+
label: range.label,
|
|
107
|
+
days: range.days,
|
|
108
|
+
from: range.days[0],
|
|
109
|
+
to: range.days[range.days.length - 1],
|
|
110
|
+
note: range.note || null,
|
|
111
|
+
activeMs: 0, sessions: 0, prompts: 0, toolCalls: 0,
|
|
112
|
+
tokens: 0, cost: 0, linesAdded: 0, linesRemoved: 0,
|
|
113
|
+
ships: 0, shipKinds: {}, projects: [],
|
|
114
|
+
};
|
|
115
|
+
const projects = {};
|
|
116
|
+
for (const k of range.days) {
|
|
117
|
+
const d = byDay[k];
|
|
118
|
+
if (!d) continue;
|
|
119
|
+
r.activeMs += d.activeMs || 0;
|
|
120
|
+
r.sessions += d.sessions || 0;
|
|
121
|
+
r.prompts += d.userMessages || 0;
|
|
122
|
+
r.toolCalls += d.toolCalls || 0;
|
|
123
|
+
r.tokens += (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
|
|
124
|
+
r.cost += d.cost || 0;
|
|
125
|
+
r.linesAdded += d.linesAdded || 0;
|
|
126
|
+
r.linesRemoved += d.linesRemoved || 0;
|
|
127
|
+
r.ships += d.ships || 0;
|
|
128
|
+
for (const [kind, n] of Object.entries(d.shipKinds || {})) {
|
|
129
|
+
r.shipKinds[kind] = (r.shipKinds[kind] || 0) + n;
|
|
130
|
+
}
|
|
131
|
+
for (const [name, p] of Object.entries(d.projects || {})) {
|
|
132
|
+
const t = projects[name] ||= { name, activeMs: 0, tokens: 0 };
|
|
133
|
+
t.activeMs += p.activeMs || 0;
|
|
134
|
+
t.tokens += p.tokens || 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
r.projects = Object.values(projects)
|
|
138
|
+
.sort((a, b) => (b.activeMs - a.activeMs) || (b.tokens - a.tokens) || a.name.localeCompare(b.name));
|
|
139
|
+
r.empty = !(r.activeMs || r.prompts || r.toolCalls || r.sessions || r.tokens);
|
|
140
|
+
return r;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Human title for a recap: "Tue 2026-07-01 (yesterday)" / "last 7 days (…)". */
|
|
144
|
+
export function recapTitle(r) {
|
|
145
|
+
if (r.days.length > 1) return `${r.label} (${r.from} → ${r.to})`;
|
|
146
|
+
const day = prettyDay(r.from);
|
|
147
|
+
return r.label === 'today' || r.label === 'yesterday' ? `${day} (${r.label})` : day;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shipPhrase(r) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (const [kind, singular, plural] of SHIP_LABELS) {
|
|
153
|
+
const n = r.shipKinds[kind] || 0;
|
|
154
|
+
if (n) parts.push(`${n} ${n === 1 ? singular : plural}`);
|
|
155
|
+
}
|
|
156
|
+
// Counted ships whose kind map got lost (old partial data) still show up.
|
|
157
|
+
if (!parts.length && r.ships) parts.push(`${r.ships}×`);
|
|
158
|
+
return parts.join(' · ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function projectPhrase(r, max = 6) {
|
|
162
|
+
const shown = r.projects.slice(0, max)
|
|
163
|
+
.map((p) => (p.activeMs >= 60_000 ? `${p.name} (${fmtDur(p.activeMs)})` : p.name));
|
|
164
|
+
const extra = r.projects.length - max;
|
|
165
|
+
if (extra > 0) shown.push(`+${extra} more`);
|
|
166
|
+
return shown.join(', ');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Markdown recap — standup-paste-ready. */
|
|
170
|
+
export function renderRecapMarkdown(r) {
|
|
171
|
+
const lines = [`**Claude Code recap — ${recapTitle(r)}**`, ''];
|
|
172
|
+
if (r.empty) {
|
|
173
|
+
lines.push(`_No Claude Code activity ${r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`}._`);
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
lines.push(`- **Active:** ${fmtDur(r.activeMs)} across ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`);
|
|
177
|
+
if (r.projects.length) lines.push(`- **Projects:** ${projectPhrase(r)}`);
|
|
178
|
+
if (r.ships) lines.push(`- **Shipped:** ${shipPhrase(r)}`);
|
|
179
|
+
if (r.linesAdded || r.linesRemoved) lines.push(`- **Code:** +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
|
|
180
|
+
lines.push(`- **Tokens:** ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
|
|
181
|
+
if (r.note) lines.push('', `_(${r.note})_`);
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Terminal recap — array of lines for the CLI's box renderer.
|
|
187
|
+
* @param {object} r - buildRecap result
|
|
188
|
+
* @param {object} [c] - optional ANSI palette ({bold, dim, cyan, green, reset, …}); omit for plain text
|
|
189
|
+
*/
|
|
190
|
+
export function renderRecapLines(r, c = {}) {
|
|
191
|
+
const B = c.bold || '', D = c.dim || '', C = c.cyan || '', G = c.green || '', R = c.reset || '';
|
|
192
|
+
if (r.empty) {
|
|
193
|
+
const where = r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`;
|
|
194
|
+
return [`${D}no Claude Code activity ${where}${R}`];
|
|
195
|
+
}
|
|
196
|
+
const lines = [
|
|
197
|
+
`${D}active${R} ${B}${fmtDur(r.activeMs)}${R} · ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`,
|
|
198
|
+
];
|
|
199
|
+
if (r.projects.length) lines.push(`${D}projects${R} ${C}${projectPhrase(r)}${R}`);
|
|
200
|
+
if (r.ships) lines.push(`${D}shipped${R} ${G}${shipPhrase(r)}${R}`);
|
|
201
|
+
if (r.linesAdded || r.linesRemoved) lines.push(`${D}code${R} +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
|
|
202
|
+
lines.push(`${D}tokens${R} ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
|
|
203
|
+
if (r.note) lines.push(`${D}(${r.note})${R}`);
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
package/src/scanner.js
CHANGED
|
@@ -4,10 +4,12 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
|
|
5
5
|
import { languageOf } from './languages.js';
|
|
6
6
|
import { costFor, pricingKeyFor } from './pricing.js';
|
|
7
|
+
import { classifyShip } from './ships.js';
|
|
7
8
|
|
|
8
9
|
// Bumping this forces a full re-parse on next scan. Increment whenever the
|
|
9
10
|
// per-transcript summary schema changes in a way old caches can't satisfy.
|
|
10
|
-
|
|
11
|
+
// v5: per-day ships/shipKinds + per-day project attribution (recap).
|
|
12
|
+
const CACHE_VERSION = 5;
|
|
11
13
|
|
|
12
14
|
// Cap counted gap between consecutive timestamps. Anything larger is treated
|
|
13
15
|
// as the user walking away — we count only what's plausibly active time.
|
|
@@ -129,8 +131,13 @@ function blankDay() {
|
|
|
129
131
|
linesRemoved: 0,
|
|
130
132
|
cost: 0,
|
|
131
133
|
notifications: 0,
|
|
134
|
+
ships: 0,
|
|
132
135
|
firstTs: null,
|
|
133
136
|
lastTs: null,
|
|
137
|
+
// Day buckets may also lazily carry:
|
|
138
|
+
// shipKinds — { push|commit|pr|issue|tag → count } (only when a ship lands)
|
|
139
|
+
// projects — { name → { activeMs, tokens } } (aggregate-level only,
|
|
140
|
+
// attributed at merge time where the file's project is known)
|
|
134
141
|
};
|
|
135
142
|
}
|
|
136
143
|
|
|
@@ -147,6 +154,10 @@ function mergeDay(target, src) {
|
|
|
147
154
|
target.linesRemoved += src.linesRemoved || 0;
|
|
148
155
|
target.cost += src.cost || 0;
|
|
149
156
|
target.notifications += src.notifications || 0;
|
|
157
|
+
target.ships += src.ships || 0;
|
|
158
|
+
for (const [k, n] of Object.entries(src.shipKinds || {})) {
|
|
159
|
+
(target.shipKinds ||= {})[k] = (target.shipKinds[k] || 0) + n;
|
|
160
|
+
}
|
|
150
161
|
if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
|
|
151
162
|
if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
|
|
152
163
|
}
|
|
@@ -236,6 +247,8 @@ function blankTranscriptSummary() {
|
|
|
236
247
|
bashCommands: {}, // first token → count
|
|
237
248
|
webDomains: {}, // hostname → count
|
|
238
249
|
subagents: {}, // subagent_type → count
|
|
250
|
+
ships: 0, // shipped-command count (git commit/push, gh pr/issue/release create)
|
|
251
|
+
shipKinds: {}, // ship kind → count
|
|
239
252
|
cost: 0, // estimated USD
|
|
240
253
|
costByModel: {}, // pricing key → USD
|
|
241
254
|
modelsUsed: {}, // raw model id → assistant turns
|
|
@@ -385,6 +398,13 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
385
398
|
} else if (b.name === 'Bash') {
|
|
386
399
|
const cmd = firstShellToken(input.command);
|
|
387
400
|
if (cmd) summary.bashCommands[cmd] = (summary.bashCommands[cmd] || 0) + 1;
|
|
401
|
+
const shipKind = classifyShip(input.command);
|
|
402
|
+
if (shipKind) {
|
|
403
|
+
summary.ships = (summary.ships || 0) + 1;
|
|
404
|
+
summary.shipKinds[shipKind] = (summary.shipKinds[shipKind] || 0) + 1;
|
|
405
|
+
for (const bucket of allBuckets) bucket.ships = (bucket.ships || 0) + 1;
|
|
406
|
+
if (dayBucket) (dayBucket.shipKinds ||= {})[shipKind] = (dayBucket.shipKinds[shipKind] || 0) + 1;
|
|
407
|
+
}
|
|
388
408
|
} else if (b.name === 'WebFetch' || b.name === 'WebSearch') {
|
|
389
409
|
const host = b.name === 'WebFetch' ? domainOf(input.url) : '';
|
|
390
410
|
if (host) summary.webDomains[host] = (summary.webDomains[host] || 0) + 1;
|
|
@@ -767,6 +787,8 @@ export function aggregateFrom(cache) {
|
|
|
767
787
|
linesAdded: 0,
|
|
768
788
|
linesRemoved: 0,
|
|
769
789
|
linesNet: 0,
|
|
790
|
+
ships: 0,
|
|
791
|
+
shipKinds: {},
|
|
770
792
|
bashCommands: {},
|
|
771
793
|
webDomains: {},
|
|
772
794
|
subagents: {},
|
|
@@ -800,6 +822,10 @@ export function aggregateFrom(cache) {
|
|
|
800
822
|
// — they represent real work done by Claude.
|
|
801
823
|
agg.linesAdded += summary.linesAdded || 0;
|
|
802
824
|
agg.linesRemoved += summary.linesRemoved || 0;
|
|
825
|
+
agg.ships += summary.ships || 0;
|
|
826
|
+
for (const [k, n] of Object.entries(summary.shipKinds || {})) {
|
|
827
|
+
agg.shipKinds[k] = (agg.shipKinds[k] || 0) + n;
|
|
828
|
+
}
|
|
803
829
|
agg.estimatedCost += summary.cost || 0;
|
|
804
830
|
for (const [m, v] of Object.entries(summary.costByModel || {})) {
|
|
805
831
|
agg.costByModel[m] = (agg.costByModel[m] || 0) + v;
|
|
@@ -848,6 +874,10 @@ export function aggregateFrom(cache) {
|
|
|
848
874
|
target.linesAdded += src.linesAdded || 0;
|
|
849
875
|
target.linesRemoved += src.linesRemoved || 0;
|
|
850
876
|
target.cost += src.cost || 0;
|
|
877
|
+
target.ships += src.ships || 0;
|
|
878
|
+
for (const [kind, n] of Object.entries(src.shipKinds || {})) {
|
|
879
|
+
(target.shipKinds ||= {})[kind] = (target.shipKinds[kind] || 0) + n;
|
|
880
|
+
}
|
|
851
881
|
}
|
|
852
882
|
};
|
|
853
883
|
mergeSubBuckets(summary.byDay, agg.byDay);
|
|
@@ -882,7 +912,17 @@ export function aggregateFrom(cache) {
|
|
|
882
912
|
if (summary.lastTs) agg.lastTs = agg.lastTs ? Math.max(agg.lastTs, summary.lastTs) : summary.lastTs;
|
|
883
913
|
// Full per-day/week/hour merge for top-level sessions.
|
|
884
914
|
for (const [k, day] of Object.entries(summary.byDay || {})) {
|
|
885
|
-
|
|
915
|
+
const target = agg.byDay[k] ||= blankDay();
|
|
916
|
+
mergeDay(target, day);
|
|
917
|
+
// Per-day project attribution — only possible here, where the whole
|
|
918
|
+
// file's project is known. Presence in the map means "touched that
|
|
919
|
+
// day", even if activeMs rounded to 0.
|
|
920
|
+
if (summary.project) {
|
|
921
|
+
const pm = (target.projects ||= {});
|
|
922
|
+
const p = pm[summary.project] ||= { activeMs: 0, tokens: 0 };
|
|
923
|
+
p.activeMs += day.activeMs || 0;
|
|
924
|
+
p.tokens += (day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0);
|
|
925
|
+
}
|
|
886
926
|
}
|
|
887
927
|
for (const [k, w] of Object.entries(summary.byWeek || {})) {
|
|
888
928
|
mergeDay(agg.byWeek[k] ||= blankDay(), w);
|
package/src/ships.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// "Ship" classification — deciding whether a shell command published work to
|
|
2
|
+
// the world (commit, push, PR, issue, release). Shared by the hook (live
|
|
3
|
+
// celebration frame) and the scanner (per-day ship counts in aggregate.json),
|
|
4
|
+
// so both surfaces agree on what counts as shipping.
|
|
5
|
+
|
|
6
|
+
// Precedence when a command ships more than one way (`git commit && git push`
|
|
7
|
+
// → push). Highest first.
|
|
8
|
+
const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
|
|
9
|
+
|
|
10
|
+
// Tokenize one command segment the way a shell roughly would for our purposes:
|
|
11
|
+
// strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
|
|
12
|
+
// from the leading binary (/usr/bin/git → git), lowercase it.
|
|
13
|
+
function tokenizeSegment(seg) {
|
|
14
|
+
const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
|
|
15
|
+
let toks = stripped.split(/\s+/).filter(Boolean);
|
|
16
|
+
while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
|
|
17
|
+
if (toks.length) {
|
|
18
|
+
const slash = toks[0].lastIndexOf('/');
|
|
19
|
+
if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
|
|
20
|
+
toks[0] = toks[0].toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
return toks;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// First real git subcommand, skipping global flags and their values
|
|
26
|
+
// (`git -C /repo -c k=v push` → push).
|
|
27
|
+
function gitSubcommand(args) {
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
|
|
31
|
+
if (a.startsWith('-')) continue;
|
|
32
|
+
return a.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// First two gh positionals (noun, verb), skipping global flags so the canonical
|
|
38
|
+
// targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
|
|
39
|
+
// value; other globals here don't precede a create, so skipping the rest is safe.
|
|
40
|
+
function ghSubcommand(args) {
|
|
41
|
+
const pos = [];
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const a = args[i];
|
|
44
|
+
if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
|
|
45
|
+
if (a.startsWith('-')) continue;
|
|
46
|
+
pos.push(a.toLowerCase());
|
|
47
|
+
if (pos.length === 2) break;
|
|
48
|
+
}
|
|
49
|
+
return { noun: pos[0], verb: pos[1] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shipKindForSegment(seg) {
|
|
53
|
+
const toks = tokenizeSegment(seg);
|
|
54
|
+
if (!toks.length) return null;
|
|
55
|
+
if (toks[0] === 'git') {
|
|
56
|
+
const sub = gitSubcommand(toks.slice(1));
|
|
57
|
+
if (sub === 'push') return 'push';
|
|
58
|
+
if (sub === 'commit') return 'commit';
|
|
59
|
+
} else if (toks[0] === 'gh') {
|
|
60
|
+
const { noun, verb } = ghSubcommand(toks.slice(1));
|
|
61
|
+
if (noun === 'pr' && verb === 'create') return 'pr';
|
|
62
|
+
if (noun === 'issue' && verb === 'create') return 'issue';
|
|
63
|
+
if (noun === 'release' && verb === 'create') return 'tag';
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Return the "shipped" kind for a shell command, or null. Exported for tests.
|
|
69
|
+
// Splits on shell separators and only classifies a segment whose *actual*
|
|
70
|
+
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
71
|
+
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
72
|
+
// sudo/time, chained commands, and git global flags.
|
|
73
|
+
//
|
|
74
|
+
// Quoted spans are blanked BEFORE splitting: separators inside quotes
|
|
75
|
+
// (`echo "run git push && rejoice"`) used to create a fake segment whose
|
|
76
|
+
// leading command was git. The real command's own quoted args (`git commit
|
|
77
|
+
// -m "msg"`) classify the same with or without the message text, so blanking
|
|
78
|
+
// is lossless for detection. An unbalanced quote leaves the string untouched.
|
|
79
|
+
export function classifyShip(cmd) {
|
|
80
|
+
const blanked = String(cmd || '')
|
|
81
|
+
.replace(/'[^']*'/g, ' ')
|
|
82
|
+
.replace(/"(?:\\.|[^"\\])*"/g, ' ');
|
|
83
|
+
const segments = blanked.split(/[;&|\n]+/);
|
|
84
|
+
const found = new Set();
|
|
85
|
+
for (const seg of segments) {
|
|
86
|
+
const k = shipKindForSegment(seg);
|
|
87
|
+
if (k) found.add(k);
|
|
88
|
+
}
|
|
89
|
+
for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
|
|
90
|
+
return null;
|
|
91
|
+
}
|