@selvakumaresra/specship 0.1.2 → 0.3.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 (216) hide show
  1. package/README.md +2 -2
  2. package/commands/ss-design-implement.md +79 -0
  3. package/commands/{cg-drifted.md → ss-drifted.md} +2 -2
  4. package/commands/{cg-fix.md → ss-fix.md} +1 -1
  5. package/commands/{cg-implement.md → ss-implement.md} +1 -1
  6. package/commands/ss-spec-author.md +43 -0
  7. package/commands/ss-spec-review.md +48 -0
  8. package/dist/bin/specship.js +6 -5
  9. package/dist/bin/specship.js.map +1 -1
  10. package/dist/db/index.d.ts +3 -1
  11. package/dist/db/index.d.ts.map +1 -1
  12. package/dist/db/index.js +5 -2
  13. package/dist/db/index.js.map +1 -1
  14. package/dist/db/migrations.d.ts +1 -1
  15. package/dist/db/migrations.d.ts.map +1 -1
  16. package/dist/db/migrations.js +36 -1
  17. package/dist/db/migrations.js.map +1 -1
  18. package/dist/db/schema.sql +9 -0
  19. package/dist/directory.d.ts +13 -3
  20. package/dist/directory.d.ts.map +1 -1
  21. package/dist/directory.js +17 -3
  22. package/dist/directory.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/installer/index.d.ts.map +1 -1
  27. package/dist/installer/index.js +16 -10
  28. package/dist/installer/index.js.map +1 -1
  29. package/dist/installer/targets/claude.d.ts +11 -1
  30. package/dist/installer/targets/claude.d.ts.map +1 -1
  31. package/dist/installer/targets/claude.js +59 -2
  32. package/dist/installer/targets/claude.js.map +1 -1
  33. package/dist/server/ingest/ingestor.js +117 -17
  34. package/dist/server/routes/claude.js +394 -6
  35. package/dist/server/routes/graph.js +62 -0
  36. package/dist/server/routes/spec.js +161 -1
  37. package/dist/server/routes/status.js +77 -0
  38. package/dist/server/routes/workflow.js +31 -3
  39. package/dist/web/chunk-2AJCHB7P.js +1 -0
  40. package/dist/web/chunk-2CPLUFCH.js +2 -0
  41. package/dist/web/chunk-2DHIGIOI.js +1 -0
  42. package/dist/web/chunk-2GBEK2GM.js +1 -0
  43. package/dist/web/chunk-2I7L37NS.js +1 -0
  44. package/dist/web/chunk-2NAWAJB5.js +1 -0
  45. package/dist/web/chunk-2OJBIPE4.js +1 -0
  46. package/dist/web/chunk-2OKMB4KX.js +2 -0
  47. package/dist/web/chunk-3E2WB6D5.js +1 -0
  48. package/dist/web/chunk-3EBFYSCH.js +2 -0
  49. package/dist/web/chunk-3QCQ4BXS.js +1 -0
  50. package/dist/web/chunk-3SEJX2BK.js +1 -0
  51. package/dist/web/chunk-42XVAQ6I.js +1 -0
  52. package/dist/web/chunk-4IMMPEYM.js +1 -0
  53. package/dist/web/chunk-4N5DWG46.js +1 -0
  54. package/dist/web/chunk-4TJQJPCZ.js +1 -0
  55. package/dist/web/chunk-4WZIHTPC.js +1 -0
  56. package/dist/web/chunk-4YVSYOSD.js +1 -0
  57. package/dist/web/chunk-5BQIOYKW.js +1 -0
  58. package/dist/web/chunk-5HGWHUJA.js +1 -0
  59. package/dist/web/chunk-5Y244R4G.js +1 -0
  60. package/dist/web/chunk-6RRDPT5Z.js +1 -0
  61. package/dist/web/chunk-6VKB2ZWM.js +1 -0
  62. package/dist/web/chunk-7DMFVTU4.js +1 -0
  63. package/dist/web/chunk-7RNS77UP.js +1 -0
  64. package/dist/web/chunk-7SMPKVEP.js +1 -0
  65. package/dist/web/chunk-AZJVTPLU.js +1 -0
  66. package/dist/web/chunk-BCZM5AXU.js +1 -0
  67. package/dist/web/chunk-BLBRMCN2.js +1 -0
  68. package/dist/web/chunk-BMIAXD2V.js +2 -0
  69. package/dist/web/chunk-BPECIDVO.js +1 -0
  70. package/dist/web/chunk-BUXWEHIY.js +1 -0
  71. package/dist/web/chunk-BYZFQSM6.js +1 -0
  72. package/dist/web/chunk-DA6SNNAF.js +1 -0
  73. package/dist/web/chunk-DLQPZWSI.css +1 -0
  74. package/dist/web/chunk-DTRN7FZR.js +1 -0
  75. package/dist/web/chunk-DYRFLPJA.js +1 -0
  76. package/dist/web/chunk-E3J3CXR5.js +1 -0
  77. package/dist/web/chunk-E44X4RH2.js +1 -0
  78. package/dist/web/chunk-E73OX2P7.js +1 -0
  79. package/dist/web/chunk-EAXRKDLV.js +1 -0
  80. package/dist/web/chunk-EBKKDHYI.js +1 -0
  81. package/dist/web/chunk-EE7V7Q5P.js +1 -0
  82. package/dist/web/chunk-EKY2FUHU.js +1 -0
  83. package/dist/web/chunk-EMGMOEVR.js +1 -0
  84. package/dist/web/chunk-EP6XOPXH.js +1 -0
  85. package/dist/web/chunk-ESGDLJOJ.js +1 -0
  86. package/dist/web/chunk-ETJG7NCY.js +1 -0
  87. package/dist/web/chunk-EUUEFEDI.js +1 -0
  88. package/dist/web/chunk-FGNZDHTL.js +11 -0
  89. package/dist/web/chunk-FIJW2UNJ.js +1 -0
  90. package/dist/web/chunk-FMV5PXRC.js +5 -0
  91. package/dist/web/chunk-G7VZT5KB.js +3 -0
  92. package/dist/web/chunk-GRZYXPSO.js +7 -0
  93. package/dist/web/chunk-GYGPS3AN.js +1 -0
  94. package/dist/web/chunk-H7AF7YS4.js +1 -0
  95. package/dist/web/chunk-HDZDQILN.js +1 -0
  96. package/dist/web/chunk-HMK6UO6N.js +1 -0
  97. package/dist/web/chunk-HZA6NEAB.js +1 -0
  98. package/dist/web/chunk-IHEE5NYJ.js +1 -0
  99. package/dist/web/chunk-ISNEBICW.js +1 -0
  100. package/dist/web/chunk-J2GZVLHH.js +1 -0
  101. package/dist/web/chunk-JFYVCXK3.js +1 -0
  102. package/dist/web/chunk-JN6W7HCN.js +17 -0
  103. package/dist/web/chunk-JT7P3DEK.js +6 -0
  104. package/dist/web/chunk-JTFXTIPE.js +903 -0
  105. package/dist/web/chunk-L37MTFSG.js +3 -0
  106. package/dist/web/chunk-LB6JPLX2.js +1 -0
  107. package/dist/web/chunk-LNSVDHCI.js +1 -0
  108. package/dist/web/chunk-LV4G6QFG.js +2 -0
  109. package/dist/web/chunk-LVGIY3SO.js +1 -0
  110. package/dist/web/chunk-LXLHIHEN.js +1 -0
  111. package/dist/web/chunk-MC4DFIHG.js +1 -0
  112. package/dist/web/chunk-MVOMVPYB.js +1 -0
  113. package/dist/web/chunk-N6SS4G6S.js +1 -0
  114. package/dist/web/chunk-NTBJG6SJ.js +1 -0
  115. package/dist/web/chunk-NUDB3Q2Y.js +3 -0
  116. package/dist/web/chunk-OXEF5E3E.js +1 -0
  117. package/dist/web/chunk-PDN6QYGJ.js +4 -0
  118. package/dist/web/chunk-PGGJPDJG.js +1 -0
  119. package/dist/web/chunk-PUYSJNJR.js +1 -0
  120. package/dist/web/chunk-Q2RVFS45.js +1 -0
  121. package/dist/web/chunk-Q7L6LLAK.js +1 -0
  122. package/dist/web/chunk-QCMKJIWY.js +1 -0
  123. package/dist/web/chunk-QH6CF3M3.js +1 -0
  124. package/dist/web/chunk-QQ5LD7PI.js +1 -0
  125. package/dist/web/chunk-QR6L3KAC.js +1 -0
  126. package/dist/web/chunk-R2DLK4HO.js +1 -0
  127. package/dist/web/chunk-R5W2MDZN.js +1 -0
  128. package/dist/web/chunk-RAAMPHPJ.js +1 -0
  129. package/dist/web/chunk-RD6TVPOT.js +1 -0
  130. package/dist/web/chunk-RKY4EJYJ.js +1 -0
  131. package/dist/web/chunk-RONYWVY7.js +1 -0
  132. package/dist/web/chunk-RXKXYF2C.js +1 -0
  133. package/dist/web/chunk-SBWU7JFC.js +1 -0
  134. package/dist/web/chunk-SEXBRGYK.js +1 -0
  135. package/dist/web/chunk-SHPTC4RL.js +1 -0
  136. package/dist/web/chunk-SUZYBYDW.js +1 -0
  137. package/dist/web/chunk-SWKJRNYY.js +1 -0
  138. package/dist/web/chunk-T66XVKGB.js +1 -0
  139. package/dist/web/chunk-T7AZ65JP.js +1 -0
  140. package/dist/web/chunk-TCZDVOHD.js +1 -0
  141. package/dist/web/chunk-TPTITA3V.js +1 -0
  142. package/dist/web/chunk-TR335633.js +1 -0
  143. package/dist/web/chunk-TWXZK6XM.js +1 -0
  144. package/dist/web/chunk-UR5KDXPX.js +1 -0
  145. package/dist/web/chunk-UR6O2GEH.js +1 -0
  146. package/dist/web/chunk-UTNMGWTP.js +1 -0
  147. package/dist/web/chunk-UYC52MBC.js +1 -0
  148. package/dist/web/chunk-VECWMHJP.js +1 -0
  149. package/dist/web/chunk-VUACT35R.js +3 -0
  150. package/dist/web/chunk-VZI7H4SZ.js +1 -0
  151. package/dist/web/chunk-WAI2JMZP.js +1 -0
  152. package/dist/web/chunk-WB6YHOD4.js +1 -0
  153. package/dist/web/chunk-WBT64AWV.js +1 -0
  154. package/dist/web/chunk-WDU3WICG.js +1 -0
  155. package/dist/web/chunk-WFXJIXZE.js +4 -0
  156. package/dist/web/chunk-WTGYRH3Z.js +298 -0
  157. package/dist/web/chunk-WXTCVDTP.js +1 -0
  158. package/dist/web/chunk-X2HTISHL.js +1 -0
  159. package/dist/web/chunk-XCDHWLVH.js +1 -0
  160. package/dist/web/chunk-Y3H6FFUZ.js +1 -0
  161. package/dist/web/chunk-Y4F5ULGJ.js +1 -0
  162. package/dist/web/chunk-YAWCRPHV.js +1 -0
  163. package/dist/web/chunk-YEGKAAEE.js +1 -0
  164. package/dist/web/chunk-YM2KU57F.js +1 -0
  165. package/dist/web/chunk-YRERBP6T.js +1 -0
  166. package/dist/web/chunk-ZLV4VCDG.js +3 -0
  167. package/dist/web/chunk-ZTVI5KFF.js +1 -0
  168. package/dist/web/index.html +3 -3
  169. package/dist/web/main-ESADRXN2.css +1 -0
  170. package/dist/web/main-WVI3YTDU.js +1 -0
  171. package/dist/web/media/codicon-LN6W7LCM.ttf +0 -0
  172. package/dist/web/styles-KSOPUVDA.css +1 -0
  173. package/dist/workflows/defaults/claude-design-implement.yaml +247 -0
  174. package/dist/workflows/defaults/spec-author.yaml +214 -0
  175. package/dist/workflows/executor.d.ts.map +1 -1
  176. package/dist/workflows/executor.js +4 -3
  177. package/dist/workflows/executor.js.map +1 -1
  178. package/package.json +8 -3
  179. package/scripts/offline-install.sh +19 -6
  180. package/scripts/sync-shim-version.mjs +64 -0
  181. package/dist/web/chunk-2YZXEHZ2.js +0 -1
  182. package/dist/web/chunk-3GIC555L.js +0 -18
  183. package/dist/web/chunk-3IIIGRMT.js +0 -1
  184. package/dist/web/chunk-47QYKLE5.js +0 -1
  185. package/dist/web/chunk-4LHBWWP7.js +0 -1
  186. package/dist/web/chunk-4OAZLD5W.js +0 -1
  187. package/dist/web/chunk-5OQKAJAE.js +0 -1
  188. package/dist/web/chunk-7B525GKQ.js +0 -1
  189. package/dist/web/chunk-BPDXCOOZ.js +0 -1
  190. package/dist/web/chunk-DT37HTZB.js +0 -1
  191. package/dist/web/chunk-EIMUHJND.js +0 -1
  192. package/dist/web/chunk-FTESTUEO.js +0 -1
  193. package/dist/web/chunk-GLJZV6MU.js +0 -1
  194. package/dist/web/chunk-I7LS67U5.js +0 -1
  195. package/dist/web/chunk-L4TVIPSR.js +0 -1
  196. package/dist/web/chunk-MASCULC2.js +0 -1
  197. package/dist/web/chunk-MW7ICSRM.js +0 -1
  198. package/dist/web/chunk-OI5VP2A3.js +0 -1
  199. package/dist/web/chunk-RA6EBF6I.js +0 -1
  200. package/dist/web/chunk-RP3WU5Y6.js +0 -1
  201. package/dist/web/chunk-RQDRMTXN.js +0 -1
  202. package/dist/web/chunk-TQMT6UDU.js +0 -1
  203. package/dist/web/chunk-U7IYOV7T.js +0 -1
  204. package/dist/web/chunk-UE227MWF.js +0 -1
  205. package/dist/web/chunk-WV573J4K.js +0 -1
  206. package/dist/web/chunk-WVCKOJZL.js +0 -4
  207. package/dist/web/chunk-XZKLVPHE.js +0 -1
  208. package/dist/web/chunk-ZABKKHJ3.js +0 -1
  209. package/dist/web/main-RI5CO5Z4.js +0 -1
  210. package/dist/web/styles-CYN7IKT4.css +0 -1
  211. /package/commands/{cg-explore.md → ss-explore.md} +0 -0
  212. /package/commands/{cg-impact.md → ss-impact.md} +0 -0
  213. /package/commands/{cg-relink.md → ss-relink.md} +0 -0
  214. /package/commands/{cg-spec.md → ss-spec.md} +0 -0
  215. /package/commands/{cg-sync.md → ss-sync.md} +0 -0
  216. /package/commands/{cg-trace.md → ss-trace.md} +0 -0
@@ -15,6 +15,24 @@
15
15
  * GET /api/claude/tips — rule-based tips engine output
16
16
  * POST /api/claude/ingest — force a one-shot ingest pass
17
17
  */
18
+ import { decodeProjectSlug } from '../ingest/index.js';
19
+ /**
20
+ * Normalize a `?project=` filter value to the form stored in
21
+ * claude_sessions.project_path. The Sessions page (and any other UI
22
+ * surface that uses ProjectsService.projectQuery()) sends the directory
23
+ * SLUG that names the Claude Code transcript dir (e.g.
24
+ * `-Users-foo-projects-bar`), but the ingestor writes the DECODED path
25
+ * (e.g. `/Users/foo/projects/bar`) into project_path because that's the
26
+ * value the agent actually identifies the project by. Comparing slug
27
+ * against path always fails and the list comes back empty even though
28
+ * the rows are there — fix by decoding the slug form here.
29
+ *
30
+ * A path passed in directly (doesn't start with `-`) round-trips
31
+ * unchanged, so curl-by-path and the old behavior both keep working.
32
+ */
33
+ function normalizeProjectFilter(input) {
34
+ return decodeProjectSlug(input);
35
+ }
18
36
  const RANGE_WINDOW_MS = {
19
37
  today: 24 * 60 * 60 * 1000,
20
38
  week: 7 * 24 * 60 * 60 * 1000,
@@ -31,6 +49,18 @@ function rangeStart(key) {
31
49
  return 0;
32
50
  return Date.now() - RANGE_WINDOW_MS[key];
33
51
  }
52
+ /**
53
+ * Bounds of the period immediately BEFORE the current range window — used for
54
+ * week-over-week (wowDelta) comparisons. For 'all' there's no prior period, so
55
+ * both bounds collapse to 0 and callers should treat the delta as 0.
56
+ */
57
+ function priorWindow(key) {
58
+ if (key === 'all')
59
+ return { start: 0, end: 0 };
60
+ const w = RANGE_WINDOW_MS[key];
61
+ const end = Date.now() - w;
62
+ return { start: end - w, end };
63
+ }
34
64
  /**
35
65
  * Get the internal SQLite handle off the DatabaseConnection so we can run
36
66
  * Claude-specific aggregate queries directly. SpecShip exposes this via
@@ -91,7 +121,7 @@ export async function registerClaudeRoutes(app) {
91
121
  let whereProject = '';
92
122
  if (req.query.project) {
93
123
  whereProject = ' AND project_path = ?';
94
- params.push(req.query.project);
124
+ params.push(normalizeProjectFilter(req.query.project));
95
125
  }
96
126
  params.push(limit);
97
127
  const sessions = db.prepare(`
@@ -116,8 +146,216 @@ export async function registerClaudeRoutes(app) {
116
146
  const toolCalls = db.prepare(`
117
147
  SELECT * FROM claude_tool_calls WHERE session_id = ? ORDER BY ts ASC
118
148
  `).all(req.params.id);
149
+ // Per-prompt wall-clock duration: the gap from this prompt to the next one
150
+ // (last prompt runs to the session's end). Lets the UI show "how long did
151
+ // this turn take" without a separate per-event timeline.
152
+ const endedAt = session.ended_at ?? null;
153
+ prompts.forEach((p, i) => {
154
+ const next = prompts[i + 1];
155
+ const end = next ? next.ts : (endedAt ?? p.ts);
156
+ p.durationMs = Math.max(0, end - p.ts);
157
+ });
119
158
  return { session, prompts, toolCalls };
120
159
  });
160
+ /**
161
+ * SSE event stream for a single session — pushes new prompts and tool
162
+ * calls as the JSONL ingest watcher lands them, so the dashboard's
163
+ * Session Detail page can update without polling. Mirrors the shape used
164
+ * by /api/workflows/runs/:id/events.
165
+ *
166
+ * Polling cadence inside the loop is 500 ms — fast enough that the
167
+ * end-to-end "user hit Enter in Claude Code → prompt visible in
168
+ * dashboard" latency stays well under one second (300 ms watcher
169
+ * debounce + ≤50 ms ingest + ≤500 ms poll). Heartbeat every 15 s
170
+ * keeps idle connections alive past any proxy or browser tab-throttle
171
+ * cutoff.
172
+ *
173
+ * The client (LiveSessionTail in session-detail.ts) doesn't merge events
174
+ * incrementally — it just calls resource.refetch() on every event since
175
+ * the detail endpoint is local + cheap. Server-side, that means we only
176
+ * need to push enough info for the client to know "something changed"
177
+ * (id + ts), not the full row payloads. We send the full row anyway so
178
+ * a future client could merge incrementally without an API change.
179
+ */
180
+ app.get('/api/claude/session/:id/events', async (req, reply) => {
181
+ const cg = requirePrimary(reply);
182
+ if (!cg)
183
+ return;
184
+ const db = getDb(cg);
185
+ const sessionId = req.params.id;
186
+ // Confirm the session exists before opening the stream — saves a
187
+ // doomed connection from polling forever against a typo.
188
+ const sessionRow = db.prepare('SELECT id FROM claude_sessions WHERE id = ?').get(sessionId);
189
+ if (!sessionRow) {
190
+ return reply.code(404).send({ error: 'session not found' });
191
+ }
192
+ // Resume points — clients can pick up after a disconnect without
193
+ // re-receiving every prompt. Defaults to "now" so an opening client
194
+ // only sees future events.
195
+ let lastPromptTs = parseInt(req.query.sincePromptTs ?? '0', 10);
196
+ let lastToolTs = parseInt(req.query.sinceToolTs ?? '0', 10);
197
+ if (!lastPromptTs || Number.isNaN(lastPromptTs)) {
198
+ const row = db.prepare('SELECT MAX(ts) as m FROM claude_prompts WHERE session_id = ?').get(sessionId);
199
+ lastPromptTs = row?.m ?? 0;
200
+ }
201
+ if (!lastToolTs || Number.isNaN(lastToolTs)) {
202
+ const row = db.prepare('SELECT MAX(ts) as m FROM claude_tool_calls WHERE session_id = ?').get(sessionId);
203
+ lastToolTs = row?.m ?? 0;
204
+ }
205
+ reply.raw.setHeader('Content-Type', 'text/event-stream');
206
+ reply.raw.setHeader('Cache-Control', 'no-cache');
207
+ reply.raw.setHeader('Connection', 'keep-alive');
208
+ reply.raw.setHeader('X-Accel-Buffering', 'no'); // nginx-friendly
209
+ reply.raw.flushHeaders();
210
+ // Initial snapshot — gives the client a clean baseline (so it knows
211
+ // SSE is wired up even before any new event fires) and the cursor
212
+ // positions it should resume from on reconnect.
213
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ sessionId, lastPromptTs, lastToolTs })}\n\n`);
214
+ let closed = false;
215
+ req.raw.on('close', () => { closed = true; });
216
+ const newPromptsStmt = db.prepare('SELECT * FROM claude_prompts WHERE session_id = ? AND ts > ? ORDER BY ts ASC');
217
+ const newToolsStmt = db.prepare('SELECT * FROM claude_tool_calls WHERE session_id = ? AND ts > ? ORDER BY ts ASC');
218
+ let lastHeartbeat = Date.now();
219
+ const tick = () => {
220
+ if (closed)
221
+ return;
222
+ try {
223
+ const freshPrompts = newPromptsStmt.all(sessionId, lastPromptTs);
224
+ for (const p of freshPrompts) {
225
+ reply.raw.write(`event: prompt_added\ndata: ${JSON.stringify(p)}\n\n`);
226
+ if (p.ts > lastPromptTs)
227
+ lastPromptTs = p.ts;
228
+ }
229
+ const freshTools = newToolsStmt.all(sessionId, lastToolTs);
230
+ for (const t of freshTools) {
231
+ reply.raw.write(`event: tool_call_added\ndata: ${JSON.stringify(t)}\n\n`);
232
+ if (t.ts > lastToolTs)
233
+ lastToolTs = t.ts;
234
+ }
235
+ // Heartbeat every 15 s — keeps proxies / browser tab throttles
236
+ // from killing an otherwise-idle connection.
237
+ const now = Date.now();
238
+ if (now - lastHeartbeat >= 15_000) {
239
+ reply.raw.write(`: keepalive ${now}\n\n`);
240
+ lastHeartbeat = now;
241
+ }
242
+ }
243
+ catch (err) {
244
+ // Surface DB errors to the client as a named event and end the
245
+ // stream — the client's onError handler flips to polling.
246
+ reply.raw.write(`event: stream_error\ndata: ${JSON.stringify({ message: err instanceof Error ? err.message : String(err) })}\n\n`);
247
+ reply.raw.end();
248
+ closed = true;
249
+ return;
250
+ }
251
+ setTimeout(tick, 500);
252
+ };
253
+ void tick();
254
+ });
255
+ /**
256
+ * Session-level aggregation for the Session Detail "Session summary" panel.
257
+ * Three things callers can't cheaply derive client-side from the per-prompt
258
+ * list: tool usage rolled up by name, files touched across all turns, and
259
+ * slash-command / skill invocation counts (which require either a regex
260
+ * over every prompt's text or json_extract over every Skill tool input).
261
+ * Computed once per session-detail page load (and after refresh) — cheap
262
+ * enough to recompute on every call, no caching.
263
+ */
264
+ app.get('/api/claude/session/:id/summary', async (req, reply) => {
265
+ const cg = requirePrimary(reply);
266
+ if (!cg)
267
+ return;
268
+ const db = getDb(cg);
269
+ const sessionId = req.params.id;
270
+ const session = db.prepare('SELECT id, started_at, ended_at FROM claude_sessions WHERE id = ?').get(sessionId);
271
+ if (!session)
272
+ return reply.code(404).send({ error: 'session not found' });
273
+ // Tools used in this session, rolled up by name. Mirrors the heatmap
274
+ // tool query but scoped to one session, so the panel can answer "what
275
+ // did Claude DO in this session" at a glance.
276
+ const byTool = db.prepare(`
277
+ SELECT tool_name as name, COUNT(*) as calls, COALESCE(SUM(result_length), 0) as totalBytes
278
+ FROM claude_tool_calls
279
+ WHERE session_id = ?
280
+ GROUP BY tool_name
281
+ ORDER BY calls DESC
282
+ `).all(sessionId);
283
+ // Models used. Most sessions stay on one model but sidechains can fan
284
+ // out to Haiku, so this surfaces multi-model spend that the session-level
285
+ // last_model column hides.
286
+ const byModel = db.prepare(`
287
+ SELECT model, COUNT(*) as prompts, COALESCE(SUM(cost_usd), 0) as cost
288
+ FROM claude_prompts
289
+ WHERE session_id = ? AND model IS NOT NULL
290
+ GROUP BY model
291
+ ORDER BY cost DESC
292
+ `).all(sessionId);
293
+ // Files touched via Read/Edit/Write/NotebookEdit. input_summary IS the
294
+ // file path for those tools (set in the ingestor's parser). Group by
295
+ // path, count ops, also record which tool last touched it so the UI
296
+ // can show a "last op" hint (Read vs Edit).
297
+ const filesTouched = db.prepare(`
298
+ SELECT input_summary as path, COUNT(*) as ops,
299
+ (SELECT tool_name FROM claude_tool_calls AS inner_tc
300
+ WHERE inner_tc.session_id = tc.session_id
301
+ AND inner_tc.input_summary = tc.input_summary
302
+ ORDER BY inner_tc.ts DESC LIMIT 1) as lastOp
303
+ FROM claude_tool_calls AS tc
304
+ WHERE tc.session_id = ?
305
+ AND tc.tool_name IN ('Read','Edit','Write','NotebookEdit','MultiEdit')
306
+ AND tc.input_summary != ''
307
+ GROUP BY tc.input_summary
308
+ ORDER BY ops DESC
309
+ LIMIT 30
310
+ `).all(sessionId);
311
+ // Skills invoked via the Skill tool. The ingestor stashes the
312
+ // JSON-serialized input under input_summary, so json_extract pulls the
313
+ // skill name straight out without needing the v7 input_json column to
314
+ // be backfilled.
315
+ const skills = db.prepare(`
316
+ SELECT
317
+ COALESCE(json_extract(input_summary, '$.skill'), json_extract(input_summary, '$.skill_name'), 'unknown') as name,
318
+ COUNT(*) as count
319
+ FROM claude_tool_calls
320
+ WHERE session_id = ? AND tool_name = 'Skill'
321
+ GROUP BY name
322
+ ORDER BY count DESC
323
+ `).all(sessionId);
324
+ // Slash commands invoked. Claude Code wraps each one in a
325
+ // <command-name>/foo</command-name> tag in the user-prompt text.
326
+ // Pull every prompt that has that tag and regex it client-side here
327
+ // (SQLite has no built-in regex). Cheap — most sessions have <50.
328
+ const taggedPrompts = db.prepare(`
329
+ SELECT text FROM claude_prompts WHERE session_id = ? AND text LIKE '%<command-name>%'
330
+ `).all(sessionId);
331
+ const cmdCounts = new Map();
332
+ const cmdRe = /<command-name>([^<]+)<\/command-name>/g;
333
+ for (const { text } of taggedPrompts) {
334
+ if (!text)
335
+ continue;
336
+ for (const m of text.matchAll(cmdRe)) {
337
+ const raw = m[1]?.trim();
338
+ if (!raw)
339
+ continue;
340
+ cmdCounts.set(raw, (cmdCounts.get(raw) ?? 0) + 1);
341
+ }
342
+ }
343
+ const slashCommands = Array.from(cmdCounts.entries())
344
+ .map(([name, count]) => ({ name, count }))
345
+ .sort((a, b) => b.count - a.count);
346
+ const durationMs = session.started_at && session.ended_at
347
+ ? Math.max(0, session.ended_at - session.started_at)
348
+ : 0;
349
+ return {
350
+ sessionId,
351
+ byTool,
352
+ byModel,
353
+ slashCommands,
354
+ skills,
355
+ filesTouched,
356
+ durationMs,
357
+ };
358
+ });
121
359
  app.get('/api/claude/heatmap', async (req, reply) => {
122
360
  const cg = requirePrimary(reply);
123
361
  if (!cg)
@@ -133,6 +371,28 @@ export async function registerClaudeRoutes(app) {
133
371
  ORDER BY calls DESC
134
372
  LIMIT 100
135
373
  `).all(since);
374
+ // Per-file 7-day call trend — one sparkline (oldest→newest) per file cell.
375
+ // Always a fixed 7-day window, independent of `range`, so the trend reads
376
+ // consistently regardless of the heatmap's selected window.
377
+ const dayMs = 24 * 60 * 60 * 1000;
378
+ const trend7Start = Math.floor(Date.now() / dayMs) * dayMs - 6 * dayMs;
379
+ const trendRows = db.prepare(`
380
+ SELECT input_summary as path, CAST((ts - ?) / ? AS INTEGER) as bucket, COUNT(*) as calls
381
+ FROM claude_tool_calls
382
+ WHERE ts >= ? AND tool_name IN ('Read','Edit','Write','NotebookEdit') AND input_summary != ''
383
+ GROUP BY input_summary, bucket
384
+ `).all(trend7Start, dayMs, trend7Start);
385
+ const trendByPath = new Map();
386
+ for (const r of trendRows) {
387
+ if (r.bucket < 0 || r.bucket > 6)
388
+ continue;
389
+ const arr = trendByPath.get(r.path) ?? [0, 0, 0, 0, 0, 0, 0];
390
+ arr[r.bucket] = r.calls;
391
+ trendByPath.set(r.path, arr);
392
+ }
393
+ for (const f of files) {
394
+ f.trend = trendByPath.get(f.path) ?? [0, 0, 0, 0, 0, 0, 0];
395
+ }
136
396
  // Tools heatmap.
137
397
  const tools = db.prepare(`
138
398
  SELECT tool_name as name, COUNT(*) as calls, SUM(result_length) as resultBytes
@@ -302,6 +562,24 @@ export async function registerClaudeRoutes(app) {
302
562
  // Use Opus 4-7 input ($15/M) as a generous upper bound — the UI shows
303
563
  // this as an approximation.
304
564
  const dollarsSaved = ((agg.cr ?? 0) / 1_000_000) * 15 * 0.9;
565
+ // Week-over-week reuse delta: current read-rate minus the prior equal-length
566
+ // window's read-rate. Fractional (e.g. 0.06 → "+6%" in the UI). Zero for
567
+ // 'all' (no prior period) or when there's no prior-window data to compare.
568
+ const prior = priorWindow(rangeKey(req.query.range));
569
+ let wowDelta = 0;
570
+ if (prior.end > prior.start) {
571
+ const pAgg = db.prepare(`
572
+ SELECT
573
+ COALESCE(SUM(total_input_tokens), 0) as inp,
574
+ COALESCE(SUM(total_cache_creation_tokens), 0) as cw,
575
+ COALESCE(SUM(total_cache_read_tokens), 0) as cr
576
+ FROM claude_sessions
577
+ WHERE started_at >= ? AND started_at < ?
578
+ `).get(prior.start, prior.end);
579
+ const pTotal = (pAgg.inp ?? 0) + (pAgg.cw ?? 0) + (pAgg.cr ?? 0);
580
+ if (pTotal > 0)
581
+ wowDelta = readRate - pAgg.cr / pTotal;
582
+ }
305
583
  return {
306
584
  readRate,
307
585
  creationTokens: agg.cw ?? 0,
@@ -310,9 +588,7 @@ export async function registerClaudeRoutes(app) {
310
588
  outputTokens: agg.out ?? 0,
311
589
  totalCost: agg.cost ?? 0,
312
590
  dollarsSaved,
313
- // wowDelta would need historical snapshotting; placeholder until we
314
- // add a rolling-window aggregate table. UI shows 0% with no arrow.
315
- wowDelta: 0,
591
+ wowDelta,
316
592
  };
317
593
  });
318
594
  app.get('/api/claude/costs', async (req, reply) => {
@@ -322,6 +598,16 @@ export async function registerClaudeRoutes(app) {
322
598
  const db = getDb(cg);
323
599
  const since = rangeStart(rangeKey(req.query.range));
324
600
  const total = db.prepare(`SELECT SUM(total_cost_usd) as t FROM claude_sessions WHERE started_at >= ?`).get(since);
601
+ // Week-over-week spend delta: fractional change in total cost vs the prior
602
+ // equal-length window (e.g. -0.08 → "-8%"). Zero for 'all' or no prior data.
603
+ const prior = priorWindow(rangeKey(req.query.range));
604
+ let wowDelta = 0;
605
+ if (prior.end > prior.start) {
606
+ const pTotal = db.prepare(`SELECT SUM(total_cost_usd) as t FROM claude_sessions WHERE started_at >= ? AND started_at < ?`).get(prior.start, prior.end);
607
+ const pt = pTotal.t ?? 0;
608
+ if (pt > 0)
609
+ wowDelta = ((total.t ?? 0) - pt) / pt;
610
+ }
325
611
  // Top prompts by cost.
326
612
  const topPrompts = db.prepare(`
327
613
  SELECT id, session_id, substr(text, 1, 200) as text, model, cost_usd,
@@ -359,7 +645,74 @@ export async function registerClaudeRoutes(app) {
359
645
  GROUP BY model
360
646
  ORDER BY cost DESC
361
647
  `).all(since);
362
- return { total: total.t ?? 0, topPrompts, series: dense, byModel };
648
+ return { total: total.t ?? 0, topPrompts, series: dense, byModel, wowDelta };
649
+ });
650
+ /**
651
+ * GET /api/claude/stats?range= — the four dashboard stat tiles, each with a
652
+ * value, a fractional week-over-week delta (vs the prior equal-length window)
653
+ * and a 7-day sparkline series (oldest→newest). Drift is a live graph metric
654
+ * with no history, so it returns value-only.
655
+ */
656
+ app.get('/api/claude/stats', async (req, reply) => {
657
+ const cg = requirePrimary(reply);
658
+ if (!cg)
659
+ return;
660
+ const db = getDb(cg);
661
+ const rkey = rangeKey(req.query.range);
662
+ const since = rangeStart(rkey);
663
+ const prior = priorWindow(rkey);
664
+ const dayMs = 24 * 60 * 60 * 1000;
665
+ const d7Start = Math.floor(Date.now() / dayMs) * dayMs - 6 * dayMs;
666
+ const dense7 = (rows) => {
667
+ const a = [0, 0, 0, 0, 0, 0, 0];
668
+ for (const r of rows)
669
+ if (r.bucket >= 0 && r.bucket <= 6)
670
+ a[r.bucket] = r.v;
671
+ return a;
672
+ };
673
+ const countSince = (sql, ...p) => db.prepare(sql).get(...p).c ?? 0;
674
+ // --- Tool calls ---
675
+ const tcTotal = countSince(`SELECT COUNT(*) c FROM claude_tool_calls WHERE ts >= ?`, since);
676
+ const tcPrior = prior.end > prior.start
677
+ ? countSince(`SELECT COUNT(*) c FROM claude_tool_calls WHERE ts >= ? AND ts < ?`, prior.start, prior.end)
678
+ : 0;
679
+ const tcSeries = db.prepare(`SELECT CAST((ts - ?) / ? AS INTEGER) as bucket, COUNT(*) as v FROM claude_tool_calls WHERE ts >= ? GROUP BY bucket`).all(d7Start, dayMs, d7Start);
680
+ const toolCalls = { value: tcTotal, delta: tcPrior > 0 ? (tcTotal - tcPrior) / tcPrior : 0, series: dense7(tcSeries) };
681
+ // --- Subagent spend share (by cost) ---
682
+ const subPctOf = (start, end) => {
683
+ const where = end == null ? `ts >= ?` : `ts >= ? AND ts < ?`;
684
+ const args = end == null ? [start] : [start, end];
685
+ const rows = db.prepare(`SELECT is_sidechain as side, COALESCE(SUM(cost_usd), 0) as cost FROM claude_prompts WHERE ${where} GROUP BY is_sidechain`).all(...args);
686
+ const sub = rows.find((r) => r.side === 1)?.cost ?? 0;
687
+ const tot = rows.reduce((a, r) => a + (r.cost ?? 0), 0);
688
+ return tot > 0 ? sub / tot : 0;
689
+ };
690
+ const subPct = subPctOf(since, null);
691
+ const subPctPrior = prior.end > prior.start ? subPctOf(prior.start, prior.end) : 0;
692
+ const saDaily = db.prepare(`
693
+ SELECT CAST((ts - ?) / ? AS INTEGER) as bucket,
694
+ SUM(CASE WHEN is_sidechain = 1 THEN cost_usd ELSE 0 END) as sub,
695
+ SUM(cost_usd) as tot
696
+ FROM claude_prompts WHERE ts >= ? GROUP BY bucket
697
+ `).all(d7Start, dayMs, d7Start);
698
+ const saSeries = [0, 0, 0, 0, 0, 0, 0];
699
+ for (const r of saDaily)
700
+ if (r.bucket >= 0 && r.bucket <= 6)
701
+ saSeries[r.bucket] = r.tot > 0 ? Math.round((r.sub / r.tot) * 100) : 0;
702
+ const subagentPct = { value: Math.round(subPct * 100), delta: subPct - subPctPrior, series: saSeries };
703
+ // --- Last session cost (delta vs previous session, spark of recent sessions) ---
704
+ const recent = db.prepare(`SELECT total_cost_usd as cost FROM claude_sessions ORDER BY started_at DESC LIMIT 10`).all();
705
+ const lastCost = recent[0]?.cost ?? 0;
706
+ const prevCost = recent[1]?.cost ?? 0;
707
+ const lastSessionCost = {
708
+ value: lastCost,
709
+ delta: prevCost > 0 ? (lastCost - prevCost) / prevCost : 0,
710
+ series: recent.map((r) => r.cost ?? 0).reverse(),
711
+ };
712
+ // --- Drift (live graph count, no time series) ---
713
+ const driftCount = cg.getSpecQueries().getLinksByState(['drifted', 'broken', 'orphaned']).length;
714
+ const drift = { value: driftCount, delta: 0, series: [] };
715
+ return { lastSessionCost, toolCalls, subagentPct, drift };
363
716
  });
364
717
  app.get('/api/claude/compare', async (_req, reply) => {
365
718
  const cg = requirePrimary(reply);
@@ -381,7 +734,42 @@ export async function registerClaudeRoutes(app) {
381
734
  GROUP BY p.path
382
735
  ORDER BY cost DESC
383
736
  `).all();
384
- return { projects: rows };
737
+ // Per-model cost split per project (drives the stacked bars).
738
+ const modelRows = db.prepare(`
739
+ SELECT s.project_path as path, p.model as model, COALESCE(SUM(p.cost_usd), 0) as cost
740
+ FROM claude_prompts p
741
+ JOIN claude_sessions s ON s.id = p.session_id
742
+ WHERE p.model IS NOT NULL
743
+ GROUP BY s.project_path, p.model
744
+ `).all();
745
+ const byModelByPath = new Map();
746
+ for (const r of modelRows) {
747
+ const arr = byModelByPath.get(r.path) ?? [];
748
+ arr.push({ model: r.model, cost: r.cost });
749
+ byModelByPath.set(r.path, arr);
750
+ }
751
+ // Top tools per project (top 4 by call count).
752
+ const toolRows = db.prepare(`
753
+ SELECT s.project_path as path, t.tool_name as name, COUNT(*) as calls
754
+ FROM claude_tool_calls t
755
+ JOIN claude_sessions s ON s.id = t.session_id
756
+ GROUP BY s.project_path, t.tool_name
757
+ `).all();
758
+ const toolsByPath = new Map();
759
+ for (const r of toolRows) {
760
+ const arr = toolsByPath.get(r.path) ?? [];
761
+ arr.push({ name: r.name, calls: r.calls });
762
+ toolsByPath.set(r.path, arr);
763
+ }
764
+ const projects = rows.map((p) => ({
765
+ ...p,
766
+ byModel: (byModelByPath.get(p.path) ?? []).sort((a, b) => b.cost - a.cost),
767
+ topTools: (toolsByPath.get(p.path) ?? [])
768
+ .sort((a, b) => b.calls - a.calls)
769
+ .slice(0, 4)
770
+ .map((t) => t.name),
771
+ }));
772
+ return { projects };
385
773
  });
