@specship/specship-win32-arm64 0.12.0 → 0.13.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 (85) hide show
  1. package/lib/dist/bin/specship.js +48 -12
  2. package/lib/dist/bin/specship.js.map +1 -1
  3. package/lib/dist/db/migrations.d.ts +1 -1
  4. package/lib/dist/db/migrations.d.ts.map +1 -1
  5. package/lib/dist/db/migrations.js +16 -1
  6. package/lib/dist/db/migrations.js.map +1 -1
  7. package/lib/dist/db/schema.sql +10 -0
  8. package/lib/dist/db/spec-queries.d.ts +6 -0
  9. package/lib/dist/db/spec-queries.d.ts.map +1 -1
  10. package/lib/dist/db/spec-queries.js +8 -0
  11. package/lib/dist/db/spec-queries.js.map +1 -1
  12. package/lib/dist/index.d.ts +8 -0
  13. package/lib/dist/index.d.ts.map +1 -1
  14. package/lib/dist/index.js +9 -0
  15. package/lib/dist/index.js.map +1 -1
  16. package/lib/dist/server/cli.js +9 -7
  17. package/lib/dist/server/product-version.js +44 -0
  18. package/lib/dist/server/routes/claude.js +153 -42
  19. package/lib/dist/server/routes/config.js +35 -0
  20. package/lib/dist/server/routes/graph.js +22 -1
  21. package/lib/dist/server/routes/mcp.js +510 -0
  22. package/lib/dist/server/routes/projects.js +31 -3
  23. package/lib/dist/server/routes/status.js +3 -0
  24. package/lib/dist/server/routes/workflow.js +44 -3
  25. package/lib/dist/server/server-config.js +38 -0
  26. package/lib/dist/server/server.js +128 -58
  27. package/lib/dist/statusline/active-run.d.ts +2 -2
  28. package/lib/dist/statusline/active-run.d.ts.map +1 -1
  29. package/lib/dist/statusline/active-run.js +2 -2
  30. package/lib/dist/statusline/active-run.js.map +1 -1
  31. package/lib/dist/statusline/render.d.ts.map +1 -1
  32. package/lib/dist/statusline/render.js +41 -5
  33. package/lib/dist/statusline/render.js.map +1 -1
  34. package/lib/dist/statusline/types.d.ts +15 -0
  35. package/lib/dist/statusline/types.d.ts.map +1 -1
  36. package/lib/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  37. package/lib/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  38. package/lib/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  39. package/lib/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  40. package/lib/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  41. package/lib/dist/ui/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  42. package/lib/dist/ui/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  43. package/lib/dist/ui/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  44. package/lib/dist/ui/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  45. package/lib/dist/ui/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  46. package/lib/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/lib/dist/ui/assets/index-D8e4LiVb.js +51 -0
  48. package/lib/dist/ui/assets/index-ezwX2EfG.css +1 -0
  49. package/lib/dist/ui/favicon-16.png +0 -0
  50. package/lib/dist/ui/favicon-180.png +0 -0
  51. package/lib/dist/ui/favicon-32.png +0 -0
  52. package/lib/dist/ui/favicon.svg +20 -0
  53. package/lib/dist/ui/index.html +17 -0
  54. package/lib/dist/update/resolve-latest.d.ts +14 -0
  55. package/lib/dist/update/resolve-latest.d.ts.map +1 -0
  56. package/lib/dist/update/resolve-latest.js +29 -0
  57. package/lib/dist/update/resolve-latest.js.map +1 -0
  58. package/lib/dist/update/run-installer.d.ts +13 -0
  59. package/lib/dist/update/run-installer.d.ts.map +1 -0
  60. package/lib/dist/update/run-installer.js +43 -0
  61. package/lib/dist/update/run-installer.js.map +1 -0
  62. package/lib/dist/update/updater.d.ts +53 -0
  63. package/lib/dist/update/updater.d.ts.map +1 -0
  64. package/lib/dist/update/updater.js +164 -0
  65. package/lib/dist/update/updater.js.map +1 -0
  66. package/lib/dist/workflows/eta.d.ts +42 -0
  67. package/lib/dist/workflows/eta.d.ts.map +1 -0
  68. package/lib/dist/workflows/eta.js +146 -0
  69. package/lib/dist/workflows/eta.js.map +1 -0
  70. package/lib/dist/workflows/executor.d.ts.map +1 -1
  71. package/lib/dist/workflows/executor.js +25 -6
  72. package/lib/dist/workflows/executor.js.map +1 -1
  73. package/lib/dist/workflows/runners/prompt.d.ts +6 -0
  74. package/lib/dist/workflows/runners/prompt.d.ts.map +1 -1
  75. package/lib/dist/workflows/runners/prompt.js +10 -0
  76. package/lib/dist/workflows/runners/prompt.js.map +1 -1
  77. package/lib/dist/workflows/runners/types.d.ts +12 -0
  78. package/lib/dist/workflows/runners/types.d.ts.map +1 -1
  79. package/lib/node_modules/.package-lock.json +1 -1
  80. package/lib/package.json +3 -2
  81. package/package.json +1 -1
  82. package/lib/dist/server/ssr/md.mjs +0 -83
  83. package/lib/dist/server/ssr/public/app.css +0 -1
  84. package/lib/dist/server/ssr/render.mjs +0 -564
  85. package/lib/dist/server/ssr/routes.js +0 -73
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * Endpoints:
9
9
  * GET /api/claude/projects — every indexed Claude project
