claude-rpc 1.0.2 → 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
@@ -15,7 +15,7 @@
15
15
 
16
16
  **Discord Rich Presence for [Claude Code](https://claude.com/claude-code)** — your live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires.
17
17
 
18
- **[claude-rpc.vercel.app →](https://claude-rpc.vercel.app)** — see it in one page.
18
+ **[claude-rpc.com →](https://claude-rpc.com)** — see it in one page.
19
19
 
20
20
  [![community · sessions](https://claude-rpc-totals.claude-rpc.workers.dev/sessions.svg)](#community-totals)   [![community · tokens](https://claude-rpc-totals.claude-rpc.workers.dev/tokens.svg)](#community-totals)
21
21
 
@@ -53,7 +53,7 @@ That installs `claude-rpc` globally, wires the hooks into Claude Code, and start
53
53
  **Prefer a one-liner that figures it out for you?**
54
54
 
55
55
  ```sh
56
- curl -fsSL https://claude-rpc.vercel.app/install | sh
56
+ curl -fsSL https://claude-rpc.com/install | sh
57
57
  ```
58
58
 
59
59
  Detects Node (installs the npm package) or falls back to the prebuilt Apple-Silicon binary, then runs `setup` for you.
@@ -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.
@@ -149,9 +150,9 @@ claude-rpc readme --raw | pbcopy # straight to your clipboard
149
150
  **Live card + badges, paste once.** With a public profile (`claude-rpc profile set --handle <you> && claude-rpc profile on`), your stats are served as an always-current card (and badges) from the community worker — no `gh`, no gist, nothing to re-run:
150
151
 
151
152
  ```md
152
- [![Claude Code stats](https://claude-rpc-totals.claude-rpc.workers.dev/card/<you>.svg)](https://claude-rpc.vercel.app/u/<you>)
153
+ [![Claude Code stats](https://claude-rpc-totals.claude-rpc.workers.dev/card/<you>.svg)](https://claude-rpc.com/u/<you>)
153
154
 
154
- [![Claude Code hours](https://claude-rpc-totals.claude-rpc.workers.dev/badge/<you>.svg?metric=hours)](https://claude-rpc.vercel.app/?ref=badge)
155
+ [![Claude Code hours](https://claude-rpc-totals.claude-rpc.workers.dev/badge/<you>.svg?metric=hours)](https://claude-rpc.com/?ref=badge)
155
156
  ```
156
157
 
157
158
  The card shows tokens, sessions, active hours and streak; badge `metric=` is one of `tokens · sessions · hours · streak` (optional `&label=` to retitle). Both refresh themselves as the daemon flushes your profile (~every 30 min). Your profile page at `/u/<you>` has a one-click "copy" for the whole block.
@@ -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,11 +1,11 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.0.2",
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",
7
7
  "author": "Archer Simmons",
8
- "homepage": "https://github.com/rar-file/claude-rpc#readme",
8
+ "homepage": "https://claude-rpc.com",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/rar-file/claude-rpc.git"
package/src/calendar.js CHANGED
@@ -86,7 +86,7 @@ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date()
86
86
  <text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
87
87
  ${legend}
88
88
  <text x="${W - 28}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">more</text>
89
- <text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc.vercel.app · v${VERSION}</text>
89
+ <text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc.com · v${VERSION}</text>
90
90
  </svg>`;
91
91
  }
92
92
 
package/src/card.js CHANGED
@@ -322,7 +322,7 @@ export function renderCard(aggregate, { range = 'year', generatedAt = new Date()
322
322
  <!-- ── credits ── -->
323
323
  <g transform="translate(${W - 60} ${H - 24})" text-anchor="end">
324
324
  <text font-family="JetBrains Mono, ui-monospace, monospace" font-size="10"
325
- fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · claude-rpc.vercel.app</text>
325
+ fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · claude-rpc.com</text>
326
326
  </g>
327
327
  </svg>`;
328
328
  }
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++) {
@@ -1066,7 +1094,7 @@ function doReadme(argv) {
1066
1094
  const raw = argv.includes('--raw') || argv.includes('--plain');
1067
1095
  const cfg = loadConfig();
1068
1096
  const endpoint = (cfg.community?.endpoint || 'https://claude-rpc-totals.claude-rpc.workers.dev').replace(/\/+$/, '');
1069
- const site = 'https://claude-rpc.vercel.app';
1097
+ const site = 'https://claude-rpc.com';
1070
1098
  const profile = cfg.profile || {};
1071
1099
  const handle = profile.handle;
1072
1100
  const live = !!profile.enabled && lb.isValidHandle(handle);
@@ -1287,7 +1315,7 @@ async function doExport(argv) {
1287
1315
  // `claude-rpc squad join <code>`
1288
1316
  // `claude-rpc squad` / `squad status` → standings for every squad you're in
1289
1317
  // `claude-rpc squad leave [id]`
1290
- // The web flow (claude-rpc.vercel.app + GitHub login) drives the same worker
1318
+ // The web flow (claude-rpc.com + GitHub login) drives the same worker
1291
1319
  // endpoints; the CLI authenticates with the community instanceId it already has.
1292
1320
 
1293
1321
  function squadAuth() {
@@ -1332,7 +1360,7 @@ function squadAuth() {
1332
1360
  return { cfg, endpoint, instanceId, post, get };
1333
1361
  }
1334
1362
 
1335
- function squadPageUrl(id) { return `https://claude-rpc.vercel.app/squad/${id}`; }
1363
+ function squadPageUrl(id) { return `https://claude-rpc.com/squad/${id}`; }
1336
1364
 
1337
1365
  function printSquadInvite(squad) {
1338
1366
  console.log('');
@@ -1431,11 +1459,11 @@ async function doSquadCmd(argv) {
1431
1459
  // one-time code, no browser needed
1432
1460
  // claude-rpc link <code> on the NEW machine → claims it, merging this
1433
1461
  // install into the same leaderboard identity
1434
- // The browser fallback lives at claude-rpc.vercel.app/link (log in with
1462
+ // The browser fallback lives at claude-rpc.com/link (log in with
1435
1463
  // GitHub → same code) for when the other machine isn't handy. Claiming
1436
1464
  // verifies the profile (✓) and unlocks managing squads from the browser.
1437
1465
 
1438
- const LINK_PAGE = 'https://claude-rpc.vercel.app/link';
1466
+ const LINK_PAGE = 'https://claude-rpc.com/link';
1439
1467
 
1440
1468
  // Mint side: this machine asks the worker for a code. The worker only obliges
1441
1469
  // when this install's canonical profile is verified — the ✓ a claim grants
@@ -1638,7 +1666,7 @@ function profileNextStep() {
1638
1666
  const p = loadConfig().profile || {};
1639
1667
  const next = profileSteps(p).find((s) => !s.done);
1640
1668
  if (!next) {
1641
- console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}${c.reset}`);
1669
+ console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.com/u/${encodeURIComponent(p.handle)}${c.reset}`);
1642
1670
  } else if (next.key === 'verify') {
1643
1671
  console.log(` ${c.dim}→ next: run${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine that's already verified, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset} ${c.dim}here — first machine? ${LINK_PAGE}${c.reset}`);
1644
1672
  } else {
@@ -1649,7 +1677,7 @@ function profileNextStep() {
1649
1677
  function profileStatus() {
1650
1678
  const p = (loadConfig().profile) || {};
1651
1679
  const handleOk = lb.isValidHandle(p.handle);
1652
- const boardUrl = handleOk ? `https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}` : '';
1680
+ const boardUrl = handleOk ? `https://claude-rpc.com/u/${encodeURIComponent(p.handle)}` : '';
1653
1681
 
1654
1682
  console.log('');
1655
1683
  console.log(` ${c.bold}${c.magenta}◆ profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
@@ -1776,7 +1804,7 @@ async function profilePublish() {
1776
1804
  console.log(` ${c.dim}publishing @${cfg.profile.handle} to the board…${c.reset}`);
1777
1805
  const r = await flushProfile(cfg);
1778
1806
  if (r.ok) {
1779
- console.log(` ${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
1807
+ console.log(` ${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.com/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
1780
1808
  } else if (r.reason === 'rate-limited') {
1781
1809
  console.log(` ${c.yellow}!${c.reset} rate-limited — already published in the last minute; the board has you`);
1782
1810
  } else {
@@ -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;
@@ -241,7 +241,7 @@ export const DEFAULT_CONFIG = {
241
241
  // ?ref=discord lets the landing page attribute installs that originate
242
242
  // from a presence card. (When the cwd is a github repo the daemon also
243
243
  // prepends a "View on GitHub →" button, so both can show.)
244
- { label: "Get claude-rpc →", url: "https://claude-rpc.vercel.app/?ref=discord" },
244
+ { label: "Get claude-rpc →", url: "https://claude-rpc.com/?ref=discord" },
245
245
  ],
246
246
  },
247
247
  statusIcons: {
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/profile.js CHANGED
@@ -135,7 +135,7 @@ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Da
135
135
  <!-- footer -->
136
136
  <text x="40" y="${H - 18}"
137
137
  font-family="JetBrains Mono, ui-monospace, monospace"
138
- font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc.vercel.app`)}</text>
138
+ font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc.com`)}</text>
139
139
  </svg>`;
140
140
  }
141
141
 
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);
@@ -20,7 +20,7 @@
20
20
  // Landing-page CTA used by the finale share. The dashboard is served on
21
21
  // localhost, so sharing `location.href` hands the recipient a dead link —
22
22
  // the finale shares this public URL + a stats summary instead.
23
- const LANDING = 'https://claude-rpc.vercel.app/?ref=wrapped';
23
+ const LANDING = 'https://claude-rpc.com/?ref=wrapped';
24
24
  let _shareText = ''; // built in the finale slide, consumed by wireFinale
25
25
 
26
26
  // ── build the story slides from the wrapped payload ──────────
@@ -131,7 +131,7 @@
131
131
  ${cell('Lines', (d.linesNet >= 0 ? '+' : '−') + fmtNum(Math.abs(d.linesNet)), 'grass')}
132
132
  ${cell('Hotspot', d.hotspot ? esc(d.hotspot.name) : '—')}
133
133
  </div>
134
- <div class="foot">made with claude-rpc · claude-rpc.vercel.app</div>
134
+ <div class="foot">made with claude-rpc · claude-rpc.com</div>
135
135
  </div>
136
136
  <div class="actions">
137
137
  <button class="btn primary" id="w-replay">↺ replay</button>
@@ -60,7 +60,7 @@ export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {})
60
60
  ${statCell(C[1], 184, 'Est. cost', String(cost), PALETTE.blurple)}
61
61
  ${statCell(C[2], 184, 'Reads', String(v.filesRead ?? 0))}
62
62
 
63
- <text x="40" y="${H - 16}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkFaint}">${escapeXml(`${v.currentFilePretty ? 'last: ' + v.currentFilePretty + ' · ' : ''}claude-rpc.vercel.app · v${VERSION}`)}</text>
63
+ <text x="40" y="${H - 16}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkFaint}">${escapeXml(`${v.currentFilePretty ? 'last: ' + v.currentFilePretty + ' · ' : ''}claude-rpc.com · v${VERSION}`)}</text>
64
64
  </svg>`;
65
65
  }
66
66
 
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.2';
14
+ const BAKED = '1.1.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {