@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.
- package/lib/dist/activation/steering.d.ts +4 -4
- package/lib/dist/activation/steering.d.ts.map +1 -1
- package/lib/dist/activation/steering.js +6 -6
- package/lib/dist/activation/steering.js.map +1 -1
- package/lib/dist/bin/specship.js +277 -40
- package/lib/dist/bin/specship.js.map +1 -1
- package/lib/dist/commands/specship/learn.md +5 -3
- package/lib/dist/commands/specship/spec.md +73 -15
- package/lib/dist/db/spec-queries.d.ts +73 -3
- package/lib/dist/db/spec-queries.d.ts.map +1 -1
- package/lib/dist/db/spec-queries.js +139 -5
- package/lib/dist/db/spec-queries.js.map +1 -1
- package/lib/dist/diagram/flow-diagram.d.ts +86 -0
- package/lib/dist/diagram/flow-diagram.d.ts.map +1 -0
- package/lib/dist/diagram/flow-diagram.js +0 -0
- package/lib/dist/diagram/flow-diagram.js.map +1 -0
- package/lib/dist/enforce/changed-files.d.ts +28 -0
- package/lib/dist/enforce/changed-files.d.ts.map +1 -0
- package/lib/dist/enforce/changed-files.js +72 -0
- package/lib/dist/enforce/changed-files.js.map +1 -0
- package/lib/dist/enforce/enforce.d.ts +28 -1
- package/lib/dist/enforce/enforce.d.ts.map +1 -1
- package/lib/dist/enforce/enforce.js +37 -5
- package/lib/dist/enforce/enforce.js.map +1 -1
- package/lib/dist/extraction/grammars.d.ts +10 -0
- package/lib/dist/extraction/grammars.d.ts.map +1 -1
- package/lib/dist/extraction/grammars.js +32 -3
- package/lib/dist/extraction/grammars.js.map +1 -1
- package/lib/dist/extraction/index.d.ts +7 -0
- package/lib/dist/extraction/index.d.ts.map +1 -1
- package/lib/dist/extraction/index.js.map +1 -1
- package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts +7 -0
- package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
- package/lib/dist/extraction/specs/markdown-spec-extractor.js +81 -8
- package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
- package/lib/dist/extraction/wasm/tree-sitter-pascal.wasm.gz +0 -0
- package/lib/dist/extraction/wasm/tree-sitter-scala.wasm.gz +0 -0
- package/lib/dist/graph/spec-coverage.d.ts +86 -0
- package/lib/dist/graph/spec-coverage.d.ts.map +1 -0
- package/lib/dist/graph/spec-coverage.js +152 -0
- package/lib/dist/graph/spec-coverage.js.map +1 -0
- package/lib/dist/index.d.ts +81 -6
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +178 -21
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/installer/index.d.ts +33 -8
- package/lib/dist/installer/index.d.ts.map +1 -1
- package/lib/dist/installer/index.js +50 -50
- package/lib/dist/installer/index.js.map +1 -1
- package/lib/dist/installer/init-offer.d.ts +14 -9
- package/lib/dist/installer/init-offer.d.ts.map +1 -1
- package/lib/dist/installer/init-offer.js +13 -9
- package/lib/dist/installer/init-offer.js.map +1 -1
- package/lib/dist/installer/install-defaults.d.ts +62 -0
- package/lib/dist/installer/install-defaults.d.ts.map +1 -0
- package/lib/dist/installer/install-defaults.js +67 -0
- package/lib/dist/installer/install-defaults.js.map +1 -0
- package/lib/dist/installer/targets/claude.d.ts +14 -0
- package/lib/dist/installer/targets/claude.d.ts.map +1 -1
- package/lib/dist/installer/targets/claude.js +64 -0
- package/lib/dist/installer/targets/claude.js.map +1 -1
- package/lib/dist/installer/targets/shared.js +4 -4
- package/lib/dist/installer/targets/shared.js.map +1 -1
- package/lib/dist/mcp/fitness-tool.d.ts +5 -6
- package/lib/dist/mcp/fitness-tool.d.ts.map +1 -1
- package/lib/dist/mcp/fitness-tool.js +0 -16
- package/lib/dist/mcp/fitness-tool.js.map +1 -1
- package/lib/dist/mcp/health-tool.d.ts +26 -0
- package/lib/dist/mcp/health-tool.d.ts.map +1 -0
- package/lib/dist/mcp/health-tool.js +50 -0
- package/lib/dist/mcp/health-tool.js.map +1 -0
- package/lib/dist/mcp/maintainability-tool.d.ts +5 -7
- package/lib/dist/mcp/maintainability-tool.d.ts.map +1 -1
- package/lib/dist/mcp/maintainability-tool.js +0 -16
- package/lib/dist/mcp/maintainability-tool.js.map +1 -1
- package/lib/dist/mcp/model-context.d.ts +13 -2
- package/lib/dist/mcp/model-context.d.ts.map +1 -1
- package/lib/dist/mcp/model-context.js +39 -12
- package/lib/dist/mcp/model-context.js.map +1 -1
- package/lib/dist/mcp/proxy.d.ts.map +1 -1
- package/lib/dist/mcp/proxy.js +4 -1
- package/lib/dist/mcp/proxy.js.map +1 -1
- package/lib/dist/mcp/server-instructions.d.ts +1 -1
- package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
- package/lib/dist/mcp/server-instructions.js +5 -4
- package/lib/dist/mcp/server-instructions.js.map +1 -1
- package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
- package/lib/dist/mcp/spec-tools.js +213 -28
- package/lib/dist/mcp/spec-tools.js.map +1 -1
- package/lib/dist/mcp/tools.d.ts +12 -8
- package/lib/dist/mcp/tools.d.ts.map +1 -1
- package/lib/dist/mcp/tools.js +89 -48
- package/lib/dist/mcp/tools.js.map +1 -1
- package/lib/dist/resolution/spec-link-resolver.d.ts +44 -2
- package/lib/dist/resolution/spec-link-resolver.d.ts.map +1 -1
- package/lib/dist/resolution/spec-link-resolver.js +132 -0
- package/lib/dist/resolution/spec-link-resolver.js.map +1 -1
- package/lib/dist/resolution/test-title-links.d.ts +62 -0
- package/lib/dist/resolution/test-title-links.d.ts.map +1 -0
- package/lib/dist/resolution/test-title-links.js +108 -0
- package/lib/dist/resolution/test-title-links.js.map +1 -0
- package/lib/dist/search/query-parser.d.ts.map +1 -1
- package/lib/dist/search/query-parser.js +6 -1
- package/lib/dist/search/query-parser.js.map +1 -1
- package/lib/dist/server/project-registry.js +1 -1
- package/lib/dist/server/routes/claude.js +9 -36
- package/lib/dist/server/routes/graph.js +53 -7
- package/lib/dist/server/routes/mcp.js +4 -3
- package/lib/dist/server/routes/projects.js +4 -4
- package/lib/dist/server/routes/spec.js +49 -7
- package/lib/dist/server/server.js +29 -17
- package/lib/dist/skills/spec-author/SKILL.md +164 -0
- package/lib/dist/skills/spec-author/references/format.md +210 -0
- package/lib/dist/skills/spec-author/references/gap-questions.md +152 -0
- package/lib/dist/skills/spec-author/references/quality-rubric.md +125 -0
- package/lib/dist/skills/spec-author/references/review-checklist.md +175 -0
- package/lib/dist/spec-lint/lint.d.ts +82 -0
- package/lib/dist/spec-lint/lint.d.ts.map +1 -0
- package/lib/dist/spec-lint/lint.js +430 -0
- package/lib/dist/spec-lint/lint.js.map +1 -0
- package/lib/dist/statusline/index.js.map +1 -1
- package/lib/dist/statusline/render.d.ts +1 -1
- package/lib/dist/statusline/render.d.ts.map +1 -1
- package/lib/dist/statusline/render.js +3 -1
- package/lib/dist/statusline/render.js.map +1 -1
- package/lib/dist/ui/assets/index-BzuUWUAE.js +50 -0
- package/lib/dist/ui/index.html +1 -1
- package/lib/dist/verify/report-ingest.d.ts +88 -0
- package/lib/dist/verify/report-ingest.d.ts.map +1 -0
- package/lib/dist/verify/report-ingest.js +269 -0
- package/lib/dist/verify/report-ingest.js.map +1 -0
- package/lib/dist/workflows/defaults/spec-fix.yaml +103 -12
- package/lib/dist/workflows/defaults/spec-implement-mixed.yaml +264 -21
- package/lib/dist/workflows/defaults/spec-implement.yaml +260 -19
- package/lib/dist/workflows/defaults/spec-verify.yaml +60 -9
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +7 -3
- package/package.json +1 -1
- package/lib/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/lib/dist/extraction/wasm/tree-sitter-scala.wasm +0 -0
- package/lib/dist/server/ingest/impact-query.js +0 -343
- package/lib/dist/server/routes/events.js +0 -152
- 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
|
-
}
|