10
- * GET /api/claude/sessions?project=&limit= — sessions list
10
+ * GET /api/claude/sessions?project=&limit=&range=&model= — sessions list
11
+ * GET /api/claude/prompts/recent?project=&limit= — newest user prompts (⌘K palette)
11
12
  * GET /api/claude/session/:id — session detail (prompts + tools)
12
- * GET /api/claude/heatmap?range= — file/tool/subagent heatmaps
13
- * GET /api/claude/costs?range= — cost rollup, timeseries, per-model
13
+ * GET /api/claude/heatmap?range=&project= — file/tool/subagent heatmaps
14
+ * GET /api/claude/costs?range=&project=&model= — cost rollup, timeseries, per-model
14
15
  * GET /api/claude/compare — per-project cost comparison
15
- * GET /api/claude/tips — rule-based tips engine output
16
+ * GET /api/claude/tips — rule-based tips engine output (minus dismissed)
17
+ * POST /api/claude/tips/state — persist a tip's Apply/Dismiss state
16
18
  * GET /api/claude/specship-impact?range=&project= — SpecShip token-impact aggregation
17
19
  * POST /api/claude/ingest — force a one-shot ingest pass
18
20
  */
@@ -94,6 +96,7 @@ function getDb(cg) {
94
96
  return anyCg.queries.db;
95
97
  throw new Error('specship DB handle not accessible from server context');
96
98
  }
