@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.
- package/README.md +2 -2
- package/commands/ss-design-implement.md +79 -0
- package/commands/{cg-drifted.md → ss-drifted.md} +2 -2
- package/commands/{cg-fix.md → ss-fix.md} +1 -1
- package/commands/{cg-implement.md → ss-implement.md} +1 -1
- package/commands/ss-spec-author.md +43 -0
- package/commands/ss-spec-review.md +48 -0
- package/dist/bin/specship.js +6 -5
- package/dist/bin/specship.js.map +1 -1
- package/dist/db/index.d.ts +3 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +5 -2
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +36 -1
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/schema.sql +9 -0
- package/dist/directory.d.ts +13 -3
- package/dist/directory.d.ts.map +1 -1
- package/dist/directory.js +17 -3
- package/dist/directory.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +16 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/targets/claude.d.ts +11 -1
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +59 -2
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/server/ingest/ingestor.js +117 -17
- package/dist/server/routes/claude.js +394 -6
- package/dist/server/routes/graph.js +62 -0
- package/dist/server/routes/spec.js +161 -1
- package/dist/server/routes/status.js +77 -0
- package/dist/server/routes/workflow.js +31 -3
- package/dist/web/chunk-2AJCHB7P.js +1 -0
- package/dist/web/chunk-2CPLUFCH.js +2 -0
- package/dist/web/chunk-2DHIGIOI.js +1 -0
- package/dist/web/chunk-2GBEK2GM.js +1 -0
- package/dist/web/chunk-2I7L37NS.js +1 -0
- package/dist/web/chunk-2NAWAJB5.js +1 -0
- package/dist/web/chunk-2OJBIPE4.js +1 -0
- package/dist/web/chunk-2OKMB4KX.js +2 -0
- package/dist/web/chunk-3E2WB6D5.js +1 -0
- package/dist/web/chunk-3EBFYSCH.js +2 -0
- package/dist/web/chunk-3QCQ4BXS.js +1 -0
- package/dist/web/chunk-3SEJX2BK.js +1 -0
- package/dist/web/chunk-42XVAQ6I.js +1 -0
- package/dist/web/chunk-4IMMPEYM.js +1 -0
- package/dist/web/chunk-4N5DWG46.js +1 -0
- package/dist/web/chunk-4TJQJPCZ.js +1 -0
- package/dist/web/chunk-4WZIHTPC.js +1 -0
- package/dist/web/chunk-4YVSYOSD.js +1 -0
- package/dist/web/chunk-5BQIOYKW.js +1 -0
- package/dist/web/chunk-5HGWHUJA.js +1 -0
- package/dist/web/chunk-5Y244R4G.js +1 -0
- package/dist/web/chunk-6RRDPT5Z.js +1 -0
- package/dist/web/chunk-6VKB2ZWM.js +1 -0
- package/dist/web/chunk-7DMFVTU4.js +1 -0
- package/dist/web/chunk-7RNS77UP.js +1 -0
- package/dist/web/chunk-7SMPKVEP.js +1 -0
- package/dist/web/chunk-AZJVTPLU.js +1 -0
- package/dist/web/chunk-BCZM5AXU.js +1 -0
- package/dist/web/chunk-BLBRMCN2.js +1 -0
- package/dist/web/chunk-BMIAXD2V.js +2 -0
- package/dist/web/chunk-BPECIDVO.js +1 -0
- package/dist/web/chunk-BUXWEHIY.js +1 -0
- package/dist/web/chunk-BYZFQSM6.js +1 -0
- package/dist/web/chunk-DA6SNNAF.js +1 -0
- package/dist/web/chunk-DLQPZWSI.css +1 -0
- package/dist/web/chunk-DTRN7FZR.js +1 -0
- package/dist/web/chunk-DYRFLPJA.js +1 -0
- package/dist/web/chunk-E3J3CXR5.js +1 -0
- package/dist/web/chunk-E44X4RH2.js +1 -0
- package/dist/web/chunk-E73OX2P7.js +1 -0
- package/dist/web/chunk-EAXRKDLV.js +1 -0
- package/dist/web/chunk-EBKKDHYI.js +1 -0
- package/dist/web/chunk-EE7V7Q5P.js +1 -0
- package/dist/web/chunk-EKY2FUHU.js +1 -0
- package/dist/web/chunk-EMGMOEVR.js +1 -0
- package/dist/web/chunk-EP6XOPXH.js +1 -0
- package/dist/web/chunk-ESGDLJOJ.js +1 -0
- package/dist/web/chunk-ETJG7NCY.js +1 -0
- package/dist/web/chunk-EUUEFEDI.js +1 -0
- package/dist/web/chunk-FGNZDHTL.js +11 -0
- package/dist/web/chunk-FIJW2UNJ.js +1 -0
- package/dist/web/chunk-FMV5PXRC.js +5 -0
- package/dist/web/chunk-G7VZT5KB.js +3 -0
- package/dist/web/chunk-GRZYXPSO.js +7 -0
- package/dist/web/chunk-GYGPS3AN.js +1 -0
- package/dist/web/chunk-H7AF7YS4.js +1 -0
- package/dist/web/chunk-HDZDQILN.js +1 -0
- package/dist/web/chunk-HMK6UO6N.js +1 -0
- package/dist/web/chunk-HZA6NEAB.js +1 -0
- package/dist/web/chunk-IHEE5NYJ.js +1 -0
- package/dist/web/chunk-ISNEBICW.js +1 -0
- package/dist/web/chunk-J2GZVLHH.js +1 -0
- package/dist/web/chunk-JFYVCXK3.js +1 -0
- package/dist/web/chunk-JN6W7HCN.js +17 -0
- package/dist/web/chunk-JT7P3DEK.js +6 -0
- package/dist/web/chunk-JTFXTIPE.js +903 -0
- package/dist/web/chunk-L37MTFSG.js +3 -0
- package/dist/web/chunk-LB6JPLX2.js +1 -0
- package/dist/web/chunk-LNSVDHCI.js +1 -0
- package/dist/web/chunk-LV4G6QFG.js +2 -0
- package/dist/web/chunk-LVGIY3SO.js +1 -0
- package/dist/web/chunk-LXLHIHEN.js +1 -0
- package/dist/web/chunk-MC4DFIHG.js +1 -0
- package/dist/web/chunk-MVOMVPYB.js +1 -0
- package/dist/web/chunk-N6SS4G6S.js +1 -0
- package/dist/web/chunk-NTBJG6SJ.js +1 -0
- package/dist/web/chunk-NUDB3Q2Y.js +3 -0
- package/dist/web/chunk-OXEF5E3E.js +1 -0
- package/dist/web/chunk-PDN6QYGJ.js +4 -0
- package/dist/web/chunk-PGGJPDJG.js +1 -0
- package/dist/web/chunk-PUYSJNJR.js +1 -0
- package/dist/web/chunk-Q2RVFS45.js +1 -0
- package/dist/web/chunk-Q7L6LLAK.js +1 -0
- package/dist/web/chunk-QCMKJIWY.js +1 -0
- package/dist/web/chunk-QH6CF3M3.js +1 -0
- package/dist/web/chunk-QQ5LD7PI.js +1 -0
- package/dist/web/chunk-QR6L3KAC.js +1 -0
- package/dist/web/chunk-R2DLK4HO.js +1 -0
- package/dist/web/chunk-R5W2MDZN.js +1 -0
- package/dist/web/chunk-RAAMPHPJ.js +1 -0
- package/dist/web/chunk-RD6TVPOT.js +1 -0
- package/dist/web/chunk-RKY4EJYJ.js +1 -0
- package/dist/web/chunk-RONYWVY7.js +1 -0
- package/dist/web/chunk-RXKXYF2C.js +1 -0
- package/dist/web/chunk-SBWU7JFC.js +1 -0
- package/dist/web/chunk-SEXBRGYK.js +1 -0
- package/dist/web/chunk-SHPTC4RL.js +1 -0
- package/dist/web/chunk-SUZYBYDW.js +1 -0
- package/dist/web/chunk-SWKJRNYY.js +1 -0
- package/dist/web/chunk-T66XVKGB.js +1 -0
- package/dist/web/chunk-T7AZ65JP.js +1 -0
- package/dist/web/chunk-TCZDVOHD.js +1 -0
- package/dist/web/chunk-TPTITA3V.js +1 -0
- package/dist/web/chunk-TR335633.js +1 -0
- package/dist/web/chunk-TWXZK6XM.js +1 -0
- package/dist/web/chunk-UR5KDXPX.js +1 -0
- package/dist/web/chunk-UR6O2GEH.js +1 -0
- package/dist/web/chunk-UTNMGWTP.js +1 -0
- package/dist/web/chunk-UYC52MBC.js +1 -0
- package/dist/web/chunk-VECWMHJP.js +1 -0
- package/dist/web/chunk-VUACT35R.js +3 -0
- package/dist/web/chunk-VZI7H4SZ.js +1 -0
- package/dist/web/chunk-WAI2JMZP.js +1 -0
- package/dist/web/chunk-WB6YHOD4.js +1 -0
- package/dist/web/chunk-WBT64AWV.js +1 -0
- package/dist/web/chunk-WDU3WICG.js +1 -0
- package/dist/web/chunk-WFXJIXZE.js +4 -0
- package/dist/web/chunk-WTGYRH3Z.js +298 -0
- package/dist/web/chunk-WXTCVDTP.js +1 -0
- package/dist/web/chunk-X2HTISHL.js +1 -0
- package/dist/web/chunk-XCDHWLVH.js +1 -0
- package/dist/web/chunk-Y3H6FFUZ.js +1 -0
- package/dist/web/chunk-Y4F5ULGJ.js +1 -0
- package/dist/web/chunk-YAWCRPHV.js +1 -0
- package/dist/web/chunk-YEGKAAEE.js +1 -0
- package/dist/web/chunk-YM2KU57F.js +1 -0
- package/dist/web/chunk-YRERBP6T.js +1 -0
- package/dist/web/chunk-ZLV4VCDG.js +3 -0
- package/dist/web/chunk-ZTVI5KFF.js +1 -0
- package/dist/web/index.html +3 -3
- package/dist/web/main-ESADRXN2.css +1 -0
- package/dist/web/main-WVI3YTDU.js +1 -0
- package/dist/web/media/codicon-LN6W7LCM.ttf +0 -0
- package/dist/web/styles-KSOPUVDA.css +1 -0
- package/dist/workflows/defaults/claude-design-implement.yaml +247 -0
- package/dist/workflows/defaults/spec-author.yaml +214 -0
- package/dist/workflows/executor.d.ts.map +1 -1
- package/dist/workflows/executor.js +4 -3
- package/dist/workflows/executor.js.map +1 -1
- package/package.json +8 -3
- package/scripts/offline-install.sh +19 -6
- package/scripts/sync-shim-version.mjs +64 -0
- package/dist/web/chunk-2YZXEHZ2.js +0 -1
- package/dist/web/chunk-3GIC555L.js +0 -18
- package/dist/web/chunk-3IIIGRMT.js +0 -1
- package/dist/web/chunk-47QYKLE5.js +0 -1
- package/dist/web/chunk-4LHBWWP7.js +0 -1
- package/dist/web/chunk-4OAZLD5W.js +0 -1
- package/dist/web/chunk-5OQKAJAE.js +0 -1
- package/dist/web/chunk-7B525GKQ.js +0 -1
- package/dist/web/chunk-BPDXCOOZ.js +0 -1
- package/dist/web/chunk-DT37HTZB.js +0 -1
- package/dist/web/chunk-EIMUHJND.js +0 -1
- package/dist/web/chunk-FTESTUEO.js +0 -1
- package/dist/web/chunk-GLJZV6MU.js +0 -1
- package/dist/web/chunk-I7LS67U5.js +0 -1
- package/dist/web/chunk-L4TVIPSR.js +0 -1
- package/dist/web/chunk-MASCULC2.js +0 -1
- package/dist/web/chunk-MW7ICSRM.js +0 -1
- package/dist/web/chunk-OI5VP2A3.js +0 -1
- package/dist/web/chunk-RA6EBF6I.js +0 -1
- package/dist/web/chunk-RP3WU5Y6.js +0 -1
- package/dist/web/chunk-RQDRMTXN.js +0 -1
- package/dist/web/chunk-TQMT6UDU.js +0 -1
- package/dist/web/chunk-U7IYOV7T.js +0 -1
- package/dist/web/chunk-UE227MWF.js +0 -1
- package/dist/web/chunk-WV573J4K.js +0 -1
- package/dist/web/chunk-WVCKOJZL.js +0 -4
- package/dist/web/chunk-XZKLVPHE.js +0 -1
- package/dist/web/chunk-ZABKKHJ3.js +0 -1
- package/dist/web/main-RI5CO5Z4.js +0 -1
- package/dist/web/styles-CYN7IKT4.css +0 -1
- /package/commands/{cg-explore.md → ss-explore.md} +0 -0
- /package/commands/{cg-impact.md → ss-impact.md} +0 -0
- /package/commands/{cg-relink.md → ss-relink.md} +0 -0
- /package/commands/{cg-spec.md → ss-spec.md} +0 -0
- /package/commands/{cg-sync.md → ss-sync.md} +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
}
|