386
774
  /**
387
775
  * Rule-based tips engine. Each rule is a SQL query that finds a wasteful
@@ -19,6 +19,15 @@ async function resolveCg(app, req, reply) {
19
19
  }
20
20
  return cg;
21
21
  }
22
+ /** Dig the raw SQLite handle off a SpecShip instance (same shape as routes/claude.ts). */
23
+ function getDb(cg) {
24
+ const anyCg = cg;
25
+ if (anyCg.db?.getDb)
26
+ return anyCg.db.getDb();
27
+ if (anyCg.queries?.db)
28
+ return anyCg.queries.db;
29
+ throw new Error('specship DB handle not accessible from server context');
30
+ }
22
31
  export async function registerGraphRoutes(app) {
23
32
  app.get('/api/graph/stats', async (req, reply) => {
24
33
  const cg = await resolveCg(app, req, reply);
@@ -146,4 +155,57 @@ export async function registerGraphRoutes(app) {
146
155
  return;
147
156
  return { files: cg.getFiles() };
148
157
  });
158
+ /**
159
+ * GET /api/graph/health — feeds the Graph overview panel:
160
+ * - linkHealth: spec-link counts per state (verified/drifted/broken/orphaned/…)
161
+ * - edgeKinds: edge counts grouped into calls / implements-documents / tests
162
+ * - hubs: the most-connected nodes (by total degree), for the "Most connected" list
163
+ */
164
+ app.get('/api/graph/health', async (req, reply) => {
165
+ const cg = await resolveCg(app, req, reply);
166
+ if (!cg)
167
+ return;
168
+ // Spec-link health — count by state off the spec_links table.
169
+ const linkHealth = {};
170
+ for (const l of cg.getSpecQueries().getAllLinks()) {
171
+ linkHealth[l.state] = (linkHealth[l.state] ?? 0) + 1;
172
+ }
173
+ const db = getDb(cg);
174
+ // Edge kinds — bucket by the node kinds the edge connects, mirroring the
175
+ // design's "calls / implements / tests" legend. spec endpoints → implements,
176
+ // test endpoints → tests, everything else → calls.
177
+ const edgeKinds = db.prepare(`
178
+ SELECT
179
+ CASE
180
+ WHEN ns.kind = 'spec' OR nt.kind = 'spec' THEN 'implements'
181
+ WHEN ns.kind = 'test' OR nt.kind = 'test' THEN 'tests'
182
+ ELSE 'calls'
183
+ END as bucket,
184
+ COUNT(*) as count
185
+ FROM edges e
186
+ JOIN nodes ns ON ns.id = e.source
187
+ JOIN nodes nt ON nt.id = e.target
188
+ GROUP BY bucket
189
+ `).all();
190
+ const edgeKindMap = { calls: 0, implements: 0, tests: 0 };
191
+ for (const r of edgeKinds)
192
+ edgeKindMap[r.bucket] = r.count;
193
+ // Synthesized (heuristic) edge count — dashed in the legend.
194
+ const synth = db.prepare(`SELECT COUNT(*) as c FROM edges WHERE provenance = 'heuristic'`).get();
195
+ edgeKindMap['synth'] = synth.c ?? 0;
196
+ // Most-connected hubs — total degree (in + out) per node, top 8.
197
+ const hubs = db.prepare(`
198
+ SELECT n.id, n.name, n.kind, n.file_path as filePath, deg.degree
199
+ FROM (
200
+ SELECT node, COUNT(*) as degree FROM (
201
+ SELECT source as node FROM edges
202
+ UNION ALL
203
+ SELECT target as node FROM edges
204
+ ) GROUP BY node ORDER BY degree DESC LIMIT 8
205
+ ) deg
206
+ JOIN nodes n ON n.id = deg.node
207
+ ORDER BY deg.degree DESC
208
+ `).all();
209
+ return { linkHealth, edgeKinds: edgeKindMap, hubs };
210
+ });
149
211
  }