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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- // Precedence when a command ships more than one way (`git commit && git push`
11
- // push). Highest first.
12
- const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
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
- const CACHE_VERSION = 4;
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
- mergeDay(agg.byDay[k] ||= blankDay(), day);
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
+ }
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '1.0.3';
14
+ const BAKED = '1.1.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {