@specship/specship-win32-arm64 0.23.0 → 0.25.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 (143) hide show
  1. package/lib/dist/activation/steering.d.ts +4 -4
  2. package/lib/dist/activation/steering.d.ts.map +1 -1
  3. package/lib/dist/activation/steering.js +6 -6
  4. package/lib/dist/activation/steering.js.map +1 -1
  5. package/lib/dist/bin/specship.js +277 -40
  6. package/lib/dist/bin/specship.js.map +1 -1
  7. package/lib/dist/commands/specship/learn.md +5 -3
  8. package/lib/dist/commands/specship/spec.md +73 -15
  9. package/lib/dist/db/spec-queries.d.ts +73 -3
  10. package/lib/dist/db/spec-queries.d.ts.map +1 -1
  11. package/lib/dist/db/spec-queries.js +139 -5
  12. package/lib/dist/db/spec-queries.js.map +1 -1
  13. package/lib/dist/diagram/flow-diagram.d.ts +86 -0
  14. package/lib/dist/diagram/flow-diagram.d.ts.map +1 -0
  15. package/lib/dist/diagram/flow-diagram.js +0 -0
  16. package/lib/dist/diagram/flow-diagram.js.map +1 -0
  17. package/lib/dist/enforce/changed-files.d.ts +28 -0
  18. package/lib/dist/enforce/changed-files.d.ts.map +1 -0
  19. package/lib/dist/enforce/changed-files.js +72 -0
  20. package/lib/dist/enforce/changed-files.js.map +1 -0
  21. package/lib/dist/enforce/enforce.d.ts +28 -1
  22. package/lib/dist/enforce/enforce.d.ts.map +1 -1
  23. package/lib/dist/enforce/enforce.js +37 -5
  24. package/lib/dist/enforce/enforce.js.map +1 -1
  25. package/lib/dist/extraction/grammars.d.ts +10 -0
  26. package/lib/dist/extraction/grammars.d.ts.map +1 -1
  27. package/lib/dist/extraction/grammars.js +32 -3
  28. package/lib/dist/extraction/grammars.js.map +1 -1
  29. package/lib/dist/extraction/index.d.ts +7 -0
  30. package/lib/dist/extraction/index.d.ts.map +1 -1
  31. package/lib/dist/extraction/index.js.map +1 -1
  32. package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts +7 -0
  33. package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
  34. package/lib/dist/extraction/specs/markdown-spec-extractor.js +81 -8
  35. package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
  36. package/lib/dist/extraction/wasm/tree-sitter-pascal.wasm.gz +0 -0
  37. package/lib/dist/extraction/wasm/tree-sitter-scala.wasm.gz +0 -0
  38. package/lib/dist/graph/spec-coverage.d.ts +86 -0
  39. package/lib/dist/graph/spec-coverage.d.ts.map +1 -0
  40. package/lib/dist/graph/spec-coverage.js +152 -0
  41. package/lib/dist/graph/spec-coverage.js.map +1 -0
  42. package/lib/dist/index.d.ts +81 -6
  43. package/lib/dist/index.d.ts.map +1 -1
  44. package/lib/dist/index.js +178 -21
  45. package/lib/dist/index.js.map +1 -1
  46. package/lib/dist/installer/index.d.ts +33 -8
  47. package/lib/dist/installer/index.d.ts.map +1 -1
  48. package/lib/dist/installer/index.js +50 -50
  49. package/lib/dist/installer/index.js.map +1 -1
  50. package/lib/dist/installer/init-offer.d.ts +14 -9
  51. package/lib/dist/installer/init-offer.d.ts.map +1 -1
  52. package/lib/dist/installer/init-offer.js +13 -9
  53. package/lib/dist/installer/init-offer.js.map +1 -1
  54. package/lib/dist/installer/install-defaults.d.ts +62 -0
  55. package/lib/dist/installer/install-defaults.d.ts.map +1 -0
  56. package/lib/dist/installer/install-defaults.js +67 -0
  57. package/lib/dist/installer/install-defaults.js.map +1 -0
  58. package/lib/dist/installer/targets/claude.d.ts +14 -0
  59. package/lib/dist/installer/targets/claude.d.ts.map +1 -1
  60. package/lib/dist/installer/targets/claude.js +64 -0
  61. package/lib/dist/installer/targets/claude.js.map +1 -1
  62. package/lib/dist/installer/targets/shared.js +4 -4
  63. package/lib/dist/installer/targets/shared.js.map +1 -1
  64. package/lib/dist/mcp/fitness-tool.d.ts +5 -6
  65. package/lib/dist/mcp/fitness-tool.d.ts.map +1 -1
  66. package/lib/dist/mcp/fitness-tool.js +0 -16
  67. package/lib/dist/mcp/fitness-tool.js.map +1 -1
  68. package/lib/dist/mcp/health-tool.d.ts +26 -0
  69. package/lib/dist/mcp/health-tool.d.ts.map +1 -0
  70. package/lib/dist/mcp/health-tool.js +50 -0
  71. package/lib/dist/mcp/health-tool.js.map +1 -0
  72. package/lib/dist/mcp/maintainability-tool.d.ts +5 -7
  73. package/lib/dist/mcp/maintainability-tool.d.ts.map +1 -1
  74. package/lib/dist/mcp/maintainability-tool.js +0 -16
  75. package/lib/dist/mcp/maintainability-tool.js.map +1 -1
  76. package/lib/dist/mcp/model-context.d.ts +13 -2
  77. package/lib/dist/mcp/model-context.d.ts.map +1 -1
  78. package/lib/dist/mcp/model-context.js +39 -12
  79. package/lib/dist/mcp/model-context.js.map +1 -1
  80. package/lib/dist/mcp/proxy.d.ts.map +1 -1
  81. package/lib/dist/mcp/proxy.js +4 -1
  82. package/lib/dist/mcp/proxy.js.map +1 -1
  83. package/lib/dist/mcp/server-instructions.d.ts +1 -1
  84. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  85. package/lib/dist/mcp/server-instructions.js +5 -4
  86. package/lib/dist/mcp/server-instructions.js.map +1 -1
  87. package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
  88. package/lib/dist/mcp/spec-tools.js +213 -28
  89. package/lib/dist/mcp/spec-tools.js.map +1 -1
  90. package/lib/dist/mcp/tools.d.ts +12 -8
  91. package/lib/dist/mcp/tools.d.ts.map +1 -1
  92. package/lib/dist/mcp/tools.js +89 -48
  93. package/lib/dist/mcp/tools.js.map +1 -1
  94. package/lib/dist/resolution/spec-link-resolver.d.ts +44 -2
  95. package/lib/dist/resolution/spec-link-resolver.d.ts.map +1 -1
  96. package/lib/dist/resolution/spec-link-resolver.js +132 -0
  97. package/lib/dist/resolution/spec-link-resolver.js.map +1 -1
  98. package/lib/dist/resolution/test-title-links.d.ts +62 -0
  99. package/lib/dist/resolution/test-title-links.d.ts.map +1 -0
  100. package/lib/dist/resolution/test-title-links.js +108 -0
  101. package/lib/dist/resolution/test-title-links.js.map +1 -0
  102. package/lib/dist/search/query-parser.d.ts.map +1 -1
  103. package/lib/dist/search/query-parser.js +6 -1
  104. package/lib/dist/search/query-parser.js.map +1 -1
  105. package/lib/dist/server/project-registry.js +1 -1
  106. package/lib/dist/server/routes/claude.js +9 -36
  107. package/lib/dist/server/routes/graph.js +53 -7
  108. package/lib/dist/server/routes/mcp.js +4 -3
  109. package/lib/dist/server/routes/projects.js +4 -4
  110. package/lib/dist/server/routes/spec.js +49 -7
  111. package/lib/dist/server/server.js +29 -17
  112. package/lib/dist/skills/spec-author/SKILL.md +164 -0
  113. package/lib/dist/skills/spec-author/references/format.md +210 -0
  114. package/lib/dist/skills/spec-author/references/gap-questions.md +152 -0
  115. package/lib/dist/skills/spec-author/references/quality-rubric.md +125 -0
  116. package/lib/dist/skills/spec-author/references/review-checklist.md +175 -0
  117. package/lib/dist/spec-lint/lint.d.ts +82 -0
  118. package/lib/dist/spec-lint/lint.d.ts.map +1 -0
  119. package/lib/dist/spec-lint/lint.js +430 -0
  120. package/lib/dist/spec-lint/lint.js.map +1 -0
  121. package/lib/dist/statusline/index.js.map +1 -1
  122. package/lib/dist/statusline/render.d.ts +1 -1
  123. package/lib/dist/statusline/render.d.ts.map +1 -1
  124. package/lib/dist/statusline/render.js +3 -1
  125. package/lib/dist/statusline/render.js.map +1 -1
  126. package/lib/dist/ui/assets/index-BzuUWUAE.js +50 -0
  127. package/lib/dist/ui/index.html +1 -1
  128. package/lib/dist/verify/report-ingest.d.ts +88 -0
  129. package/lib/dist/verify/report-ingest.d.ts.map +1 -0
  130. package/lib/dist/verify/report-ingest.js +269 -0
  131. package/lib/dist/verify/report-ingest.js.map +1 -0
  132. package/lib/dist/workflows/defaults/spec-fix.yaml +103 -12
  133. package/lib/dist/workflows/defaults/spec-implement-mixed.yaml +264 -21
  134. package/lib/dist/workflows/defaults/spec-implement.yaml +260 -19
  135. package/lib/dist/workflows/defaults/spec-verify.yaml +60 -9
  136. package/lib/node_modules/.package-lock.json +1 -1
  137. package/lib/package.json +7 -3
  138. package/package.json +1 -1
  139. package/lib/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  140. package/lib/dist/extraction/wasm/tree-sitter-scala.wasm +0 -0
  141. package/lib/dist/server/ingest/impact-query.js +0 -343
  142. package/lib/dist/server/routes/events.js +0 -152
  143. package/lib/dist/ui/assets/index-CjjaftDw.js +0 -50
@@ -1,343 +0,0 @@
1
- /**
2
- * computeSpecshipImpact — aggregate SpecShip token-impact metrics from
3
- * claude_tool_calls + claude_sessions.
4
- *
5
- * Pure function of (db, opts) — no Fastify dependency, unit-testable.
6
- * Called by GET /api/claude/specship-impact in routes/claude.ts.
7
- *
8
- * Algorithm (SQL fetch + JS reduction — no GROUP BY prompt_id/file SQL):
9
- * 1. Fetch scoped rows joining claude_tool_calls ↔ claude_sessions.
10
- * 2. Spend: ceil(Σ result_length / CHARS_PER_TOKEN) for is_specship=1.
11
- * 3. Saved: per-prompt dedup — union displaced file paths across a prompt's
12
- * resolved calls, sum each distinct file's size ONCE, subtract the
13
- * prompt's specship spend chars, floor at 0.
14
- * 4. Overhead: ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN) ×
15
- * distinct session count with ≥1 specship call.
16
- * 5. Cost: price spend/saved chars at input-token rate, grouped by model.
17
- */
18
- import { normalizeModelId, resolvePricing } from './pricing.js';
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
- /** Characters treated as one token (rough average for Claude). */
23
- const CHARS_PER_TOKEN = 4;
24
- /**
25
- * JSON.stringify length of the SpecShip MCP tool definitions array.
26
- * Measured: JSON.stringify(tools.filter(t => t.name.startsWith('specship_'))).length
27
- * at 2026-06-24 — 12 tools in the registry at that time.
28
- * See src/mcp/tools.ts; recheck after adding/removing tools.
29
- */
30
- const SPECSHIP_TOOLDEF_CHARS = 10047;
31
- /** Overhead tokens charged per session (tool-definition context cost). */
32
- const OVERHEAD_TOKENS_PER_SESSION = Math.ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN);
33
- // ---------------------------------------------------------------------------
34
- // Helpers
35
- // ---------------------------------------------------------------------------
36
- function parseDisplacedFiles(raw) {
37
- if (!raw)
38
- return [];
39
- try {
40
- const parsed = JSON.parse(raw);
41
- if (!Array.isArray(parsed))
42
- return [];
43
- return parsed;
44
- }
45
- catch {
46
- return [];
47
- }
48
- }
49
- /**
50
- * Load pricing rows from the DB (returns [] if the table is empty or absent).
51
- */
52
- function loadPricing(db) {
53
- try {
54
- return db
55
- .prepare('SELECT model, input_per_mtok, output_per_mtok, cache_creation_per_mtok, cache_read_per_mtok FROM claude_pricing')
56
- .all();
57
- }
58
- catch {
59
- return [];
60
- }
61
- }
62
- /**
63
- * Price a char count (already summed) as input tokens at the input rate for
64
- * the given model. Returns 0 when model is unknown or pricing is unavailable.
65
- */
66
- function priceChars(chars, model, pricingRows) {
67
- if (!model || pricingRows.length === 0)
68
- return 0;
69
- const pricing = resolvePricing(model, pricingRows);
70
- if (!pricing)
71
- return 0;
72
- const tokens = chars / CHARS_PER_TOKEN;
73
- return (tokens * pricing.input_per_mtok) / 1_000_000;
74
- }
75
- /**
76
- * Per-prompt dedup logic. Given the rows for ONE prompt:
77
- * - union displaced file paths from resolved rows (same file counted once)
78
- * - promptSavedChars = max(0, unionSize - promptSpendChars)
79
- *
80
- * Returns { savedChars, spendChars }.
81
- */
82
- function computePromptSaved(rows) {
83
- const spendChars = rows
84
- .filter(r => r.is_specship === 1)
85
- .reduce((s, r) => s + (r.result_length ?? 0), 0);
86
- // Union displaced files across ALL resolved calls in this prompt.
87
- const fileMap = new Map(); // path → size
88
- for (const row of rows) {
89
- if (row.is_specship !== 1 || row.resolution !== 'resolved')
90
- continue;
91
- for (const [path, size] of parseDisplacedFiles(row.displaced_files)) {
92
- if (!fileMap.has(path)) {
93
- fileMap.set(path, size);
94
- }
95
- }
96
- }
97
- const readEquivChars = Array.from(fileMap.values()).reduce((s, n) => s + n, 0);
98
- const savedChars = Math.max(0, readEquivChars - spendChars);
99
- return { savedChars, spendChars };
100
- }
101
- // ---------------------------------------------------------------------------
102
- // Main export
103
- // ---------------------------------------------------------------------------
104
- export function computeSpecshipImpact(db, opts) {
105
- const { since, project, sessionId } = opts;
106
- // 1. Fetch scoped rows.
107
- const queryParts = [
108
- `SELECT tc.prompt_id, tc.tool_name, tc.is_specship, tc.result_length,
109
- tc.displaced_files, tc.resolution, tc.ts,
110
- s.project_path, s.last_model
111
- FROM claude_tool_calls tc
112
- JOIN claude_sessions s ON s.id = tc.session_id
113
- WHERE tc.ts >= ?`,
114
- ];
115
- const params = [since];
116
- if (project) {
117
- queryParts.push('AND s.project_path = ?');
118
- params.push(project);
119
- }
120
- if (sessionId) {
121
- queryParts.push('AND tc.session_id = ?');
122
- params.push(sessionId);
123
- }
124
- const rows = db.prepare(queryParts.join(' ')).all(...params);
125
- if (rows.length === 0) {
126
- return {
127
- spendTokens: 0,
128
- spendCostUsd: 0,
129
- savedTokens: 0,
130
- savedCostUsd: 0,
131
- overheadTokens: 0,
132
- netTokens: 0,
133
- netCostUsd: 0,
134
- unresolvedCalls: 0,
135
- totalSpecshipCalls: 0,
136
- byTool: [],
137
- byProject: [],
138
- trend: [],
139
- };
140
- }
141
- const pricingRows = loadPricing(db);
142
- // ---------------------------------------------------------------------------
143
- // 2. Group rows by prompt_id for per-prompt dedup.
144
- // ---------------------------------------------------------------------------
145
- const byPrompt = new Map();
146
- for (const row of rows) {
147
- const key = row.prompt_id ?? `__no_prompt_${row.ts}`;
148
- const arr = byPrompt.get(key);
149
- if (arr)
150
- arr.push(row);
151
- else
152
- byPrompt.set(key, [row]);
153
- }
154
- // ---------------------------------------------------------------------------
155
- // 3. Global spend / saved (with per-prompt dedup) + cost by model.
156
- // ---------------------------------------------------------------------------
157
- let totalSpendChars = 0;
158
- let totalSavedChars = 0;
159
- let totalSpecshipCalls = 0;
160
- let unresolvedCalls = 0;
161
- // For cost we need to track chars by model.
162
- const spendCharsByModel = new Map();
163
- const savedCharsByModel = new Map();
164
- // Per-tool accumulators: spend is exact; saved is a per-tool APPROXIMATION.
165
- // Exact per-tool saved attribution is ambiguous when two tools in the same
166
- // prompt share a file. We approximate: a tool's saved chars = its own
167
- // resolved displaced files (deduped within the tool, not globally within
168
- // the prompt). This overestimates when tools share files but keeps
169
- // individual tool savings intuitive. Spend is always exact.
170
- const toolSpendChars = new Map();
171
- const toolSavedChars = new Map();
172
- const toolCalls = new Map();
173
- // Per-project (only when no project filter).
174
- const projectSpendChars = new Map();
175
- const projectSavedChars = new Map();
176
- // Trend: daily buckets.
177
- const dayMs = 24 * 60 * 60 * 1000;
178
- const trendSpend = new Map(); // dayBucket → chars
179
- const trendSaved = new Map();
180
- // Pass 1: global spend/saved via per-prompt dedup.
181
- for (const [, promptRows] of byPrompt) {
182
- const { savedChars, spendChars } = computePromptSaved(promptRows);
183
- totalSpendChars += spendChars;
184
- totalSavedChars += savedChars;
185
- // Attribute spend/saved chars to the session's model.
186
- // All rows in a prompt share the same session (same last_model).
187
- const model = promptRows[0]?.last_model ?? null;
188
- const normModel = model ? normalizeModelId(model) : null;
189
- if (normModel) {
190
- spendCharsByModel.set(normModel, (spendCharsByModel.get(normModel) ?? 0) + spendChars);
191
- savedCharsByModel.set(normModel, (savedCharsByModel.get(normModel) ?? 0) + savedChars);
192
- }
193
- // Track per-day trend for this prompt's is_specship rows.
194
- for (const row of promptRows) {
195
- if (row.is_specship !== 1)
196
- continue;
197
- totalSpecshipCalls++;
198
- if (row.resolution === 'unresolved')
199
- unresolvedCalls++;
200
- // Session tracking (for overhead).
201
- // We need the session_id — it's not in our fetch. Derive from project+model
202
- // won't work. We'll collect it via a separate map after this loop using rows.
203
- }
204
- }
205
- // We need session_ids for overhead. Since we joined sessions but didn't select
206
- // session_id, do a lightweight distinct-session count via a separate query.
207
- const sessionCountQParts = [
208
- `SELECT COUNT(DISTINCT tc.session_id) as cnt
209
- FROM claude_tool_calls tc
210
- JOIN claude_sessions s ON s.id = tc.session_id
211
- WHERE tc.ts >= ? AND tc.is_specship = 1`,
212
- ];
213
- const sessionCountParams = [since];
214
- if (project) {
215
- sessionCountQParts.push('AND s.project_path = ?');
216
- sessionCountParams.push(project);
217
- }
218
- if (sessionId) {
219
- sessionCountQParts.push('AND tc.session_id = ?');
220
- sessionCountParams.push(sessionId);
221
- }
222
- const sessionCountRow = db
223
- .prepare(sessionCountQParts.join(' '))
224
- .get(...sessionCountParams);
225
- const specshipSessionCount = sessionCountRow?.cnt ?? 0;
226
- // ---------------------------------------------------------------------------
227
- // Pass 2: per-tool and trend (row-level, NOT requiring per-prompt dedup).
228
- // ---------------------------------------------------------------------------
229
- // Per-tool saved approximation: for each is_specship row with resolution=resolved,
230
- // compute its own per-call "saved" as max(0, sum(displaced_file_sizes) - result_length).
231
- // This is approximate because it ignores cross-call file sharing within a prompt,
232
- // but it gives an intuitive per-tool estimate and spend is always exact.
233
- for (const row of rows) {
234
- const dayBucket = Math.floor(row.ts / dayMs) * dayMs;
235
- if (row.is_specship !== 1)
236
- continue;
237
- const toolName = row.tool_name ?? 'unknown';
238
- toolCalls.set(toolName, (toolCalls.get(toolName) ?? 0) + 1);
239
- const spend = row.result_length ?? 0;
240
- toolSpendChars.set(toolName, (toolSpendChars.get(toolName) ?? 0) + spend);
241
- // Per-tool saved approximation: own displaced files minus own spend, ≥0.
242
- const displaced = parseDisplacedFiles(row.displaced_files);
243
- if (row.resolution === 'resolved' && displaced.length > 0) {
244
- const fileUnion = new Map();
245
- for (const [p, sz] of displaced)
246
- if (!fileUnion.has(p))
247
- fileUnion.set(p, sz);
248
- const readEquiv = Array.from(fileUnion.values()).reduce((s, n) => s + n, 0);
249
- const toolSaved = Math.max(0, readEquiv - spend);
250
- toolSavedChars.set(toolName, (toolSavedChars.get(toolName) ?? 0) + toolSaved);
251
- }
252
- // Trend.
253
- trendSpend.set(dayBucket, (trendSpend.get(dayBucket) ?? 0) + spend);
254
- }
255
- // Per-prompt saved for trend and byProject (both need per-prompt dedup).
256
- for (const [, promptRows] of byPrompt) {
257
- const { savedChars } = computePromptSaved(promptRows);
258
- const project_path = promptRows[0]?.project_path ?? '';
259
- const dayBucket = Math.floor((promptRows[0]?.ts ?? 0) / dayMs) * dayMs;
260
- // Trend saved.
261
- if (savedChars > 0) {
262
- trendSaved.set(dayBucket, (trendSaved.get(dayBucket) ?? 0) + savedChars);
263
- }
264
- // byProject accumulators.
265
- if (!project) {
266
- const spendChars = promptRows
267
- .filter(r => r.is_specship === 1)
268
- .reduce((s, r) => s + (r.result_length ?? 0), 0);
269
- projectSpendChars.set(project_path, (projectSpendChars.get(project_path) ?? 0) + spendChars);
270
- projectSavedChars.set(project_path, (projectSavedChars.get(project_path) ?? 0) + savedChars);
271
- }
272
- }
273
- // ---------------------------------------------------------------------------
274
- // 4. Compute tokens + cost.
275
- // ---------------------------------------------------------------------------
276
- const spendTokens = Math.ceil(totalSpendChars / CHARS_PER_TOKEN);
277
- const savedTokens = Math.ceil(totalSavedChars / CHARS_PER_TOKEN);
278
- const overheadTokens = OVERHEAD_TOKENS_PER_SESSION * specshipSessionCount;
279
- let spendCostUsd = 0;
280
- let savedCostUsd = 0;
281
- for (const [model, chars] of spendCharsByModel) {
282
- spendCostUsd += priceChars(chars, model, pricingRows);
283
- }
284
- for (const [model, chars] of savedCharsByModel) {
285
- savedCostUsd += priceChars(chars, model, pricingRows);
286
- }
287
- const netTokens = savedTokens - spendTokens - overheadTokens;
288
- const netCostUsd = savedCostUsd - spendCostUsd;
289
- // ---------------------------------------------------------------------------
290
- // 5. byTool.
291
- // ---------------------------------------------------------------------------
292
- const byTool = Array.from(toolCalls.entries()).map(([tool, calls]) => ({
293
- tool,
294
- calls,
295
- spendTokens: Math.ceil((toolSpendChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
296
- savedTokens: Math.ceil((toolSavedChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
297
- })).sort((a, b) => b.calls - a.calls);
298
- // ---------------------------------------------------------------------------
299
- // 6. byProject (only when no project filter).
300
- // ---------------------------------------------------------------------------
301
- // NOTE: per-project netTokens = saved − spend and intentionally does NOT
302
- // subtract the tool-definition overhead. Overhead is a per-session constant
303
- // and isn't attributed to individual projects, so the byProject rows will
304
- // not sum to the headline netTokens (which does include overhead). The page
305
- // methodology note discloses this.
306
- const byProject = project
307
- ? []
308
- : Array.from(projectSpendChars.keys()).map((proj) => {
309
- const spend = Math.ceil((projectSpendChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
310
- const saved = Math.ceil((projectSavedChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
311
- return {
312
- project: proj,
313
- spendTokens: spend,
314
- savedTokens: saved,
315
- netTokens: saved - spend, // overhead not allocated per-project — see note above
316
- };
317
- }).sort((a, b) => b.spendTokens - a.spendTokens);
318
- // ---------------------------------------------------------------------------
319
- // 7. trend.
320
- // ---------------------------------------------------------------------------
321
- const allDayBuckets = new Set([...trendSpend.keys(), ...trendSaved.keys()]);
322
- const trend = Array.from(allDayBuckets)
323
- .sort((a, b) => a - b)
324
- .map((ts) => ({
325
- ts,
326
- spendTokens: Math.ceil((trendSpend.get(ts) ?? 0) / CHARS_PER_TOKEN),
327
- savedTokens: Math.ceil((trendSaved.get(ts) ?? 0) / CHARS_PER_TOKEN),
328
- }));
329
- return {
330
- spendTokens,
331
- spendCostUsd,
332
- savedTokens,
333
- savedCostUsd,
334
- overheadTokens,
335
- netTokens,
336
- netCostUsd,
337
- unresolvedCalls,
338
- totalSpecshipCalls,
339
- byTool,
340
- byProject,
341
- trend,
342
- };
343
- }
@@ -1,152 +0,0 @@
1
- /**
2
- * GET /api/events — cross-project alert stream (REQ-PWA-002).
3
- *
4
- * A single SSE stream that watches EVERY initialized project (not just the
5
- * selected one) and emits an event when an alert-worthy transition happens:
6
- * - `approval` — a workflow run entered `paused` (an approval gate)
7
- * - `runDone` — a run entered `completed` or `failed`
8
- * - `drift` — a spec→code link newly entered drifted/broken/orphaned
9
- *
10
- * Polls the same SQLite the executor/resolver write to (mirrors the workflow
11
- * SSE's 500ms-poll approach), diffing against per-connection seen-state so only
12
- * NEW transitions are emitted — never a backlog burst on connect, never a repeat
13
- * for the same transition. The client (NotificationsService) turns these into
14
- * desktop notifications, gated on permission + per-type toggles.
15
- */
16
- import os from 'node:os';
17
- import { writeSseHead } from './sse.js';
18
- import path from 'node:path';
19
- import { enumerate } from './projects.js';
20
- const POLL_MS = 3000;
21
- const KEEPALIVE_MS = 15000;
22
- // Reflection sweep cadence — high-severity-only, roughly daily (REQ-REFLECT-006).
23
- // The persisted reflect_proposals store dedupes across sweeps/connections, so a
24
- // proposal is only ever notified once regardless of how often the sweep runs.
25
- const SWEEP_MS = 24 * 60 * 60 * 1000;
26
- export async function registerEventsRoutes(app) {
27
- const claudeRoot = path.join(os.homedir(), '.claude', 'projects');
28
- app.get('/api/events', async (req, reply) => {
29
- writeSseHead(req, reply);
30
- reply.raw.write(': connected\n\n');
31
- // Per-connection seen-state — emit only NEW transitions.
32
- const lastStatus = new Map(); // `${slug}:${runId}` -> status
33
- const seenDrift = new Set(); // `${slug}:${linkId}`
34
- const lastSweep = new Map(); // slug -> last reflection sweep ms
35
- const lastIndexSig = new Map(); // slug -> lastIndexed signature
36
- let primed = false; // first pass seeds state silently (no burst on connect)
37
- let closed = false;
38
- const send = (ev) => {
39
- if (closed)
40
- return;
41
- try {
42
- reply.raw.write(`data: ${JSON.stringify(ev)}\n\n`);
43
- }
44
- catch { /* peer gone */ }
45
- };
46
- const poll = async () => {
47
- let projects;
48
- try {
49
- projects = await enumerate(claudeRoot);
50
- }
51
- catch {
52
- return;
53
- }
54
- // Bound the sweep to the most-recently-active initialized projects
55
- // (enumerate() sorts by recency): every project here is opened via the
56
- // registry, and an unbounded sweep churns the LRU open/close cycle on
57
- // every poll once slugs resolve to real paths.
58
- projects = projects.filter((p) => p.initialized).slice(0, 10);
59
- for (const p of projects) {
60
- if (closed)
61
- continue;
62
- let cg;
63
- try {
64
- cg = await app.projects.getBySlug(p.slug);
65
- }
66
- catch {
67
- cg = null;
68
- }
69
- if (!cg)
70
- continue;
71
- const sq = cg.getSpecQueries();
72
- // Workflow run transitions.
73
- let runs = [];
74
- try {
75
- runs = sq.getAllWorkflowRuns(50);
76
- }
77
- catch { /* ignore */ }
78
- for (const r of runs) {
79
- const key = `${p.slug}:${r.id}`;
80
- if (lastStatus.get(key) === r.status)
81
- continue;
82
- lastStatus.set(key, r.status);
83
- if (!primed)
84
- continue;
85
- if (r.status === 'paused') {
86
- send({ kind: 'approval', project: p.slug, projectPath: p.path, id: r.id, title: 'Run needs approval', detail: r.workflowName });
87
- }
88
- else if (r.status === 'completed' || r.status === 'failed') {
89
- send({ kind: 'runDone', project: p.slug, projectPath: p.path, id: r.id, title: `Run ${r.status}`, detail: r.workflowName, status: r.status });
90
- }
91
- }
92
- // Index/ingest activity (REQ-DASHUX-004): emit when the project's
93
- // index freshness changes so open pages can refetch instead of
94
- // going stale between manual refreshes.
95
- try {
96
- const sig = String(cg.getLastIndexedAt() ?? '');
97
- if (lastIndexSig.get(p.slug) !== sig) {
98
- lastIndexSig.set(p.slug, sig);
99
- if (primed) {
100
- send({ kind: 'activity', project: p.slug, projectPath: p.path, id: sig, title: 'Index updated' });
101
- }
102
- }
103
- }
104
- catch { /* ignore — activity detection must not break the stream */ }
105
- // Newly-drifted links.
106
- let links = [];
107
- try {
108
- links = sq.getLinksByState(['drifted', 'broken', 'orphaned']);
109
- }
110
- catch { /* ignore */ }
111
- for (const l of links) {
112
- const key = `${p.slug}:${l.id}`;
113
- if (seenDrift.has(key))
114
- continue;
115
- seenDrift.add(key);
116
- if (!primed)
117
- continue;
118
- send({ kind: 'drift', project: p.slug, projectPath: p.path, id: String(l.id), title: 'Drift detected', detail: `${l.specId} → ${l.targetQualifiedName}` });
119
- }
120
- // Reflection sweep — throttled to ~daily. Notifies only on NEW
121
- // high-severity proposals; the persisted store dedupes, so this is NOT
122
- // gated on `primed` (a brand-new finding should alert even on connect,
123
- // while an already-seen one never re-fires regardless of connection).
124
- const now = Date.now();
125
- if (now - (lastSweep.get(p.slug) ?? 0) >= SWEEP_MS) {
126
- lastSweep.set(p.slug, now);
127
- try {
128
- const res = cg.reflectSweep();
129
- for (const prop of res.notify) {
130
- send({ kind: 'reflect', project: p.slug, projectPath: p.path, id: prop.contentHash, title: prop.title, detail: prop.evidence.detail });
131
- }
132
- }
133
- catch { /* ignore — a sweep failure must not break the stream */ }
134
- }
135
- }
136
- primed = true;
137
- };
138
- await poll(); // prime: seed seen-state without emitting
139
- const pollTimer = setInterval(() => { void poll(); }, POLL_MS);
140
- const kaTimer = setInterval(() => { if (!closed) {
141
- try {
142
- reply.raw.write(': ka\n\n');
143
- }
144
- catch { /* noop */ }
145
- } }, KEEPALIVE_MS);
146
- req.raw.on('close', () => {
147
- closed = true;
148
- clearInterval(pollTimer);
149
- clearInterval(kaTimer);
150
- });
151
- });
152
- }