@selvakumaresra/specship 0.4.0 → 0.6.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.
Files changed (57) hide show
  1. package/README.md +17 -7
  2. package/commands/ss-brainstorm.md +68 -0
  3. package/dist/analytics/specship-impact.d.ts +72 -0
  4. package/dist/analytics/specship-impact.d.ts.map +1 -0
  5. package/dist/analytics/specship-impact.js +216 -0
  6. package/dist/analytics/specship-impact.js.map +1 -0
  7. package/dist/bin/specship.js +177 -4
  8. package/dist/bin/specship.js.map +1 -1
  9. package/dist/db/migrations.d.ts +1 -1
  10. package/dist/db/migrations.d.ts.map +1 -1
  11. package/dist/db/migrations.js +15 -1
  12. package/dist/db/migrations.js.map +1 -1
  13. package/dist/db/schema.sql +8 -0
  14. package/dist/extraction/specs/markdown-spec-extractor.d.ts +19 -0
  15. package/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
  16. package/dist/extraction/specs/markdown-spec-extractor.js +132 -11
  17. package/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
  18. package/dist/index.d.ts +37 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +64 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/installer/index.d.ts +2 -2
  23. package/dist/installer/index.d.ts.map +1 -1
  24. package/dist/installer/targets/claude.d.ts.map +1 -1
  25. package/dist/installer/targets/claude.js +2 -0
  26. package/dist/installer/targets/claude.js.map +1 -1
  27. package/dist/mcp/spec-tools.d.ts.map +1 -1
  28. package/dist/mcp/spec-tools.js +87 -6
  29. package/dist/mcp/spec-tools.js.map +1 -1
  30. package/dist/resolution/brief-link-resolver.d.ts +114 -0
  31. package/dist/resolution/brief-link-resolver.d.ts.map +1 -0
  32. package/dist/resolution/brief-link-resolver.js +261 -0
  33. package/dist/resolution/brief-link-resolver.js.map +1 -0
  34. package/dist/server/ingest/impact-backfill.js +69 -0
  35. package/dist/server/ingest/impact-query.js +343 -0
  36. package/dist/server/ingest/index.js +2 -1
  37. package/dist/server/ingest/ingestor.js +41 -6
  38. package/dist/server/ingest/specship-classify.js +153 -0
  39. package/dist/server/routes/claude.js +32 -0
  40. package/dist/server/routes/spec.js +103 -0
  41. package/dist/server/server.js +26 -2
  42. package/dist/types.d.ts +1 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +4 -0
  45. package/dist/types.js.map +1 -1
  46. package/dist/web/chunk-JQ534IB6.js +6 -0
  47. package/dist/web/chunk-O7434ZMN.js +1 -0
  48. package/dist/web/chunk-RASJHUXS.js +1 -0
  49. package/dist/web/chunk-TQ3P2QZO.js +1 -0
  50. package/dist/web/chunk-WCHGDXWC.js +1 -0
  51. package/dist/web/index.html +1 -1
  52. package/dist/web/main-QAP4FTDP.js +1 -0
  53. package/package.json +1 -1
  54. package/dist/web/chunk-2YUJNZ2Y.js +0 -6
  55. package/dist/web/chunk-B3YPFY6A.js +0 -1
  56. package/dist/web/chunk-GWPVKJIY.js +0 -1
  57. package/dist/web/main-R53HA54V.js +0 -1
@@ -0,0 +1,343 @@
1
+ /**
2
+ * computeSpecshipImpact — aggregate SpecShip token-impact metrics from
3
+ * claude_tool_calls + claude_sessions.
4
+ *
5
+ * Pure function of (db, opts) — no Fastify dependency, unit-testable.
6
+ * Called by GET /api/claude/specship-impact in routes/claude.ts.
7
+ *
8
+ * Algorithm (SQL fetch + JS reduction — no GROUP BY prompt_id/file SQL):
9
+ * 1. Fetch scoped rows joining claude_tool_calls ↔ claude_sessions.
10
+ * 2. Spend: ceil(Σ result_length / CHARS_PER_TOKEN) for is_specship=1.
11
+ * 3. Saved: per-prompt dedup — union displaced file paths across a prompt's
12
+ * resolved calls, sum each distinct file's size ONCE, subtract the
13
+ * prompt's specship spend chars, floor at 0.
14
+ * 4. Overhead: ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN) ×
15
+ * distinct session count with ≥1 specship call.
16
+ * 5. Cost: price spend/saved chars at input-token rate, grouped by model.
17
+ */
18
+ import { normalizeModelId, resolvePricing } from './pricing.js';
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+ /** Characters treated as one token (rough average for Claude). */
23
+ const CHARS_PER_TOKEN = 4;
24
+ /**
25
+ * JSON.stringify length of the SpecShip MCP tool definitions array.
26
+ * Measured: JSON.stringify(tools.filter(t => t.name.startsWith('specship_'))).length
27
+ * at 2026-06-24 — 12 tools in the registry at that time.
28
+ * See src/mcp/tools.ts; recheck after adding/removing tools.
29
+ */
30
+ const SPECSHIP_TOOLDEF_CHARS = 10047;
31
+ /** Overhead tokens charged per session (tool-definition context cost). */
32
+ const OVERHEAD_TOKENS_PER_SESSION = Math.ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN);
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+ function parseDisplacedFiles(raw) {
37
+ if (!raw)
38
+ return [];
39
+ try {
40
+ const parsed = JSON.parse(raw);
41
+ if (!Array.isArray(parsed))
42
+ return [];
43
+ return parsed;
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ /**
50
+ * Load pricing rows from the DB (returns [] if the table is empty or absent).
51
+ */
52
+ function loadPricing(db) {
53
+ try {
54
+ return db
55
+ .prepare('SELECT model, input_per_mtok, output_per_mtok, cache_creation_per_mtok, cache_read_per_mtok FROM claude_pricing')
56
+ .all();
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ }
62
+ /**
63
+ * Price a char count (already summed) as input tokens at the input rate for
64
+ * the given model. Returns 0 when model is unknown or pricing is unavailable.
65
+ */
66
+ function priceChars(chars, model, pricingRows) {
67
+ if (!model || pricingRows.length === 0)
68
+ return 0;
69
+ const pricing = resolvePricing(model, pricingRows);
70
+ if (!pricing)
71
+ return 0;
72
+ const tokens = chars / CHARS_PER_TOKEN;
73
+ return (tokens * pricing.input_per_mtok) / 1_000_000;
74
+ }
75
+ /**
76
+ * Per-prompt dedup logic. Given the rows for ONE prompt:
77
+ * - union displaced file paths from resolved rows (same file counted once)
78
+ * - promptSavedChars = max(0, unionSize - promptSpendChars)
79
+ *
80
+ * Returns { savedChars, spendChars }.
81
+ */
82
+ function computePromptSaved(rows) {
83
+ const spendChars = rows
84
+ .filter(r => r.is_specship === 1)
85
+ .reduce((s, r) => s + (r.result_length ?? 0), 0);
86
+ // Union displaced files across ALL resolved calls in this prompt.
87
+ const fileMap = new Map(); // path → size
88
+ for (const row of rows) {
89
+ if (row.is_specship !== 1 || row.resolution !== 'resolved')
90
+ continue;
91
+ for (const [path, size] of parseDisplacedFiles(row.displaced_files)) {
92
+ if (!fileMap.has(path)) {
93
+ fileMap.set(path, size);
94
+ }
95
+ }
96
+ }
97
+ const readEquivChars = Array.from(fileMap.values()).reduce((s, n) => s + n, 0);
98
+ const savedChars = Math.max(0, readEquivChars - spendChars);
99
+ return { savedChars, spendChars };
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Main export
103
+ // ---------------------------------------------------------------------------
104
+ export function computeSpecshipImpact(db, opts) {
105
+ const { since, project, sessionId } = opts;
106
+ // 1. Fetch scoped rows.
107
+ const queryParts = [
108
+ `SELECT tc.prompt_id, tc.tool_name, tc.is_specship, tc.result_length,
109
+ tc.displaced_files, tc.resolution, tc.ts,
110
+ s.project_path, s.last_model
111
+ FROM claude_tool_calls tc
112
+ JOIN claude_sessions s ON s.id = tc.session_id
113
+ WHERE tc.ts >= ?`,
114
+ ];
115
+ const params = [since];
116
+ if (project) {
117
+ queryParts.push('AND s.project_path = ?');
118
+ params.push(project);
119
+ }
120
+ if (sessionId) {
121
+ queryParts.push('AND tc.session_id = ?');
122
+ params.push(sessionId);
123
+ }
124
+ const rows = db.prepare(queryParts.join(' ')).all(...params);
125
+ if (rows.length === 0) {
126
+ return {
127
+ spendTokens: 0,
128
+ spendCostUsd: 0,
129
+ savedTokens: 0,
130
+ savedCostUsd: 0,
131
+ overheadTokens: 0,
132
+ netTokens: 0,
133
+ netCostUsd: 0,
134
+ unresolvedCalls: 0,
135
+ totalSpecshipCalls: 0,
136
+ byTool: [],
137
+ byProject: [],
138
+ trend: [],
139
+ };
140
+ }
141
+ const pricingRows = loadPricing(db);
142
+ // ---------------------------------------------------------------------------
143
+ // 2. Group rows by prompt_id for per-prompt dedup.
144
+ // ---------------------------------------------------------------------------
145
+ const byPrompt = new Map();
146
+ for (const row of rows) {
147
+ const key = row.prompt_id ?? `__no_prompt_${row.ts}`;
148
+ const arr = byPrompt.get(key);
149
+ if (arr)
150
+ arr.push(row);
151
+ else
152
+ byPrompt.set(key, [row]);
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // 3. Global spend / saved (with per-prompt dedup) + cost by model.
156
+ // ---------------------------------------------------------------------------
157
+ let totalSpendChars = 0;
158
+ let totalSavedChars = 0;
159
+ let totalSpecshipCalls = 0;
160
+ let unresolvedCalls = 0;
161
+ // For cost we need to track chars by model.
162
+ const spendCharsByModel = new Map();
163
+ const savedCharsByModel = new Map();
164
+ // Per-tool accumulators: spend is exact; saved is a per-tool APPROXIMATION.
165
+ // Exact per-tool saved attribution is ambiguous when two tools in the same
166
+ // prompt share a file. We approximate: a tool's saved chars = its own
167
+ // resolved displaced files (deduped within the tool, not globally within
168
+ // the prompt). This overestimates when tools share files but keeps
169
+ // individual tool savings intuitive. Spend is always exact.
170
+ const toolSpendChars = new Map();
171
+ const toolSavedChars = new Map();
172
+ const toolCalls = new Map();
173
+ // Per-project (only when no project filter).
174
+ const projectSpendChars = new Map();
175
+ const projectSavedChars = new Map();
176
+ // Trend: daily buckets.
177
+ const dayMs = 24 * 60 * 60 * 1000;
178
+ const trendSpend = new Map(); // dayBucket → chars
179
+ const trendSaved = new Map();
180
+ // Pass 1: global spend/saved via per-prompt dedup.
181
+ for (const [, promptRows] of byPrompt) {
182
+ const { savedChars, spendChars } = computePromptSaved(promptRows);
183
+ totalSpendChars += spendChars;
184
+ totalSavedChars += savedChars;
185
+ // Attribute spend/saved chars to the session's model.
186
+ // All rows in a prompt share the same session (same last_model).
187
+ const model = promptRows[0]?.last_model ?? null;
188
+ const normModel = model ? normalizeModelId(model) : null;
189
+ if (normModel) {
190
+ spendCharsByModel.set(normModel, (spendCharsByModel.get(normModel) ?? 0) + spendChars);
191
+ savedCharsByModel.set(normModel, (savedCharsByModel.get(normModel) ?? 0) + savedChars);
192
+ }
193
+ // Track per-day trend for this prompt's is_specship rows.
194
+ for (const row of promptRows) {
195
+ if (row.is_specship !== 1)
196
+ continue;
197
+ totalSpecshipCalls++;
198
+ if (row.resolution === 'unresolved')
199
+ unresolvedCalls++;
200
+ // Session tracking (for overhead).
201
+ // We need the session_id — it's not in our fetch. Derive from project+model
202
+ // won't work. We'll collect it via a separate map after this loop using rows.
203
+ }
204
+ }
205
+ // We need session_ids for overhead. Since we joined sessions but didn't select
206
+ // session_id, do a lightweight distinct-session count via a separate query.
207
+ const sessionCountQParts = [
208
+ `SELECT COUNT(DISTINCT tc.session_id) as cnt
209
+ FROM claude_tool_calls tc
210
+ JOIN claude_sessions s ON s.id = tc.session_id
211
+ WHERE tc.ts >= ? AND tc.is_specship = 1`,
212
+ ];
213
+ const sessionCountParams = [since];
214
+ if (project) {
215
+ sessionCountQParts.push('AND s.project_path = ?');
216
+ sessionCountParams.push(project);
217
+ }
218
+ if (sessionId) {
219
+ sessionCountQParts.push('AND tc.session_id = ?');
220
+ sessionCountParams.push(sessionId);
221
+ }
222
+ const sessionCountRow = db
223
+ .prepare(sessionCountQParts.join(' '))
224
+ .get(...sessionCountParams);
225
+ const specshipSessionCount = sessionCountRow?.cnt ?? 0;
226
+ // ---------------------------------------------------------------------------
227
+ // Pass 2: per-tool and trend (row-level, NOT requiring per-prompt dedup).
228
+ // ---------------------------------------------------------------------------
229
+ // Per-tool saved approximation: for each is_specship row with resolution=resolved,
230
+ // compute its own per-call "saved" as max(0, sum(displaced_file_sizes) - result_length).
231
+ // This is approximate because it ignores cross-call file sharing within a prompt,
232
+ // but it gives an intuitive per-tool estimate and spend is always exact.
233
+ for (const row of rows) {
234
+ const dayBucket = Math.floor(row.ts / dayMs) * dayMs;
235
+ if (row.is_specship !== 1)
236
+ continue;
237
+ const toolName = row.tool_name ?? 'unknown';
238
+ toolCalls.set(toolName, (toolCalls.get(toolName) ?? 0) + 1);
239
+ const spend = row.result_length ?? 0;
240
+ toolSpendChars.set(toolName, (toolSpendChars.get(toolName) ?? 0) + spend);
241
+ // Per-tool saved approximation: own displaced files minus own spend, ≥0.
242
+ const displaced = parseDisplacedFiles(row.displaced_files);
243
+ if (row.resolution === 'resolved' && displaced.length > 0) {
244
+ const fileUnion = new Map();
245
+ for (const [p, sz] of displaced)
246
+ if (!fileUnion.has(p))
247
+ fileUnion.set(p, sz);
248
+ const readEquiv = Array.from(fileUnion.values()).reduce((s, n) => s + n, 0);
249
+ const toolSaved = Math.max(0, readEquiv - spend);
250
+ toolSavedChars.set(toolName, (toolSavedChars.get(toolName) ?? 0) + toolSaved);
251
+ }
252
+ // Trend.
253
+ trendSpend.set(dayBucket, (trendSpend.get(dayBucket) ?? 0) + spend);
254
+ }
255
+ // Per-prompt saved for trend and byProject (both need per-prompt dedup).
256
+ for (const [, promptRows] of byPrompt) {
257
+ const { savedChars } = computePromptSaved(promptRows);
258
+ const project_path = promptRows[0]?.project_path ?? '';
259
+ const dayBucket = Math.floor((promptRows[0]?.ts ?? 0) / dayMs) * dayMs;
260
+ // Trend saved.
261
+ if (savedChars > 0) {
262
+ trendSaved.set(dayBucket, (trendSaved.get(dayBucket) ?? 0) + savedChars);
263
+ }
264
+ // byProject accumulators.
265
+ if (!project) {
266
+ const spendChars = promptRows
267
+ .filter(r => r.is_specship === 1)
268
+ .reduce((s, r) => s + (r.result_length ?? 0), 0);
269
+ projectSpendChars.set(project_path, (projectSpendChars.get(project_path) ?? 0) + spendChars);
270
+ projectSavedChars.set(project_path, (projectSavedChars.get(project_path) ?? 0) + savedChars);
271
+ }
272
+ }
273
+ // ---------------------------------------------------------------------------
274
+ // 4. Compute tokens + cost.
275
+ // ---------------------------------------------------------------------------
276
+ const spendTokens = Math.ceil(totalSpendChars / CHARS_PER_TOKEN);
277
+ const savedTokens = Math.ceil(totalSavedChars / CHARS_PER_TOKEN);
278
+ const overheadTokens = OVERHEAD_TOKENS_PER_SESSION * specshipSessionCount;
279
+ let spendCostUsd = 0;
280
+ let savedCostUsd = 0;
281
+ for (const [model, chars] of spendCharsByModel) {
282
+ spendCostUsd += priceChars(chars, model, pricingRows);
283
+ }
284
+ for (const [model, chars] of savedCharsByModel) {
285
+ savedCostUsd += priceChars(chars, model, pricingRows);
286
+ }
287
+ const netTokens = savedTokens - spendTokens - overheadTokens;
288
+ const netCostUsd = savedCostUsd - spendCostUsd;
289
+ // ---------------------------------------------------------------------------
290
+ // 5. byTool.
291
+ // ---------------------------------------------------------------------------
292
+ const byTool = Array.from(toolCalls.entries()).map(([tool, calls]) => ({
293
+ tool,
294
+ calls,
295
+ spendTokens: Math.ceil((toolSpendChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
296
+ savedTokens: Math.ceil((toolSavedChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
297
+ })).sort((a, b) => b.calls - a.calls);
298
+ // ---------------------------------------------------------------------------
299
+ // 6. byProject (only when no project filter).
300
+ // ---------------------------------------------------------------------------
301
+ // NOTE: per-project netTokens = saved − spend and intentionally does NOT
302
+ // subtract the tool-definition overhead. Overhead is a per-session constant
303
+ // and isn't attributed to individual projects, so the byProject rows will
304
+ // not sum to the headline netTokens (which does include overhead). The page
305
+ // methodology note discloses this.
306
+ const byProject = project
307
+ ? []
308
+ : Array.from(projectSpendChars.keys()).map((proj) => {
309
+ const spend = Math.ceil((projectSpendChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
310
+ const saved = Math.ceil((projectSavedChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
311
+ return {
312
+ project: proj,
313
+ spendTokens: spend,
314
+ savedTokens: saved,
315
+ netTokens: saved - spend, // overhead not allocated per-project — see note above
316
+ };
317
+ }).sort((a, b) => b.spendTokens - a.spendTokens);
318
+ // ---------------------------------------------------------------------------
319
+ // 7. trend.
320
+ // ---------------------------------------------------------------------------
321
+ const allDayBuckets = new Set([...trendSpend.keys(), ...trendSaved.keys()]);
322
+ const trend = Array.from(allDayBuckets)
323
+ .sort((a, b) => a - b)
324
+ .map((ts) => ({
325
+ ts,
326
+ spendTokens: Math.ceil((trendSpend.get(ts) ?? 0) / CHARS_PER_TOKEN),
327
+ savedTokens: Math.ceil((trendSaved.get(ts) ?? 0) / CHARS_PER_TOKEN),
328
+ }));
329
+ return {
330
+ spendTokens,
331
+ spendCostUsd,
332
+ savedTokens,
333
+ savedCostUsd,
334
+ overheadTokens,
335
+ netTokens,
336
+ netCostUsd,
337
+ unresolvedCalls,
338
+ totalSpecshipCalls,
339
+ byTool,
340
+ byProject,
341
+ trend,
342
+ };
343
+ }
@@ -12,7 +12,8 @@
12
12
  * - `ingestAll(db)` — one-shot pass, useful for CI / scripts.
13
13
  * - `startWatcher(db)` — live tail; runs ingestAll once + on each FS event.
14
14
  */
15
- export { ingestAll, listTranscriptFiles, decodeProjectSlug } from './ingestor.js';
15
+ export { ingestAll, listTranscriptFiles, decodeProjectSlug, primaryProjectMatcher } from './ingestor.js';
16
16
  export { startWatcher } from './watcher.js';
17
17
  export { resolvePricing, computeCost, normalizeModelId } from './pricing.js';
18
18
  export { parseLine, summarizeToolInput, extractUserPrompt, toolResultLength } from './parser.js';
19
+ export { backfillDisplaced } from './impact-backfill.js';
@@ -27,6 +27,7 @@ import * as os from 'os';
27
27
  import * as path from 'path';
28
28
  import { computeCost, resolvePricing } from './pricing.js';
29
29
  import { parseLine, toEpochMs, extractUserPrompt, summarizeToolInput, toolResultLength, } from './parser.js';
30
+ import { classifyToolCall } from './specship-classify.js';
30
31
  /**
31
32
  * Convert Claude's slash-escaped project dir name back into a real path.
32
33
  * `~/.claude/projects/-Users-alice-projects-foo` → `/Users/alice/projects/foo`
@@ -34,6 +35,24 @@ import { parseLine, toEpochMs, extractUserPrompt, summarizeToolInput, toolResult
34
35
  export function decodeProjectSlug(slug) {
35
36
  return slug.startsWith('-') ? '/' + slug.slice(1).replace(/-/g, '/') : slug;
36
37
  }
38
+ /**
39
+ * Build a predicate that tells whether a stored `project_path` belongs to the
40
+ * primary project, given the primary's REAL filesystem path.
41
+ *
42
+ * Why this isn't a plain `===`: `decodeProjectSlug` lossily turns every '-' in
43
+ * the slug into '/', so a real path like `/Users/a/dev/claude-projects/x` gets
44
+ * STORED as `/Users/a/dev/claude/projects/x`. The savings graph resolver only
45
+ * has the real primary path, so it never matched the mangled stored form and
46
+ * every call resolved to a null graph (savedTokens stuck at 0). We match BOTH
47
+ * the real path and its mangled form (computed by round-tripping the real path
48
+ * through the same encode→decode the storage used). No stored data is changed,
49
+ * so the (consistently-mangled) project filter is unaffected.
50
+ */
51
+ export function primaryProjectMatcher(primaryRealPath) {
52
+ // encode: real path → slug form (every '/' → '-'); decode: slug → mangled path.
53
+ const mangled = decodeProjectSlug(primaryRealPath.replace(/\//g, '-'));
54
+ return (storedPath) => storedPath === primaryRealPath || storedPath === mangled;
55
+ }
37
56
  /**
38
57
  * List every JSONL file inside `<claudeRoot>/<slug>/<sessionId>.jsonl`.
39
58
  * Returns the absolute file path + the decoded project path.
@@ -222,9 +241,22 @@ function ingestFile(db, filePath, projectPath, pricing, options) {
222
241
  VALUES (?, ?, ?, ?)
223
242
  ON CONFLICT(path) DO UPDATE SET last_seen = excluded.last_seen
224
243
  `).run(projectPath, projectName, now, now);
244
+ // Resolve the graph once per file (per project path) — avoids reopening the DB
245
+ // on every tool call. Returns null when no resolver is configured. The
246
+ // resolver can throw on a locked/corrupt index; that estimate is best-effort
247
+ // decoration and must NEVER abort transcript ingest, so degrade to null.
248
+ let graph = null;
249
+ if (options.resolveGraph) {
250
+ try {
251
+ graph = options.resolveGraph(projectPath);
252
+ }
253
+ catch {
254
+ graph = null;
255
+ }
256
+ }
225
257
  // Per-file ingest in a transaction.
226
258
  const txn = db.transaction(() => {
227
- return processLines(db, filePath, projectPath, completeLines, pricing);
259
+ return processLines(db, filePath, projectPath, completeLines, pricing, graph);
228
260
  });
229
261
  const result = txn();
230
262
  // Persist new offset.
@@ -255,7 +287,7 @@ function ingestFile(db, filePath, projectPath, pricing, options) {
255
287
  * the next user entry) so result_length is captured. Maintain a map of
256
288
  * pending tool_use_id → { promptId, name, summary, ts } as we walk.
257
289
  */
258
- function processLines(db, filePath, projectPath, completeLines, pricing) {
290
+ function processLines(db, filePath, projectPath, completeLines, pricing, graph) {
259
291
  const insSession = db.prepare(`
260
292
  INSERT INTO claude_sessions (id, project_path, source_file, started_at, ended_at, prompt_count, last_model)
261
293
  VALUES (?, ?, ?, ?, ?, 0, ?)
@@ -286,8 +318,8 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
286
318
  cost_usd = excluded.cost_usd
287
319
  `);
288
320
  const insToolCall = db.prepare(`
289
- INSERT INTO claude_tool_calls (prompt_id, session_id, assistant_uuid, tool_use_id, tool_name, input_summary, input_json, result_length, ts)
290
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
321
+ INSERT INTO claude_tool_calls (prompt_id, session_id, assistant_uuid, tool_use_id, tool_name, input_summary, input_json, result_length, ts, is_specship, displaced_files, resolution)
322
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
291
323
  `);
292
324
  /**
293
325
  * Append the assistant's text + thinking blocks from one assistant turn
@@ -387,7 +419,8 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
387
419
  const len = toolResultLength(block);
388
420
  const pending = pendingTools.get(block.tool_use_id);
389
421
  if (pending) {
390
- insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, block.tool_use_id, pending.toolName, pending.summary, pending.inputJson, len, pending.ts);
422
+ const cls = classifyToolCall({ toolName: pending.toolName, inputJson: pending.inputJson, resultLength: len }, graph);
423
+ insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, block.tool_use_id, pending.toolName, pending.summary, pending.inputJson, len, pending.ts, cls.isSpecship, cls.displacedFiles, cls.resolution);
391
424
  toolCallsInserted++;
392
425
  pendingTools.delete(block.tool_use_id);
393
426
  }
@@ -492,7 +525,9 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
492
525
  // result_length=0 so the tool call still shows up in analytics (better to
493
526
  // show "0 tokens returned" than to omit the call).
494
527
  for (const [toolUseId, pending] of pendingTools) {
495
- insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, toolUseId, pending.toolName, pending.summary, pending.inputJson, 0, pending.ts);
528
+ // result_length=0 for pending/unmatched tools classifyToolCall returns 'n/a' for specship.
529
+ const cls = classifyToolCall({ toolName: pending.toolName, inputJson: pending.inputJson, resultLength: 0 }, graph);
530
+ insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, toolUseId, pending.toolName, pending.summary, pending.inputJson, 0, pending.ts, cls.isSpecship, cls.displacedFiles, cls.resolution);
496
531
  toolCallsInserted++;
497
532
  }
498
533
  return {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Pure classifiers and symbol-extraction helpers for SpecShip Token Impact.
3
+ *
4
+ * SERVER-LOCAL COPY. The lib has the canonical version at
5
+ * `src/analytics/specship-impact.ts`; this file is duplicated here on purpose
6
+ * so the server never carries a runtime `import … from '@selvakumaresra/specship'`
7
+ * — a bare-package value import does NOT resolve in bundled / different-cwd
8
+ * mode and silently drops the server back to a stale build (the same failure
9
+ * mode `server.ts`/`workflow.ts` already guard against). These functions are
10
+ * pure (no I/O, no deps), so duplication is cheap; `__tests__/specship-impact-parity.test.ts`
11
+ * asserts the two copies stay behaviourally identical.
12
+ */
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+ /** Prefix shared by every MCP tool routed through the specship server. */
17
+ const MCP_SPECSHIP_PREFIX = 'mcp__specship__';
18
+ /**
19
+ * Strip-prefix base names of SpecShip tools that return code-graph source.
20
+ * These are the tools a SpecShip call can displace a native Read/Grep with.
21
+ * Excluded: designer_*, specship_link_assert, specship_link_verify,
22
+ * specship_spec, specship_drifted, specship_status — they don't return
23
+ * indexed source symbols.
24
+ */
25
+ const SOURCE_RETURNING_TOOLS = new Set([
26
+ 'specship_explore',
27
+ 'specship_node',
28
+ 'specship_callers',
29
+ 'specship_callees',
30
+ 'specship_impact',
31
+ 'specship_search',
32
+ 'specship_files',
33
+ ]);
34
+ /**
35
+ * Tools whose input carries a `symbol` key (single identifier string).
36
+ * Verified against src/mcp/tools.ts inputSchema `required: ['symbol']`.
37
+ */
38
+ const SYMBOL_KEY_TOOLS = new Set([
39
+ 'specship_node',
40
+ 'specship_callers',
41
+ 'specship_callees',
42
+ 'specship_impact',
43
+ ]);
44
+ /**
45
+ * Regex for a single valid identifier token (with optional Class.method
46
+ * qualifier).
47
+ */
48
+ const SYMBOL_TOKEN_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)?$/;
49
+ /**
50
+ * Regex a token must match to be considered "code-ish" rather than prose.
51
+ * Real symbol names almost always contain an uppercase letter, underscore,
52
+ * dollar sign, digit, or a dot; pure lowercase words look like prose.
53
+ */
54
+ const CODE_SIGNAL_RE = /[A-Z_$\d.]/;
55
+ /** Cap on how many symbol-shaped tokens we pull from one query. */
56
+ const MAX_SYMBOLS_PER_QUERY = 16;
57
+ // ---------------------------------------------------------------------------
58
+ // Public API
59
+ // ---------------------------------------------------------------------------
60
+ /** True iff `name` is routed through the SpecShip MCP server. */
61
+ export function isSpecshipTool(name) {
62
+ return name.startsWith(MCP_SPECSHIP_PREFIX);
63
+ }
64
+ /** True iff the tool returns code-graph source that can displace a Read/Grep. */
65
+ export function isSourceReturningTool(name) {
66
+ if (!name.startsWith(MCP_SPECSHIP_PREFIX))
67
+ return false;
68
+ const base = name.slice(MCP_SPECSHIP_PREFIX.length);
69
+ return SOURCE_RETURNING_TOOLS.has(base);
70
+ }
71
+ /**
72
+ * Extracts the symbol names a tool call asked about from its serialised input
73
+ * JSON. Returns `[]` when no symbols are resolvable (bad JSON, prose query,
74
+ * non-symbol tool).
75
+ */
76
+ export function extractRequestedSymbols(toolName, inputJson) {
77
+ if (!inputJson)
78
+ return [];
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(inputJson);
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ if (parsed === null || typeof parsed !== 'object')
87
+ return [];
88
+ const args = parsed;
89
+ if (!toolName.startsWith(MCP_SPECSHIP_PREFIX))
90
+ return [];
91
+ const base = toolName.slice(MCP_SPECSHIP_PREFIX.length);
92
+ if (SYMBOL_KEY_TOOLS.has(base)) {
93
+ const sym = args['symbol'];
94
+ return typeof sym === 'string' && sym.length > 0 ? [sym] : [];
95
+ }
96
+ if (base === 'specship_explore' || base === 'specship_search') {
97
+ const q = args['query'];
98
+ if (typeof q !== 'string' || q.length === 0)
99
+ return [];
100
+ return symbolBagFromQuery(q);
101
+ }
102
+ return [];
103
+ }
104
+ /**
105
+ * Classify one tool call → the three ingest columns
106
+ * (`isSpecship`, `resolution`, `displacedFiles`). A graph-estimate failure
107
+ * degrades to 'unresolved' (it must NEVER break core transcript ingest).
108
+ */
109
+ export function classifyToolCall(call, graph) {
110
+ const { toolName, inputJson, resultLength } = call;
111
+ if (!isSpecshipTool(toolName)) {
112
+ return { isSpecship: 0, resolution: null, displacedFiles: null };
113
+ }
114
+ if (isSourceReturningTool(toolName) && resultLength > 0) {
115
+ const symbols = extractRequestedSymbols(toolName, inputJson);
116
+ if (symbols.length === 0) {
117
+ return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
118
+ }
119
+ if (graph === null) {
120
+ return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
121
+ }
122
+ let files;
123
+ let resolved;
124
+ try {
125
+ ({ files, resolved } = graph.estimateReadEquivalent(symbols));
126
+ }
127
+ catch {
128
+ return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
129
+ }
130
+ if (resolved) {
131
+ return {
132
+ isSpecship: 1,
133
+ resolution: 'resolved',
134
+ displacedFiles: JSON.stringify(files.map((f) => [f.path, f.size])),
135
+ };
136
+ }
137
+ return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
138
+ }
139
+ return { isSpecship: 1, resolution: 'n/a', displacedFiles: null };
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Internal helpers
143
+ // ---------------------------------------------------------------------------
144
+ // Real explore/search queries are MIXED bags (symbol names + lowercase
145
+ // keywords, often 10+ tokens). FILTER the symbol-shaped tokens out rather than
146
+ // rejecting the whole query; pure prose yields []. Capped to bound graph work.
147
+ function symbolBagFromQuery(query) {
148
+ const symbols = query
149
+ .trim()
150
+ .split(/\s+/)
151
+ .filter((t) => SYMBOL_TOKEN_RE.test(t) && CODE_SIGNAL_RE.test(t));
152
+ return symbols.slice(0, MAX_SYMBOLS_PER_QUERY);
153
+ }
@@ -13,9 +13,11 @@
13
13
  * GET /api/claude/costs?range= — cost rollup, timeseries, per-model
14
14
  * GET /api/claude/compare — per-project cost comparison
15
15
  * GET /api/claude/tips — rule-based tips engine output
16
+ * GET /api/claude/specship-impact?range=&project= — SpecShip token-impact aggregation
16
17
  * POST /api/claude/ingest — force a one-shot ingest pass
17
18
  */
18
19
  import { decodeProjectSlug } from '../ingest/index.js';
20
+ import { computeSpecshipImpact } from '../ingest/impact-query.js';
19
21
  /**
20
22
  * Normalize a `?project=` filter value to the form stored in
21
23
  * claude_sessions.project_path. The Sessions page (and any other UI
@@ -346,6 +348,13 @@ export async function registerClaudeRoutes(app) {
346
348
  const durationMs = session.started_at && session.ended_at
347
349
  ? Math.max(0, session.ended_at - session.started_at)
348
350
  : 0;
351
+ // SpecShip token-impact rollup for this session.
352
+ const impact = computeSpecshipImpact(db, { since: 0, sessionId });
353
+ const specship = {
354
+ spendTokens: impact.spendTokens,
355
+ savedTokens: impact.savedTokens,
356
+ netTokens: impact.netTokens,
357
+ };
349
358
  return {
350
359
  sessionId,
351
360
  byTool,
@@ -354,6 +363,7 @@ export async function registerClaudeRoutes(app) {
354
363
  skills,
355
364
  filesTouched,
356
365
  durationMs,
366
+ specship,
357
367
  };
358
368
  });
359
369
  app.get('/api/claude/heatmap', async (req, reply) => {
@@ -855,6 +865,28 @@ export async function registerClaudeRoutes(app) {
855
865
  tips.sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9));
856
866
  return { tips };
857
867
  });
868
+ /**
869
+ * GET /api/claude/specship-impact?range=&project=
870
+ *
871
+ * Aggregate SpecShip token-impact metrics: how many tokens specship calls
872
+ * spent vs. how many tokens' worth of Read calls they displaced (saved),
873
+ * with per-prompt dedup so a file referenced by two specship calls in the
874
+ * same prompt counts only once. See packages/server/src/ingest/impact-query.ts
875
+ * for the full algorithm.
876
+ *
877
+ * Query params:
878
+ * range — 'today' | 'week' (default) | 'month' | 'all'
879
+ * project — project slug (decoded to path) or raw path; omit for all projects
880
+ */
881
+ app.get('/api/claude/specship-impact', async (req, reply) => {
882
+ const cg = requirePrimary(reply);
883
+ if (!cg)
884
+ return;
885
+ const db = getDb(cg);
886
+ const since = rangeStart(rangeKey(req.query.range));
887
+ const project = req.query.project ? normalizeProjectFilter(req.query.project) : undefined;
888
+ return computeSpecshipImpact(db, { since, project });
889
+ });
858
890
  /**
859
891
  * Force a one-shot ingest pass. Useful for "Refresh" button in the UI.
860
892
  */