99
+ // @implements REQ-DESKTOP-019 (GET /api/claude/prompts/recent — ⌘K palette prompts source)
97
100
  export async function registerClaudeRoutes(app) {
98
101
  /**
99
102
  * Analytics routes share one SQLite — the boot-time "primary" project's
@@ -135,20 +138,54 @@ export async function registerClaudeRoutes(app) {
135
138
  const limit = Math.min(parseInt(req.query.limit ?? '100', 10) || 100, 500);
136
139
  const since = rangeStart(rangeKey(req.query.range));
137
140
  const params = [since];
138
- let whereProject = '';
141
+ let where = '';
139
142
  if (req.query.project) {
140
- whereProject = ' AND project_path = ?';
143
+ where += ' AND project_path = ?';
141
144
  params.push(normalizeProjectFilter(req.query.project));
142
145
  }
146
+ if (req.query.model) {
147
+ where += ' AND last_model = ?';
148
+ params.push(req.query.model);
149
+ }
143
150
  params.push(limit);
144
151
  const sessions = db.prepare(`
145
152
  SELECT * FROM claude_sessions
146
- WHERE started_at >= ?${whereProject}
153
+ WHERE started_at >= ?${where}
147
154
  ORDER BY started_at DESC
148
155
  LIMIT ?
149
156
  `).all(...params);
150
157
  return { sessions };
151
158
  });
159
+ /**
160
+ * Newest user prompts across sessions — feeds the ⌘K command palette's
161
+ * "recent prompts" results (REQ-DESKTOP-019). Main-chain prompts with
162
+ * non-empty text only: sidechain rows are subagent task bodies, and blank
163
+ * rows would render as empty palette entries. Text is capped at 200 chars —
164
+ * the palette shows a one-line label, never the full prompt.
165
+ */
166
+ app.get('/api/claude/prompts/recent', async (req, reply) => {
167
+ const cg = requirePrimary(reply);
168
+ if (!cg)
169
+ return;
170
+ const db = getDb(cg);
171
+ const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
172
+ const params = [];
173
+ let whereProject = '';
174
+ if (req.query.project) {
175
+ whereProject = ' AND s.project_path = ?';
176
+ params.push(normalizeProjectFilter(req.query.project));
177
+ }
178
+ params.push(limit);
179
+ const prompts = db.prepare(`
180
+ SELECT p.id, p.session_id, substr(p.text, 1, 200) as text, p.ts
181
+ FROM claude_prompts p
182
+ JOIN claude_sessions s ON s.id = p.session_id
183
+ WHERE p.is_sidechain = 0 AND p.text IS NOT NULL AND TRIM(p.text) != ''${whereProject}
184
+ ORDER BY p.ts DESC
185
+ LIMIT ?
186
+ `).all(...params);
187
+ return { prompts };
188
+ });
152
189
  app.get('/api/claude/session/:id', async (req, reply) => {
153
190
  const cg = requirePrimary(reply);
154
191
  if (!cg)
@@ -392,15 +429,20 @@ export async function registerClaudeRoutes(app) {
392
429
  return;
393
430
  const db = getDb(cg);
394
431
  const since = rangeStart(rangeKey(req.query.range));
432
+ // Optional project narrowing (REQ-DESKTOP-024.A2) — tool calls and prompts
433
+ // carry only session_id, so scope through claude_sessions.project_path.
434
+ const project = req.query.project ? normalizeProjectFilter(req.query.project) : undefined;
435
+ const inProject = (col = '') => project ? ` AND ${col}session_id IN (SELECT id FROM claude_sessions WHERE project_path = ?)` : '';
436
+ const withProject = (...lead) => (project ? [...lead, project] : lead);
395
437
  // Files heatmap — input_summary doubles as the file path for Read/Edit/Write.
396
438
  const files = db.prepare(`
397
439
  SELECT input_summary as path, COUNT(*) as calls, SUM(result_length) as resultBytes
398
440
  FROM claude_tool_calls
399
- WHERE ts >= ? AND tool_name IN ('Read','Edit','Write','NotebookEdit') AND input_summary != ''
441
+ WHERE ts >= ? AND tool_name IN ('Read','Edit','Write','NotebookEdit') AND input_summary != ''${inProject()}
400
442
  GROUP BY input_summary
401
443
  ORDER BY calls DESC
402
444
  LIMIT 100
403
- `).all(since);
445
+ `).all(...withProject(since));
404
446
  // Per-file 7-day call trend — one sparkline (oldest→newest) per file cell.
405
447
  // Always a fixed 7-day window, independent of `range`, so the trend reads
406
448
  // consistently regardless of the heatmap's selected window.
@@ -409,9 +451,9 @@ export async function registerClaudeRoutes(app) {
409
451
  const trendRows = db.prepare(`
410
452
  SELECT input_summary as path, CAST((ts - ?) / ? AS INTEGER) as bucket, COUNT(*) as calls
411
453
  FROM claude_tool_calls
412
- WHERE ts >= ? AND tool_name IN ('Read','Edit','Write','NotebookEdit') AND input_summary != ''
454
+ WHERE ts >= ? AND tool_name IN ('Read','Edit','Write','NotebookEdit') AND input_summary != ''${inProject()}
413
455
  GROUP BY input_summary, bucket
414
- `).all(trend7Start, dayMs, trend7Start);
456
+ `).all(...withProject(trend7Start, dayMs, trend7Start));
415
457
  const trendByPath = new Map();
416
458
  for (const r of trendRows) {
417
459
  if (r.bucket < 0 || r.bucket > 6)
@@ -427,10 +469,10 @@ export async function registerClaudeRoutes(app) {
427
469
  const tools = db.prepare(`
428
470
  SELECT tool_name as name, COUNT(*) as calls, SUM(result_length) as resultBytes
429
471
  FROM claude_tool_calls
430
- WHERE ts >= ?
472
+ WHERE ts >= ?${inProject()}
431
473
  GROUP BY tool_name
432
474
  ORDER BY calls DESC
433
- `).all(since);
475
+ `).all(...withProject(since));
434
476
  // Subagent attribution (is_sidechain at the prompt level — main vs. sidechain rollup).
435
477
  const subagents = db.prepare(`
436
478
  SELECT
@@ -439,9 +481,9 @@ export async function registerClaudeRoutes(app) {
439
481
  SUM(p.input_tokens + p.output_tokens + p.cache_creation_tokens + p.cache_read_tokens) as tokens,
440
482
  SUM(p.cost_usd) as cost
441
483
  FROM claude_prompts p
442
- WHERE p.ts >= ?
484
+ WHERE p.ts >= ?${inProject('p.')}
443
485
  GROUP BY type
444
- `).all(since);
486
+ `).all(...withProject(since));
445
487
  // Subagent breakdown by name — Task tool calls grouped by subagent_type
446
488
  // (defaults to 'general-purpose' when unset). Guarded extraction: Task
447
489
  // inputs carry long prompts, so input_summary is almost always truncated.
@@ -452,10 +494,10 @@ export async function registerClaudeRoutes(app) {
452
494
  MIN(ts) as firstSeen,
453
495
  MAX(ts) as lastSeen
454
496
  FROM claude_tool_calls
455
- WHERE ts >= ? AND tool_name = 'Task'
497
+ WHERE ts >= ? AND tool_name = 'Task'${inProject()}
456
498
  GROUP BY name
457
499
  ORDER BY calls DESC
458
- `).all(since);
500
+ `).all(...withProject(since));
459
501
  return { files, tools, subagents, subagentByName };
460
502
  });
461
503
  /**
@@ -627,40 +669,72 @@ export async function registerClaudeRoutes(app) {
627
669
  return;
628
670
  const db = getDb(cg);
629
671
  const since = rangeStart(rangeKey(req.query.range));
630
- const total = db.prepare(`SELECT SUM(total_cost_usd) as t FROM claude_sessions WHERE started_at >= ?`).get(since);
672
+ // Optional narrowing (REQ-DESKTOP-024.A2). Both filters live at the prompt
673
+ // level (project via the owning session), so every prompt query below
674
+ // shares one fragment; the sessions join is only added when needed.
675
+ const project = req.query.project ? normalizeProjectFilter(req.query.project) : undefined;
676
+ const model = req.query.model || undefined;
677
+ const filtered = Boolean(project || model);
678
+ const pJoin = project ? ' JOIN claude_sessions s ON s.id = p.session_id' : '';
679
+ const pFilter = (project ? ' AND s.project_path = ?' : '') + (model ? ' AND p.model = ?' : '');
680
+ const pParams = (...lead) => {
681
+ const out = [...lead];
682
+ if (project)
683
+ out.push(project);
684
+ if (model)
685
+ out.push(model);
686
+ return out;
687
+ };
688
+ // Total spend in the window. Unfiltered keeps the session-level rollup
689
+ // (matches the dashboard's historical numbers, which include prompts with
690
+ // a NULL model); filtered totals must come from the same prompt rows the
691
+ // filters apply to, or the headline wouldn't narrow with the breakdowns.
692
+ const totalOf = (start, end) => {
693
+ if (!filtered) {
694
+ const where = end == null ? 'started_at >= ?' : 'started_at >= ? AND started_at < ?';
695
+ const row = db.prepare(`SELECT SUM(total_cost_usd) as t FROM claude_sessions WHERE ${where}`)
696
+ .get(...(end == null ? [start] : [start, end]));
697
+ return row.t ?? 0;
698
+ }
699
+ const where = end == null ? 'p.ts >= ?' : 'p.ts >= ? AND p.ts < ?';
700
+ const row = db.prepare(`SELECT SUM(p.cost_usd) as t FROM claude_prompts p${pJoin} WHERE ${where}${pFilter}`)
701
+ .get(...pParams(...(end == null ? [start] : [start, end])));
702
+ return row.t ?? 0;
703
+ };
704
+ const total = totalOf(since, null);
631
705
  // Week-over-week spend delta: fractional change in total cost vs the prior
632
706
  // equal-length window (e.g. -0.08 → "-8%"). Zero for 'all' or no prior data.
633
707
  const prior = priorWindow(rangeKey(req.query.range));
634
708
  let wowDelta = 0;
635
709
  if (prior.end > prior.start) {
636
- 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);
637
- const pt = pTotal.t ?? 0;
710
+ const pt = totalOf(prior.start, prior.end);
638
711
  if (pt > 0)
639
- wowDelta = ((total.t ?? 0) - pt) / pt;
712
+ wowDelta = (total - pt) / pt;
640
713
  }
641
- // Top prompts by cost.
714
+ // Top prompts by cost. is_sidechain rides along so the dashboard's
715
+ // Recent-prompts module can badge subagent turns (REQ-DESKTOP-020).
642
716
  const topPrompts = db.prepare(`
643
- SELECT id, session_id, substr(text, 1, 200) as text, model, cost_usd,
644
- input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, ts
645
- FROM claude_prompts
646
- WHERE ts >= ? AND cost_usd > 0
647
- ORDER BY cost_usd DESC
717
+ SELECT p.id, p.session_id, substr(p.text, 1, 200) as text, p.model, p.cost_usd, p.is_sidechain,
718
+ p.input_tokens, p.output_tokens, p.cache_creation_tokens, p.cache_read_tokens, p.ts
719
+ FROM claude_prompts p${pJoin}
720
+ WHERE p.ts >= ? AND p.cost_usd > 0${pFilter}
721
+ ORDER BY p.cost_usd DESC
648
722
  LIMIT 50
649
- `).all(since);
723
+ `).all(...pParams(since));
650
724
  // Daily timeseries: bucket by 24h windows.
651
725
  const days = 30;
652
726
  const dayMs = 24 * 60 * 60 * 1000;
653
727
  const dayBoundary = Math.floor(Date.now() / dayMs) * dayMs - (days - 1) * dayMs;
654
728
  const series = db.prepare(`
655
729
  SELECT
656
- CAST((ts - ?) / ? AS INTEGER) as bucket,
657
- SUM(cost_usd) as cost,
730
+ CAST((p.ts - ?) / ? AS INTEGER) as bucket,
731
+ SUM(p.cost_usd) as cost,
658
732
  COUNT(*) as prompts
659
- FROM claude_prompts
660
- WHERE ts >= ?
733
+ FROM claude_prompts p${pJoin}
734
+ WHERE p.ts >= ?${pFilter}
661
735
  GROUP BY bucket
662
736
  ORDER BY bucket ASC
663
- `).all(dayBoundary, dayMs, dayBoundary);
737
+ `).all(...pParams(dayBoundary, dayMs, dayBoundary));
664
738
  // Densify with zeros for missing days.
665
739
  const dense = [];
666
740
  for (let i = 0; i < days; i++) {
@@ -669,13 +743,13 @@ export async function registerClaudeRoutes(app) {
669
743
  }
670
744
  // By model.
671
745
  const byModel = db.prepare(`
672
- SELECT model, COUNT(*) as prompts, SUM(cost_usd) as cost
673
- FROM claude_prompts
674
- WHERE ts >= ? AND model IS NOT NULL
675
- GROUP BY model
746
+ SELECT p.model, COUNT(*) as prompts, SUM(p.cost_usd) as cost
747
+ FROM claude_prompts p${pJoin}
748
+ WHERE p.ts >= ? AND p.model IS NOT NULL${pFilter}
749
+ GROUP BY p.model
676
750
  ORDER BY cost DESC
677
- `).all(since);
678
- return { total: total.t ?? 0, topPrompts, series: dense, byModel, wowDelta };
751
+ `).all(...pParams(since));
752
+ return { total, topPrompts, series: dense, byModel, wowDelta };
679
753
  });
680
754
  /**
681
755
  * GET /api/claude/stats?range= — the four dashboard stat tiles, each with a
@@ -742,7 +816,11 @@ export async function registerClaudeRoutes(app) {
742
816
  // --- Drift (live graph count, no time series) ---
743
817
  const driftCount = cg.getSpecQueries().getLinksByState(['drifted', 'broken', 'orphaned']).length;
744
818
  const drift = { value: driftCount, delta: 0, series: [] };
745
- return { lastSessionCost, toolCalls, subagentPct, drift };
819
+ // Total ingested sessions (all time, unfiltered). Zero means "nothing
820
+ // ingested yet" — the dashboard renders enable-ingest guidance instead of
821
+ // presenting $0 / 0-call zeros as truth (REQ-DESKTOP-020.A4).
822
+ const sessionCount = countSince(`SELECT COUNT(*) c FROM claude_sessions`);
823
+ return { lastSessionCost, toolCalls, subagentPct, drift, sessionCount };
746
824
  });
747
825
  app.get('/api/claude/compare', async (_req, reply) => {
748
826
  const cg = requirePrimary(reply);
@@ -898,7 +976,40 @@ export async function registerClaudeRoutes(app) {
898
976
  // Sort: errors before warns before info, then within bucket by saving heuristic.
899
977
  const order = { error: 0, warn: 1, info: 2 };
900
978
  tips.sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9));
901
- return { tips };
979
+ // Apply persisted tip state (REQ-DESKTOP-020.A2): dismissed tips never
980
+ // resurface even though the rule re-mines the pattern; applied ones stay
981
+ // in the list annotated so the UI can render their applied treatment.
982
+ const states = db.prepare(`SELECT tip_id, state FROM claude_tip_state`).all();
983
+ const stateById = new Map(states.map((s) => [s.tip_id, s.state]));
984
+ const visible = tips.filter((t) => stateById.get(t.id) !== 'dismissed');
985
+ for (const t of visible) {
986
+ if (stateById.get(t.id) === 'applied')
987
+ t.state = 'applied';
988
+ }
989
+ return { tips: visible };
990
+ });
991
+ /**
992
+ * Persist a tip's Apply / Dismiss state (REQ-DESKTOP-020.A2). Tip ids embed
993
+ * `/` and `:` (rule + session + file), so the id travels in the JSON body
994
+ * rather than a path param. Upsert: re-applying or re-dismissing converges
995
+ * to one row per tip.
996
+ */
997
+ app.post('/api/claude/tips/state', async (req, reply) => {
998
+ const cg = requirePrimary(reply);
999
+ if (!cg)
1000
+ return;
1001
+ const { id, state } = req.body ?? {};
1002
+ if (!id || typeof id !== 'string')
1003
+ return reply.code(400).send({ error: 'id required' });
1004
+ if (state !== 'applied' && state !== 'dismissed') {
1005
+ return reply.code(400).send({ error: "state must be 'applied' or 'dismissed'" });
1006
+ }
1007
+ const db = getDb(cg);
1008
+ db.prepare(`
1009
+ INSERT INTO claude_tip_state (tip_id, state, ts) VALUES (?, ?, ?)
1010
+ ON CONFLICT(tip_id) DO UPDATE SET state = excluded.state, ts = excluded.ts
1011
+ `).run(id, state, Date.now());
1012
+ return { ok: true, id, state };
902
1013
  });
903
1014
  /**
904
1015
  * GET /api/claude/specship-impact?range=&project=
@@ -906,7 +1017,7 @@ export async function registerClaudeRoutes(app) {
906
1017
  * Aggregate SpecShip token-impact metrics: how many tokens specship calls
907
1018
  * spent vs. how many tokens' worth of Read calls they displaced (saved),
908
1019
  * with per-prompt dedup so a file referenced by two specship calls in the
909
- * same prompt counts only once. See packages/server/src/ingest/impact-query.ts
1020
+ * same prompt counts only once. See server/src/ingest/impact-query.ts
910
1021
  * for the full algorithm.
911
1022
  *
912
1023
  * Query params:
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GET/PUT /api/config — the dashboard-visible server runtime configuration
3
+ * (REQ-DESKTOP-028.A2/.A3). Currently one knob: the Claude Code transcript
4
+ * ingest toggle, persisted to `~/.specship/server-config.json` so it
5
+ * survives restarts, plus the real product version for the About section.
6
+ *
7
+ * PUT flips the live watcher through `app.ingestControl` (server.ts): off
8
+ * stops the JSONL watcher immediately, on starts it (when a primary project
9
+ * with a DB handle exists — otherwise the intent is recorded and honored on
10
+ * the next boot with a primary).
11
+ */
12
+ import { productVersion } from '../product-version.js';
13
+ import { writeServerConfig } from '../server-config.js';
14
+ export async function registerConfigRoutes(app) {
15
+ app.get('/api/config', async () => ({
16
+ ingestEnabled: app.ingestControl.enabled(),
17
+ version: productVersion(),
18
+ }));
19
+ app.put('/api/config', async (req, reply) => {
20
+ const body = (req.body ?? {});
21
+ if (typeof body.ingestEnabled !== 'boolean') {
22
+ return reply.code(400).send({ error: 'ingestEnabled must be a boolean', code: 'bad_request' });
23
+ }
24
+ writeServerConfig({ ingestEnabled: body.ingestEnabled });
25
+ if (body.ingestEnabled)
26
+ await app.ingestControl.start();
27
+ else
28
+ app.ingestControl.stop();
29
+ return {
30
+ ok: true,
31
+ ingestEnabled: app.ingestControl.enabled(),
32
+ version: productVersion(),
33
+ };
34
+ });
35
+ }
@@ -160,6 +160,7 @@ export async function registerGraphRoutes(app) {
160
160
  * - linkHealth: spec-link counts per state (verified/drifted/broken/orphaned/…)
161
161
  * - edgeKinds: edge counts grouped into calls / implements-documents / tests
162
162
  * - hubs: the most-connected nodes (by total degree), for the "Most connected" list
163
+ * - anchored: nodes that are spec_links targets (by degree), for the "Anchored" list
163
164
  */
164
165
  app.get('/api/graph/health', async (req, reply) => {
165
166
  const cg = await resolveCg(app, req, reply);
@@ -206,7 +207,27 @@ export async function registerGraphRoutes(app) {
206
207
  JOIN nodes n ON n.id = deg.node
207
208
  ORDER BY deg.degree DESC
208
209
  `).all();
209
- return { linkHealth, edgeKinds: edgeKindMap, hubs };
210
+ // Anchored nodes — code that specs point at (resolved spec_links targets),
211
+ // most-connected first. Degree is LEFT-joined: an anchored leaf with no
212
+ // edges still shows up, at degree 0.
213
+ const anchored = db.prepare(`
214
+ SELECT n.id, n.name, n.kind, n.file_path as filePath, COALESCE(deg.degree, 0) as degree
215
+ FROM (
216
+ SELECT DISTINCT resolved_node_id AS node FROM spec_links
217
+ WHERE resolved_node_id IS NOT NULL
218
+ ) sl
219
+ JOIN nodes n ON n.id = sl.node
220
+ LEFT JOIN (
221
+ SELECT node, COUNT(*) as degree FROM (
222
+ SELECT source as node FROM edges
223
+ UNION ALL
224
+ SELECT target as node FROM edges
225
+ ) GROUP BY node
226
+ ) deg ON deg.node = n.id
227
+ ORDER BY degree DESC
228
+ LIMIT 8
229
+ `).all();
230
+ return { linkHealth, edgeKinds: edgeKindMap, hubs, anchored };
210
231
  });
211
232
  /**
212
233
  * Full-graph overview for the Graph page's whole-repo view. The graph can be