@jefuriiij/synthra 0.1.23 → 0.1.25
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/CHANGELOG.md +53 -0
- package/LICENSE +21 -21
- package/README.md +222 -222
- package/dist/cli/index.js +434 -225
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +56 -8
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +32 -9
- package/dist/server/index.js.map +1 -1
- package/package.json +66 -66
package/dist/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ var init_package = __esm({
|
|
|
18
18
|
"package.json"() {
|
|
19
19
|
package_default = {
|
|
20
20
|
name: "@jefuriiij/synthra",
|
|
21
|
-
version: "0.1.
|
|
21
|
+
version: "0.1.25",
|
|
22
22
|
publishConfig: {
|
|
23
23
|
access: "public"
|
|
24
24
|
},
|
|
@@ -88,7 +88,7 @@ var init_package = __esm({
|
|
|
88
88
|
// src/cli/index.ts
|
|
89
89
|
init_package();
|
|
90
90
|
import sade from "sade";
|
|
91
|
-
import { resolve as
|
|
91
|
+
import { resolve as resolve5 } from "path";
|
|
92
92
|
|
|
93
93
|
// src/dashboard/server.ts
|
|
94
94
|
init_package();
|
|
@@ -130,10 +130,10 @@ async function findFreePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
|
|
|
130
130
|
throw new Error(`Synthra: no free port in ${start}-${end}`);
|
|
131
131
|
}
|
|
132
132
|
function isFree(port) {
|
|
133
|
-
return new Promise((
|
|
133
|
+
return new Promise((resolve6) => {
|
|
134
134
|
const s = createServer();
|
|
135
|
-
s.once("error", () =>
|
|
136
|
-
s.once("listening", () => s.close(() =>
|
|
135
|
+
s.once("error", () => resolve6(false));
|
|
136
|
+
s.once("listening", () => s.close(() => resolve6(true)));
|
|
137
137
|
s.listen(port, "127.0.0.1");
|
|
138
138
|
});
|
|
139
139
|
}
|
|
@@ -157,6 +157,7 @@ function resolvePaths(projectRoot) {
|
|
|
157
157
|
activityLog: join(graphDir, "activity.jsonl"),
|
|
158
158
|
tokenLog: join(graphDir, "token_log.jsonl"),
|
|
159
159
|
gateLog: join(graphDir, "gate_log.jsonl"),
|
|
160
|
+
toolLog: join(graphDir, "tool_log.jsonl"),
|
|
160
161
|
mcpPort: join(graphDir, "mcp_port"),
|
|
161
162
|
mcpServerLog: join(graphDir, "mcp_server.log"),
|
|
162
163
|
mcpServerErrLog: join(graphDir, "mcp_server.err.log"),
|
|
@@ -246,6 +247,14 @@ async function listProjects() {
|
|
|
246
247
|
|
|
247
248
|
// src/dashboard/delta.ts
|
|
248
249
|
var AVG_TOKENS_PER_BLOCKED_GREP = 500;
|
|
250
|
+
function countToolCalls(entries) {
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const e of entries) {
|
|
253
|
+
if (!e.tool) continue;
|
|
254
|
+
out[e.tool] = (out[e.tool] ?? 0) + 1;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
249
258
|
async function readJsonl(path) {
|
|
250
259
|
try {
|
|
251
260
|
const text = await readFile2(path, "utf8");
|
|
@@ -294,6 +303,8 @@ function summarize(p) {
|
|
|
294
303
|
blocked_count: blocked,
|
|
295
304
|
estimated_tokens_saved: saved,
|
|
296
305
|
estimated_cost_usd: Math.round(costUsd * 100) / 100,
|
|
306
|
+
total_tool_calls: p.tools.length,
|
|
307
|
+
tool_calls: countToolCalls(p.tools),
|
|
297
308
|
models
|
|
298
309
|
};
|
|
299
310
|
}
|
|
@@ -303,12 +314,13 @@ function dedupeEnabled() {
|
|
|
303
314
|
}
|
|
304
315
|
async function loadProjectFiles(path, name, lastSeen) {
|
|
305
316
|
const paths = resolvePaths(path);
|
|
306
|
-
const [rawTokens, gates] = await Promise.all([
|
|
317
|
+
const [rawTokens, gates, tools] = await Promise.all([
|
|
307
318
|
readJsonl(paths.tokenLog),
|
|
308
|
-
readJsonl(paths.gateLog)
|
|
319
|
+
readJsonl(paths.gateLog),
|
|
320
|
+
readJsonl(paths.toolLog)
|
|
309
321
|
]);
|
|
310
322
|
const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;
|
|
311
|
-
return { path, name, last_seen: lastSeen, tokens, gates };
|
|
323
|
+
return { path, name, last_seen: lastSeen, tokens, gates, tools };
|
|
312
324
|
}
|
|
313
325
|
function dedupeTokens(entries) {
|
|
314
326
|
const score2 = (model) => {
|
|
@@ -368,10 +380,12 @@ async function computeDashboardData(activePaths, recentN = 500) {
|
|
|
368
380
|
name: activeName,
|
|
369
381
|
last_seen: null,
|
|
370
382
|
tokens: [],
|
|
371
|
-
gates: []
|
|
383
|
+
gates: [],
|
|
384
|
+
tools: []
|
|
372
385
|
};
|
|
373
386
|
const activeStats = summarize(activeFiles);
|
|
374
|
-
let g_in = 0, g_out = 0, g_cr = 0, g_cc = 0, g_gate = 0, g_block = 0, g_cost = 0, g_turns = 0;
|
|
387
|
+
let g_in = 0, g_out = 0, g_cr = 0, g_cc = 0, g_gate = 0, g_block = 0, g_cost = 0, g_turns = 0, g_tools = 0;
|
|
388
|
+
const g_tool_calls = {};
|
|
375
389
|
for (const s of projects) {
|
|
376
390
|
g_turns += s.total_turns;
|
|
377
391
|
g_in += s.total_input_tokens;
|
|
@@ -381,6 +395,8 @@ async function computeDashboardData(activePaths, recentN = 500) {
|
|
|
381
395
|
g_gate += s.total_gate_calls;
|
|
382
396
|
g_block += s.blocked_count;
|
|
383
397
|
g_cost += s.estimated_cost_usd;
|
|
398
|
+
g_tools += s.total_tool_calls;
|
|
399
|
+
for (const [k, v] of Object.entries(s.tool_calls)) g_tool_calls[k] = (g_tool_calls[k] ?? 0) + v;
|
|
384
400
|
}
|
|
385
401
|
const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;
|
|
386
402
|
const g_used = g_in + g_out + g_cc;
|
|
@@ -433,7 +449,9 @@ async function computeDashboardData(activePaths, recentN = 500) {
|
|
|
433
449
|
blocked_count: g_block,
|
|
434
450
|
estimated_tokens_saved: g_saved,
|
|
435
451
|
saved_percent: Math.round(g_saved_pct * 10) / 10,
|
|
436
|
-
estimated_cost_usd: Math.round(g_cost * 100) / 100
|
|
452
|
+
estimated_cost_usd: Math.round(g_cost * 100) / 100,
|
|
453
|
+
total_tool_calls: g_tools,
|
|
454
|
+
tool_calls: g_tool_calls
|
|
437
455
|
},
|
|
438
456
|
projects,
|
|
439
457
|
recent_turns: allTurns.slice(0, recentN),
|
|
@@ -622,6 +640,16 @@ var public_default = `<!doctype html>
|
|
|
622
640
|
</div>
|
|
623
641
|
</div>
|
|
624
642
|
|
|
643
|
+
<!-- Graph tool usage -->
|
|
644
|
+
<div class="card tools-card has-tooltip" data-tooltip="How often Claude actually used Synthra's graph tools (graph_continue / graph_read / \u2026) across all projects. A positive usage signal \u2014 unlike the Moat's block count, it captures every time Claude reached for the graph instead of running a Grep.">
|
|
645
|
+
<div class="card-head">
|
|
646
|
+
<div class="card-eyebrow">Graph tools <em>used</em></div>
|
|
647
|
+
<div class="card-meta">all projects</div>
|
|
648
|
+
</div>
|
|
649
|
+
<div class="moat-value"><span id="tool-total">0</span> <em>calls</em></div>
|
|
650
|
+
<div class="cost-sub" id="tool-breakdown"></div>
|
|
651
|
+
</div>
|
|
652
|
+
|
|
625
653
|
<!-- The Moat -->
|
|
626
654
|
<div class="card moat has-tooltip" data-tooltip="Synthra's PreToolUse hook intercepts. Each block = Synthra recognized the graph already had high-confidence context for the query, so it stopped Claude from running an exploratory Grep or Glob. The list below shows the latest decisions across all projects.">
|
|
627
655
|
<div class="card-head">
|
|
@@ -984,6 +1012,25 @@ var public_default = `<!doctype html>
|
|
|
984
1012
|
$('#blocks').textContent = fmtPlain(g.blocked_count);
|
|
985
1013
|
}
|
|
986
1014
|
|
|
1015
|
+
function renderToolUsage(g) {
|
|
1016
|
+
$('#tool-total').innerHTML = fmt(g.total_tool_calls || 0);
|
|
1017
|
+
const el = $('#tool-breakdown');
|
|
1018
|
+
el.innerHTML = '';
|
|
1019
|
+
const entries = Object.entries(g.tool_calls || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
1020
|
+
if (!entries.length) {
|
|
1021
|
+
el.innerHTML = '<div class="empty">No graph-tool calls yet.</div>';
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const frag = document.createDocumentFragment();
|
|
1025
|
+
for (const [tool, n] of entries) {
|
|
1026
|
+
const row = document.createElement('div');
|
|
1027
|
+
row.className = 'cs-row';
|
|
1028
|
+
row.innerHTML = '<span class="cs-k">' + tool + '</span><span class="cs-v">' + fmtPlain(n) + '</span>';
|
|
1029
|
+
frag.appendChild(row);
|
|
1030
|
+
}
|
|
1031
|
+
el.appendChild(frag);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
987
1034
|
function renderTurns(turns) {
|
|
988
1035
|
const tbody = $('#turns-body');
|
|
989
1036
|
const empty = $('#turns-empty');
|
|
@@ -1249,6 +1296,7 @@ var public_default = `<!doctype html>
|
|
|
1249
1296
|
renderSavings(data.global);
|
|
1250
1297
|
renderCostHero(data.global);
|
|
1251
1298
|
renderMoat(data.global);
|
|
1299
|
+
renderToolUsage(data.global);
|
|
1252
1300
|
renderTurns(turns);
|
|
1253
1301
|
renderGateMini(gates);
|
|
1254
1302
|
|
|
@@ -1337,7 +1385,7 @@ var public_default = `<!doctype html>
|
|
|
1337
1385
|
`;
|
|
1338
1386
|
|
|
1339
1387
|
// src/dashboard/public/style.css
|
|
1340
|
-
var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;\n --font-serif: "Instrument Serif", "Times New Roman", serif;\n --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop \u2014 quieter */\nbody::before,\nbody::after {\n content: "";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* ============================================================\n Panels / cards \u2014 darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column \u2014 Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column \u2014 Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip \u2014 viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row \u2014 live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: "\u203A";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: "\u203A";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: "";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 \u2014 date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n';
|
|
1388
|
+
var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;\n --font-serif: "Instrument Serif", "Times New Roman", serif;\n --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop \u2014 quieter */\nbody::before,\nbody::after {\n content: "";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* ============================================================\n Panels / cards \u2014 darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column \u2014 Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column \u2014 Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip \u2014 viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n .col-right > *:nth-child(3) { animation-delay: 320ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row \u2014 live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: "\u203A";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: "\u203A";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: "";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 \u2014 date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n';
|
|
1341
1389
|
|
|
1342
1390
|
// src/dashboard/server.ts
|
|
1343
1391
|
var FALLBACK_RANGE = 9;
|
|
@@ -1367,8 +1415,8 @@ async function startDashboard(paths, preferredPort = 8901) {
|
|
|
1367
1415
|
port,
|
|
1368
1416
|
url: `http://127.0.0.1:${port}`,
|
|
1369
1417
|
async stop() {
|
|
1370
|
-
await new Promise((
|
|
1371
|
-
nodeServer.close((err2) => err2 ? reject(err2) :
|
|
1418
|
+
await new Promise((resolve6, reject) => {
|
|
1419
|
+
nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
|
|
1372
1420
|
});
|
|
1373
1421
|
}
|
|
1374
1422
|
};
|
|
@@ -1406,33 +1454,41 @@ exit 0
|
|
|
1406
1454
|
var pre_tool_use_default = '# PreToolUse hook \u2014 Windows PowerShell.\n# THE MOAT (improvement #1). Reads the tool call from stdin (JSON), POSTs it\n# to /gate, and if the server says "block" emits a JSON deny-decision to\n# stdout. Claude Code reads stdout JSON to enforce the decision.\n# Always exits 0; failure-to-reach-server leaves Claude untouched.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$raw = [Console]::In.ReadToEnd()\nif (-not $raw) { exit 0 }\n\ntry {\n $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop\n} catch {\n exit 0\n}\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\n$payload = @{\n tool_name = $hookInput.tool_name\n tool_input = $hookInput.tool_input\n} | ConvertTo-Json -Depth 10 -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/gate" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n} catch {\n exit 0\n}\n\nif ($resp.decision -eq "block") {\n $denyJson = @{\n hookSpecificOutput = @{\n hookEventName = "PreToolUse"\n permissionDecision = "deny"\n permissionDecisionReason = $resp.reason\n }\n } | ConvertTo-Json -Depth 5 -Compress\n Write-Output $denyJson\n}\nexit 0\n';
|
|
1407
1455
|
|
|
1408
1456
|
// src/hooks/scripts/pre-tool-use.sh
|
|
1409
|
-
var pre_tool_use_default2 = `#!/usr/bin/env bash
|
|
1410
|
-
# PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
|
|
1411
|
-
# "block", emits the deny-decision JSON to stdout for Claude Code to enforce
|
|
1412
|
-
# Always exits 0; server failures leave Claude untouched
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
if [ -
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
exit 0
|
|
1457
|
+
var pre_tool_use_default2 = `#!/usr/bin/env bash
|
|
1458
|
+
# PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
|
|
1459
|
+
# "block", emits the deny-decision JSON to stdout for Claude Code to enforce.
|
|
1460
|
+
# Always exits 0; server failures leave Claude untouched.
|
|
1461
|
+
# Requires \`jq\` to read the gate response; falls back to silent no-op (no
|
|
1462
|
+
# enforcement) if absent \u2014 same policy as the Stop/Prime hooks.
|
|
1463
|
+
|
|
1464
|
+
set +e
|
|
1465
|
+
|
|
1466
|
+
PORT_FILE="$PWD/.synthra-graph/mcp_port"
|
|
1467
|
+
if [ ! -f "$PORT_FILE" ]; then exit 0; fi
|
|
1468
|
+
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1469
|
+
if [ -z "$PORT" ]; then exit 0; fi
|
|
1470
|
+
|
|
1471
|
+
INPUT=$(cat 2>/dev/null)
|
|
1472
|
+
if [ -z "$INPUT" ]; then exit 0; fi
|
|
1473
|
+
|
|
1474
|
+
RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
|
|
1475
|
+
--data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)
|
|
1476
|
+
|
|
1477
|
+
# Parse the gate response with jq, not a greedy sed capture. The block \`reason\`
|
|
1478
|
+
# legitimately contains double quotes (it quotes the query, e.g. "login"), so the
|
|
1479
|
+
# old sed capture (\\(.*\\)") both over-ran into the trailing JSON fields and, once
|
|
1480
|
+
# embedded raw in the heredoc, produced invalid hook output. jq reads each field
|
|
1481
|
+
# and re-emits the deny object with correct escaping. (matches stop.sh / prime.sh,
|
|
1482
|
+
# jq fix #1.) No jq \u2192 no enforcement; bail silently like the other hooks.
|
|
1483
|
+
if ! command -v jq >/dev/null 2>&1; then exit 0; fi
|
|
1484
|
+
|
|
1485
|
+
DECISION=$(printf '%s' "$RESP" | jq -r '.decision // empty' 2>/dev/null)
|
|
1486
|
+
if [ "$DECISION" = "block" ]; then
|
|
1487
|
+
REASON=$(printf '%s' "$RESP" | jq -r '.reason // empty' 2>/dev/null)
|
|
1488
|
+
jq -nc --arg r "$REASON" \\
|
|
1489
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
|
|
1490
|
+
fi
|
|
1491
|
+
exit 0
|
|
1436
1492
|
`;
|
|
1437
1493
|
|
|
1438
1494
|
// src/hooks/scripts/prime.ps1
|
|
@@ -1488,153 +1544,153 @@ exit 0
|
|
|
1488
1544
|
`;
|
|
1489
1545
|
|
|
1490
1546
|
// src/hooks/scripts/stop.ps1
|
|
1491
|
-
var stop_default = `# Stop hook \u2014 Windows PowerShell
|
|
1492
|
-
# Reads Claude's transcript JSONL from $hookInput.transcript_path, sums
|
|
1493
|
-
# usage.* token counts across all assistant turns since the last offset, and
|
|
1494
|
-
# POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid
|
|
1495
|
-
# double-counting on session resume
|
|
1496
|
-
|
|
1497
|
-
$ErrorActionPreference = "SilentlyContinue"
|
|
1498
|
-
|
|
1499
|
-
$raw = [Console]::In.ReadToEnd()
|
|
1500
|
-
if (-not $raw) { exit 0 }
|
|
1501
|
-
try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }
|
|
1502
|
-
|
|
1503
|
-
$transcript = $hookInput.transcript_path
|
|
1504
|
-
if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }
|
|
1505
|
-
|
|
1506
|
-
$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
|
|
1507
|
-
if (-not (Test-Path $portFile)) { exit 0 }
|
|
1508
|
-
$port = (Get-Content -Path $portFile -Raw).Trim()
|
|
1509
|
-
if (-not $port) { exit 0 }
|
|
1510
|
-
|
|
1511
|
-
$offsetFile = "$transcript.stopoffset"
|
|
1512
|
-
$startOffset = 0
|
|
1513
|
-
if (Test-Path $offsetFile) {
|
|
1514
|
-
$val = (Get-Content -Path $offsetFile -Raw).Trim()
|
|
1515
|
-
if ($val -match '^\\d+$') { $startOffset = [int]$val }
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
$lines = Get-Content -Path $transcript
|
|
1519
|
-
$inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""
|
|
1520
|
-
$lineNum = 0
|
|
1521
|
-
foreach ($line in $lines) {
|
|
1522
|
-
$lineNum
|
|
1523
|
-
if ($lineNum -le $startOffset) { continue }
|
|
1524
|
-
if (-not $line) { continue }
|
|
1525
|
-
try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }
|
|
1526
|
-
$usage = $e.message.usage
|
|
1527
|
-
if (-not $usage) { continue }
|
|
1528
|
-
$inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1529
|
-
$outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1530
|
-
$cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1531
|
-
$cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1532
|
-
if ($e.message.model) { $model = $e.message.model }
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII
|
|
1536
|
-
|
|
1537
|
-
if ($inT -eq 0 -and $outT -eq 0) { exit 0 }
|
|
1538
|
-
|
|
1539
|
-
$payload = @{
|
|
1540
|
-
input_tokens = $inT
|
|
1541
|
-
output_tokens = $outT
|
|
1542
|
-
cache_creation_input_tokens = $cc
|
|
1543
|
-
cache_read_input_tokens = $cr
|
|
1544
|
-
model = $model
|
|
1545
|
-
description = "synthra-stop-hook"
|
|
1546
|
-
project = $PWD.Path
|
|
1547
|
-
} | ConvertTo-Json -Compress
|
|
1548
|
-
|
|
1549
|
-
try {
|
|
1550
|
-
Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST
|
|
1551
|
-
-Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null
|
|
1552
|
-
} catch {
|
|
1553
|
-
# silent
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
# Refresh CONTEXT.md from the branch-scoped store
|
|
1557
|
-
$ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress
|
|
1558
|
-
try {
|
|
1559
|
-
Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST
|
|
1560
|
-
-Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null
|
|
1561
|
-
} catch {
|
|
1562
|
-
# silent
|
|
1563
|
-
}
|
|
1564
|
-
exit 0
|
|
1547
|
+
var stop_default = `# Stop hook \u2014 Windows PowerShell.
|
|
1548
|
+
# Reads Claude's transcript JSONL from $hookInput.transcript_path, sums
|
|
1549
|
+
# usage.* token counts across all assistant turns since the last offset, and
|
|
1550
|
+
# POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid
|
|
1551
|
+
# double-counting on session resume.
|
|
1552
|
+
|
|
1553
|
+
$ErrorActionPreference = "SilentlyContinue"
|
|
1554
|
+
|
|
1555
|
+
$raw = [Console]::In.ReadToEnd()
|
|
1556
|
+
if (-not $raw) { exit 0 }
|
|
1557
|
+
try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }
|
|
1558
|
+
|
|
1559
|
+
$transcript = $hookInput.transcript_path
|
|
1560
|
+
if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }
|
|
1561
|
+
|
|
1562
|
+
$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
|
|
1563
|
+
if (-not (Test-Path $portFile)) { exit 0 }
|
|
1564
|
+
$port = (Get-Content -Path $portFile -Raw).Trim()
|
|
1565
|
+
if (-not $port) { exit 0 }
|
|
1566
|
+
|
|
1567
|
+
$offsetFile = "$transcript.stopoffset"
|
|
1568
|
+
$startOffset = 0
|
|
1569
|
+
if (Test-Path $offsetFile) {
|
|
1570
|
+
$val = (Get-Content -Path $offsetFile -Raw).Trim()
|
|
1571
|
+
if ($val -match '^\\d+$') { $startOffset = [int]$val }
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
$lines = Get-Content -Path $transcript
|
|
1575
|
+
$inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""
|
|
1576
|
+
$lineNum = 0
|
|
1577
|
+
foreach ($line in $lines) {
|
|
1578
|
+
$lineNum++
|
|
1579
|
+
if ($lineNum -le $startOffset) { continue }
|
|
1580
|
+
if (-not $line) { continue }
|
|
1581
|
+
try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }
|
|
1582
|
+
$usage = $e.message.usage
|
|
1583
|
+
if (-not $usage) { continue }
|
|
1584
|
+
$inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1585
|
+
$outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1586
|
+
$cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1587
|
+
$cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
|
|
1588
|
+
if ($e.message.model) { $model = $e.message.model }
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII
|
|
1592
|
+
|
|
1593
|
+
if ($inT -eq 0 -and $outT -eq 0) { exit 0 }
|
|
1594
|
+
|
|
1595
|
+
$payload = @{
|
|
1596
|
+
input_tokens = $inT
|
|
1597
|
+
output_tokens = $outT
|
|
1598
|
+
cache_creation_input_tokens = $cc
|
|
1599
|
+
cache_read_input_tokens = $cr
|
|
1600
|
+
model = $model
|
|
1601
|
+
description = "synthra-stop-hook"
|
|
1602
|
+
project = $PWD.Path
|
|
1603
|
+
} | ConvertTo-Json -Compress
|
|
1604
|
+
|
|
1605
|
+
try {
|
|
1606
|
+
Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`
|
|
1607
|
+
-Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null
|
|
1608
|
+
} catch {
|
|
1609
|
+
# silent
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
# Refresh CONTEXT.md from the branch-scoped store.
|
|
1613
|
+
$ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress
|
|
1614
|
+
try {
|
|
1615
|
+
Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`
|
|
1616
|
+
-Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null
|
|
1617
|
+
} catch {
|
|
1618
|
+
# silent
|
|
1619
|
+
}
|
|
1620
|
+
exit 0
|
|
1565
1621
|
`;
|
|
1566
1622
|
|
|
1567
1623
|
// src/hooks/scripts/stop.sh
|
|
1568
|
-
var stop_default2 = `#!/usr/bin/env bash
|
|
1569
|
-
# Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines
|
|
1570
|
-
# POSTs totals to /log. Uses a .stopoffset file to avoid double-counting
|
|
1571
|
-
# Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent
|
|
1572
|
-
|
|
1573
|
-
set +e
|
|
1574
|
-
|
|
1575
|
-
INPUT=$(cat 2>/dev/null)
|
|
1576
|
-
if [ -z "$INPUT" ]; then exit 0; fi
|
|
1577
|
-
|
|
1578
|
-
# jq is required for the parsing below \u2014 bail early (silent no-op) if it's absent
|
|
1579
|
-
if ! command -v jq >/dev/null 2>&1; then exit 0; fi
|
|
1580
|
-
|
|
1581
|
-
# Extract transcript_path with jq, not sed. A greedy sed capture (\\(.*\\)") grabs the
|
|
1582
|
-
# trailing JSON fields after transcript_path and yields a path that doesn't exist, so
|
|
1583
|
-
# the -f check below always failed and totals were never POSTed to /log. (issue #1)
|
|
1584
|
-
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
1585
|
-
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi
|
|
1586
|
-
|
|
1587
|
-
PORT_FILE="$PWD/.synthra-graph/mcp_port"
|
|
1588
|
-
if [ ! -f "$PORT_FILE" ]; then exit 0; fi
|
|
1589
|
-
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1590
|
-
if [ -z "$PORT" ]; then exit 0; fi
|
|
1591
|
-
|
|
1592
|
-
OFFSET_FILE="\${TRANSCRIPT}.stopoffset"
|
|
1593
|
-
START_OFFSET=0
|
|
1594
|
-
if [ -f "$OFFSET_FILE" ]; then
|
|
1595
|
-
START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1596
|
-
case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac
|
|
1597
|
-
fi
|
|
1598
|
-
|
|
1599
|
-
TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
|
|
1600
|
-
TOTAL_LINES=\${TOTAL_LINES:-0}
|
|
1601
|
-
|
|
1602
|
-
if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi
|
|
1603
|
-
|
|
1604
|
-
USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null
|
|
1605
|
-
| jq -s '
|
|
1606
|
-
map(select(.message.usage != null) | .message)
|
|
1607
|
-
| reduce .[] as $m (
|
|
1608
|
-
{in:0, out:0, cc:0, cr:0, model:""}
|
|
1609
|
-
.in += ($m.usage.input_tokens // 0)
|
|
1610
|
-
| .out += ($m.usage.output_tokens // 0)
|
|
1611
|
-
| .cc += ($m.usage.cache_creation_input_tokens // 0)
|
|
1612
|
-
| .cr += ($m.usage.cache_read_input_tokens // 0)
|
|
1613
|
-
| .model = ($m.model // .model)
|
|
1614
|
-
)
|
|
1615
|
-
' 2>/dev/null)
|
|
1616
|
-
|
|
1617
|
-
printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"
|
|
1618
|
-
|
|
1619
|
-
IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')
|
|
1620
|
-
OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')
|
|
1621
|
-
CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')
|
|
1622
|
-
CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')
|
|
1623
|
-
MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')
|
|
1624
|
-
|
|
1625
|
-
if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi
|
|
1626
|
-
|
|
1627
|
-
curl -sS --max-time 3 -X POST -H "Content-Type: application/json"
|
|
1628
|
-
--data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD"
|
|
1629
|
-
'{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')"
|
|
1630
|
-
"http://127.0.0.1:$PORT/log" >/dev/null 2>&1
|
|
1631
|
-
|
|
1632
|
-
# Refresh CONTEXT.md from the branch-scoped store
|
|
1633
|
-
curl -sS --max-time 3 -X POST -H "Content-Type: application/json"
|
|
1634
|
-
--data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')"
|
|
1635
|
-
"http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1
|
|
1636
|
-
|
|
1637
|
-
exit 0
|
|
1624
|
+
var stop_default2 = `#!/usr/bin/env bash
|
|
1625
|
+
# Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,
|
|
1626
|
+
# POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.
|
|
1627
|
+
# Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.
|
|
1628
|
+
|
|
1629
|
+
set +e
|
|
1630
|
+
|
|
1631
|
+
INPUT=$(cat 2>/dev/null)
|
|
1632
|
+
if [ -z "$INPUT" ]; then exit 0; fi
|
|
1633
|
+
|
|
1634
|
+
# jq is required for the parsing below \u2014 bail early (silent no-op) if it's absent.
|
|
1635
|
+
if ! command -v jq >/dev/null 2>&1; then exit 0; fi
|
|
1636
|
+
|
|
1637
|
+
# Extract transcript_path with jq, not sed. A greedy sed capture (\\(.*\\)") grabs the
|
|
1638
|
+
# trailing JSON fields after transcript_path and yields a path that doesn't exist, so
|
|
1639
|
+
# the -f check below always failed and totals were never POSTed to /log. (issue #1)
|
|
1640
|
+
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
1641
|
+
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi
|
|
1642
|
+
|
|
1643
|
+
PORT_FILE="$PWD/.synthra-graph/mcp_port"
|
|
1644
|
+
if [ ! -f "$PORT_FILE" ]; then exit 0; fi
|
|
1645
|
+
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1646
|
+
if [ -z "$PORT" ]; then exit 0; fi
|
|
1647
|
+
|
|
1648
|
+
OFFSET_FILE="\${TRANSCRIPT}.stopoffset"
|
|
1649
|
+
START_OFFSET=0
|
|
1650
|
+
if [ -f "$OFFSET_FILE" ]; then
|
|
1651
|
+
START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1652
|
+
case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac
|
|
1653
|
+
fi
|
|
1654
|
+
|
|
1655
|
+
TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
|
|
1656
|
+
TOTAL_LINES=\${TOTAL_LINES:-0}
|
|
1657
|
+
|
|
1658
|
+
if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi
|
|
1659
|
+
|
|
1660
|
+
USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\
|
|
1661
|
+
| jq -s '
|
|
1662
|
+
map(select(.message.usage != null) | .message)
|
|
1663
|
+
| reduce .[] as $m (
|
|
1664
|
+
{in:0, out:0, cc:0, cr:0, model:""};
|
|
1665
|
+
.in += ($m.usage.input_tokens // 0)
|
|
1666
|
+
| .out += ($m.usage.output_tokens // 0)
|
|
1667
|
+
| .cc += ($m.usage.cache_creation_input_tokens // 0)
|
|
1668
|
+
| .cr += ($m.usage.cache_read_input_tokens // 0)
|
|
1669
|
+
| .model = ($m.model // .model)
|
|
1670
|
+
)
|
|
1671
|
+
' 2>/dev/null)
|
|
1672
|
+
|
|
1673
|
+
printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"
|
|
1674
|
+
|
|
1675
|
+
IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')
|
|
1676
|
+
OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')
|
|
1677
|
+
CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')
|
|
1678
|
+
CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')
|
|
1679
|
+
MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')
|
|
1680
|
+
|
|
1681
|
+
if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi
|
|
1682
|
+
|
|
1683
|
+
curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
|
|
1684
|
+
--data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\
|
|
1685
|
+
'{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\
|
|
1686
|
+
"http://127.0.0.1:$PORT/log" >/dev/null 2>&1
|
|
1687
|
+
|
|
1688
|
+
# Refresh CONTEXT.md from the branch-scoped store.
|
|
1689
|
+
curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
|
|
1690
|
+
--data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\
|
|
1691
|
+
"http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1
|
|
1692
|
+
|
|
1693
|
+
exit 0
|
|
1638
1694
|
`;
|
|
1639
1695
|
|
|
1640
1696
|
// src/hooks/installer.ts
|
|
@@ -3594,6 +3650,10 @@ async function scanCommand(rawPath) {
|
|
|
3594
3650
|
return scanProject(rawPath);
|
|
3595
3651
|
}
|
|
3596
3652
|
|
|
3653
|
+
// src/server/mcp.ts
|
|
3654
|
+
import { appendFile as appendFile2, mkdir as mkdir8 } from "fs/promises";
|
|
3655
|
+
import { dirname as dirname9 } from "path";
|
|
3656
|
+
|
|
3597
3657
|
// src/graph/rank.ts
|
|
3598
3658
|
var STOPWORDS2 = /* @__PURE__ */ new Set([
|
|
3599
3659
|
"a",
|
|
@@ -4443,7 +4503,10 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
|
|
|
4443
4503
|
async function graphContinue(args, ctx) {
|
|
4444
4504
|
const query = typeof args?.query === "string" ? args.query : "";
|
|
4445
4505
|
if (!query) return errorContent("graph_continue: 'query' (string) is required");
|
|
4446
|
-
const retrieval = await retrieve(ctx.graph, query
|
|
4506
|
+
const retrieval = await retrieve(ctx.graph, query, {
|
|
4507
|
+
recentlyEditedPaths: ctx.activity.recentFilePaths(15 * 60 * 1e3),
|
|
4508
|
+
sessionKnownPaths: getRegisteredEdits()
|
|
4509
|
+
});
|
|
4447
4510
|
const packed = await pack(retrieval.files, { query, graph: ctx.graph });
|
|
4448
4511
|
const header = `Confidence: ${retrieval.confidence}
|
|
4449
4512
|
Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
|
|
@@ -4505,6 +4568,9 @@ function graphRegisterEdit(args, _ctx) {
|
|
|
4505
4568
|
for (const f of files) editedFiles.add(f);
|
|
4506
4569
|
return textContent(`Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`);
|
|
4507
4570
|
}
|
|
4571
|
+
function getRegisteredEdits() {
|
|
4572
|
+
return Array.from(editedFiles);
|
|
4573
|
+
}
|
|
4508
4574
|
var VALID_KINDS = /* @__PURE__ */ new Set(["decision", "task", "next", "fact", "blocker"]);
|
|
4509
4575
|
async function contextRemember(args, ctx) {
|
|
4510
4576
|
const text = typeof args?.text === "string" ? args.text.trim() : "";
|
|
@@ -4568,6 +4634,17 @@ async function contextRecall(args, ctx) {
|
|
|
4568
4634
|
}
|
|
4569
4635
|
return textContent(lines.join("\n"));
|
|
4570
4636
|
}
|
|
4637
|
+
async function logToolCall(ctx, tool) {
|
|
4638
|
+
try {
|
|
4639
|
+
await mkdir8(dirname9(ctx.paths.toolLog), { recursive: true });
|
|
4640
|
+
await appendFile2(
|
|
4641
|
+
ctx.paths.toolLog,
|
|
4642
|
+
JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
|
|
4643
|
+
"utf8"
|
|
4644
|
+
);
|
|
4645
|
+
} catch {
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4571
4648
|
async function handleMcpRequest(body, ctx) {
|
|
4572
4649
|
if (!body || typeof body !== "object") {
|
|
4573
4650
|
return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
|
|
@@ -4594,6 +4671,7 @@ async function handleMcpRequest(body, ctx) {
|
|
|
4594
4671
|
const toolName = typeof params.name === "string" ? params.name : "";
|
|
4595
4672
|
if (!toolName) return err(id, ERR.invalidParams, "'name' is required for tools/call.");
|
|
4596
4673
|
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
4674
|
+
void logToolCall(ctx, toolName);
|
|
4597
4675
|
const result = await callTool(toolName, args, ctx);
|
|
4598
4676
|
return ok(id, result);
|
|
4599
4677
|
}
|
|
@@ -4629,8 +4707,8 @@ async function handleContextUpdate(req, ctx) {
|
|
|
4629
4707
|
}
|
|
4630
4708
|
|
|
4631
4709
|
// src/server/routes/gate.ts
|
|
4632
|
-
import { appendFile as
|
|
4633
|
-
import { dirname as
|
|
4710
|
+
import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
|
|
4711
|
+
import { dirname as dirname10 } from "path";
|
|
4634
4712
|
var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
|
|
4635
4713
|
var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
|
|
4636
4714
|
function extractQuery(toolName, input) {
|
|
@@ -4686,7 +4764,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
|
|
|
4686
4764
|
}
|
|
4687
4765
|
async function logDecision(ctx, toolName, query, decision, reason) {
|
|
4688
4766
|
try {
|
|
4689
|
-
await
|
|
4767
|
+
await mkdir9(dirname10(ctx.paths.gateLog), { recursive: true });
|
|
4690
4768
|
const entry = {
|
|
4691
4769
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4692
4770
|
tool: toolName,
|
|
@@ -4694,7 +4772,7 @@ async function logDecision(ctx, toolName, query, decision, reason) {
|
|
|
4694
4772
|
query,
|
|
4695
4773
|
reason
|
|
4696
4774
|
};
|
|
4697
|
-
await
|
|
4775
|
+
await appendFile3(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
|
|
4698
4776
|
} catch {
|
|
4699
4777
|
}
|
|
4700
4778
|
}
|
|
@@ -4758,16 +4836,16 @@ async function handleGate(req, ctx) {
|
|
|
4758
4836
|
}
|
|
4759
4837
|
|
|
4760
4838
|
// src/server/routes/log.ts
|
|
4761
|
-
import { appendFile as
|
|
4762
|
-
import { dirname as
|
|
4839
|
+
import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
|
|
4840
|
+
import { dirname as dirname11 } from "path";
|
|
4763
4841
|
async function handleLog(entry, ctx) {
|
|
4764
4842
|
if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
|
|
4765
4843
|
throw new Error("log: input_tokens and output_tokens (number) are required");
|
|
4766
4844
|
}
|
|
4767
4845
|
const written_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
4768
4846
|
const record = { ...entry, written_at };
|
|
4769
|
-
await
|
|
4770
|
-
await
|
|
4847
|
+
await mkdir10(dirname11(ctx.paths.tokenLog), { recursive: true });
|
|
4848
|
+
await appendFile4(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
|
|
4771
4849
|
return { ok: true, written_at };
|
|
4772
4850
|
}
|
|
4773
4851
|
|
|
@@ -4934,8 +5012,8 @@ async function startServer(paths, options = {}) {
|
|
|
4934
5012
|
async stop() {
|
|
4935
5013
|
await fileWatcher.stop().catch(() => void 0);
|
|
4936
5014
|
await gitWatcher.stop().catch(() => void 0);
|
|
4937
|
-
await new Promise((
|
|
4938
|
-
nodeServer.close((err2) => err2 ? reject(err2) :
|
|
5015
|
+
await new Promise((resolve6, reject) => {
|
|
5016
|
+
nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
|
|
4939
5017
|
});
|
|
4940
5018
|
}
|
|
4941
5019
|
};
|
|
@@ -5038,15 +5116,143 @@ async function dashboardCommand(rawPath) {
|
|
|
5038
5116
|
});
|
|
5039
5117
|
}
|
|
5040
5118
|
|
|
5119
|
+
// src/cli/doctor-command.ts
|
|
5120
|
+
import { readFile as readFile14, stat as stat4 } from "fs/promises";
|
|
5121
|
+
import { join as join10, resolve as resolve3 } from "path";
|
|
5122
|
+
import spawn from "cross-spawn";
|
|
5123
|
+
var ICON = { ok: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
|
|
5124
|
+
function binWorks(bin, args) {
|
|
5125
|
+
return new Promise((res) => {
|
|
5126
|
+
let proc;
|
|
5127
|
+
try {
|
|
5128
|
+
proc = spawn(bin, args, { stdio: "ignore" });
|
|
5129
|
+
} catch {
|
|
5130
|
+
res(false);
|
|
5131
|
+
return;
|
|
5132
|
+
}
|
|
5133
|
+
proc.on("error", () => res(false));
|
|
5134
|
+
proc.on("exit", (code) => res(code === 0));
|
|
5135
|
+
});
|
|
5136
|
+
}
|
|
5137
|
+
async function exists2(path) {
|
|
5138
|
+
try {
|
|
5139
|
+
await stat4(path);
|
|
5140
|
+
return true;
|
|
5141
|
+
} catch {
|
|
5142
|
+
return false;
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
async function runDoctorChecks(projectRoot) {
|
|
5146
|
+
const paths = resolvePaths(projectRoot);
|
|
5147
|
+
const cfg = loadConfig();
|
|
5148
|
+
const checks = [];
|
|
5149
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
5150
|
+
checks.push(
|
|
5151
|
+
nodeMajor >= 18 ? { status: "ok", label: "Node", detail: `v${process.versions.node}` } : { status: "fail", label: "Node", detail: `v${process.versions.node} \u2014 Synthra needs Node >= 18` }
|
|
5152
|
+
);
|
|
5153
|
+
const hasJq = await binWorks("jq", ["--version"]);
|
|
5154
|
+
if (process.platform === "win32") {
|
|
5155
|
+
checks.push({
|
|
5156
|
+
status: "ok",
|
|
5157
|
+
label: "jq",
|
|
5158
|
+
detail: hasJq ? "present (not required \u2014 Windows uses .ps1 hooks)" : "not required on Windows (.ps1 hooks)"
|
|
5159
|
+
});
|
|
5160
|
+
} else {
|
|
5161
|
+
checks.push(
|
|
5162
|
+
hasJq ? { status: "ok", label: "jq", detail: "present" } : {
|
|
5163
|
+
status: "warn",
|
|
5164
|
+
label: "jq",
|
|
5165
|
+
detail: "missing \u2014 Stop/PreToolUse bash hooks silently no-op (no token logging or gating). Install jq (brew/apt)."
|
|
5166
|
+
}
|
|
5167
|
+
);
|
|
5168
|
+
}
|
|
5169
|
+
const hasClaude = await binWorks(cfg.claudeBin, ["--version"]);
|
|
5170
|
+
checks.push(
|
|
5171
|
+
hasClaude ? { status: "ok", label: "claude CLI", detail: `'${cfg.claudeBin}' on PATH` } : {
|
|
5172
|
+
status: "warn",
|
|
5173
|
+
label: "claude CLI",
|
|
5174
|
+
detail: `'${cfg.claudeBin}' not found \u2014 MCP registration + IDE need it (set SYN_CLAUDE_BIN to override).`
|
|
5175
|
+
}
|
|
5176
|
+
);
|
|
5177
|
+
if (!await exists2(paths.infoGraph)) {
|
|
5178
|
+
checks.push({ status: "warn", label: "Graph", detail: "no info_graph.json \u2014 run `syn .` (or `syn scan`) here." });
|
|
5179
|
+
} else {
|
|
5180
|
+
try {
|
|
5181
|
+
const graph = JSON.parse(await readFile14(paths.infoGraph, "utf8"));
|
|
5182
|
+
const parts = [`${graph.symbol_count} symbols`, `${graph.file_count} files`];
|
|
5183
|
+
let status = "ok";
|
|
5184
|
+
const ageMs = Date.now() - Date.parse(graph.generated_at);
|
|
5185
|
+
if (Number.isFinite(ageMs)) parts.push(`scanned ${Math.max(0, Math.round(ageMs / 6e4))}m ago`);
|
|
5186
|
+
if (graph.schema_version !== SCHEMA_VERSION2) {
|
|
5187
|
+
status = "warn";
|
|
5188
|
+
parts.push(`schema v${graph.schema_version} \u2260 v${SCHEMA_VERSION2} (auto-rescans on serve)`);
|
|
5189
|
+
}
|
|
5190
|
+
if (graph.symbol_count === 0) {
|
|
5191
|
+
status = "warn";
|
|
5192
|
+
parts.push("0 symbols \u2014 unsupported language or nothing indexed");
|
|
5193
|
+
}
|
|
5194
|
+
checks.push({ status, label: "Graph", detail: parts.join(" \xB7 ") });
|
|
5195
|
+
} catch {
|
|
5196
|
+
checks.push({ status: "warn", label: "Graph", detail: "info_graph.json unreadable \u2014 re-run `syn scan`." });
|
|
5197
|
+
}
|
|
5198
|
+
}
|
|
5199
|
+
checks.push(
|
|
5200
|
+
await exists2(join10(projectRoot, ".mcp.json")) ? { status: "ok", label: "MCP registration", detail: ".mcp.json present (IDE can see graph_* tools)" } : {
|
|
5201
|
+
status: "warn",
|
|
5202
|
+
label: "MCP registration",
|
|
5203
|
+
detail: "no .mcp.json \u2014 the IDE extension won't see Synthra's tools; run `syn .`."
|
|
5204
|
+
}
|
|
5205
|
+
);
|
|
5206
|
+
if (!await exists2(paths.claudeMd)) {
|
|
5207
|
+
checks.push({ status: "warn", label: "CLAUDE.md policy", detail: "no CLAUDE.md \u2014 run `syn .` to scaffold + inject the policy block." });
|
|
5208
|
+
} else {
|
|
5209
|
+
const md = await readFile14(paths.claudeMd, "utf8");
|
|
5210
|
+
if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
|
|
5211
|
+
checks.push({ status: "ok", label: "CLAUDE.md policy", detail: `policy block v${POLICY_VERSION}` });
|
|
5212
|
+
} else {
|
|
5213
|
+
const m = md.match(/synthra-policy v(\d+) BEGIN/);
|
|
5214
|
+
checks.push({
|
|
5215
|
+
status: "warn",
|
|
5216
|
+
label: "CLAUDE.md policy",
|
|
5217
|
+
detail: m ? `policy block is v${m[1]}, current is v${POLICY_VERSION} \u2014 re-run \`syn .\` to refresh.` : "no synthra-policy block \u2014 run `syn .`."
|
|
5218
|
+
});
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
5221
|
+
if (!await exists2(paths.claudeSettings)) {
|
|
5222
|
+
checks.push({ status: "warn", label: "Hooks", detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks." });
|
|
5223
|
+
} else {
|
|
5224
|
+
const s = await readFile14(paths.claudeSettings, "utf8");
|
|
5225
|
+
checks.push(
|
|
5226
|
+
s.includes("synthra-hook=true") ? { status: "ok", label: "Hooks", detail: "registered in .claude/settings.local.json" } : { status: "warn", label: "Hooks", detail: "settings.local.json present but no Synthra hooks \u2014 run `syn .`." }
|
|
5227
|
+
);
|
|
5228
|
+
}
|
|
5229
|
+
return checks;
|
|
5230
|
+
}
|
|
5231
|
+
async function doctorCommand(rawPath) {
|
|
5232
|
+
const projectRoot = resolve3(rawPath);
|
|
5233
|
+
const checks = await runDoctorChecks(projectRoot);
|
|
5234
|
+
log.info("");
|
|
5235
|
+
log.info(` Synthra doctor \u2014 ${projectRoot}`);
|
|
5236
|
+
log.info("");
|
|
5237
|
+
for (const c of checks) {
|
|
5238
|
+
log.info(` ${ICON[c.status]} ${c.label.padEnd(18)}${c.detail}`);
|
|
5239
|
+
}
|
|
5240
|
+
const warn = checks.filter((c) => c.status === "warn").length;
|
|
5241
|
+
const fail = checks.filter((c) => c.status === "fail").length;
|
|
5242
|
+
log.info("");
|
|
5243
|
+
log.info(fail === 0 && warn === 0 ? " All checks passed." : ` ${fail} failed \xB7 ${warn} warning(s).`);
|
|
5244
|
+
log.info("");
|
|
5245
|
+
}
|
|
5246
|
+
|
|
5041
5247
|
// src/cli/self-update.ts
|
|
5042
|
-
import { mkdir as
|
|
5248
|
+
import { mkdir as mkdir11, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
|
|
5043
5249
|
import { homedir as homedir3 } from "os";
|
|
5044
|
-
import { join as
|
|
5250
|
+
import { join as join11 } from "path";
|
|
5045
5251
|
import { createInterface } from "readline/promises";
|
|
5046
|
-
import
|
|
5252
|
+
import spawn2 from "cross-spawn";
|
|
5047
5253
|
var PKG_NAME = "@jefuriiij/synthra";
|
|
5048
|
-
var SYNTHRA_DIR =
|
|
5049
|
-
var LAST_SEEN_PATH =
|
|
5254
|
+
var SYNTHRA_DIR = join11(homedir3(), ".synthra");
|
|
5255
|
+
var LAST_SEEN_PATH = join11(SYNTHRA_DIR, "last-seen-version.json");
|
|
5050
5256
|
var REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PKG_NAME)}/latest`;
|
|
5051
5257
|
var FETCH_TIMEOUT_MS = 2e3;
|
|
5052
5258
|
var currentVersionCache = null;
|
|
@@ -5097,7 +5303,7 @@ async function checkForUpdate() {
|
|
|
5097
5303
|
}
|
|
5098
5304
|
async function readLastSeen() {
|
|
5099
5305
|
try {
|
|
5100
|
-
const raw = await
|
|
5306
|
+
const raw = await readFile15(LAST_SEEN_PATH, "utf8");
|
|
5101
5307
|
const parsed = JSON.parse(raw);
|
|
5102
5308
|
return parsed.version ?? null;
|
|
5103
5309
|
} catch {
|
|
@@ -5106,22 +5312,22 @@ async function readLastSeen() {
|
|
|
5106
5312
|
}
|
|
5107
5313
|
async function writeLastSeen(version) {
|
|
5108
5314
|
try {
|
|
5109
|
-
await
|
|
5315
|
+
await mkdir11(SYNTHRA_DIR, { recursive: true });
|
|
5110
5316
|
const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5111
5317
|
await writeFile9(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
|
|
5112
5318
|
} catch {
|
|
5113
5319
|
}
|
|
5114
5320
|
}
|
|
5115
5321
|
function npmGlobalRoot() {
|
|
5116
|
-
return new Promise((
|
|
5322
|
+
return new Promise((resolve6) => {
|
|
5117
5323
|
const chunks = [];
|
|
5118
|
-
const proc =
|
|
5324
|
+
const proc = spawn2("npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
5119
5325
|
proc.stdout?.on("data", (c) => chunks.push(c));
|
|
5120
|
-
proc.on("error", () =>
|
|
5326
|
+
proc.on("error", () => resolve6(null));
|
|
5121
5327
|
proc.on("exit", (code) => {
|
|
5122
|
-
if (code !== 0) return
|
|
5328
|
+
if (code !== 0) return resolve6(null);
|
|
5123
5329
|
const out = Buffer.concat(chunks).toString("utf8").trim();
|
|
5124
|
-
|
|
5330
|
+
resolve6(out || null);
|
|
5125
5331
|
});
|
|
5126
5332
|
});
|
|
5127
5333
|
}
|
|
@@ -5140,7 +5346,7 @@ async function readInstalledChangelog() {
|
|
|
5140
5346
|
const root = await npmGlobalRoot();
|
|
5141
5347
|
if (!root) return null;
|
|
5142
5348
|
try {
|
|
5143
|
-
return await
|
|
5349
|
+
return await readFile15(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
|
|
5144
5350
|
} catch {
|
|
5145
5351
|
return null;
|
|
5146
5352
|
}
|
|
@@ -5184,12 +5390,12 @@ async function promptYesNo(question) {
|
|
|
5184
5390
|
}
|
|
5185
5391
|
}
|
|
5186
5392
|
function runNpmUpdate() {
|
|
5187
|
-
return new Promise((
|
|
5188
|
-
const proc =
|
|
5393
|
+
return new Promise((resolve6) => {
|
|
5394
|
+
const proc = spawn2("npm", ["install", "-g", PKG_NAME + "@latest"], {
|
|
5189
5395
|
stdio: "inherit"
|
|
5190
5396
|
});
|
|
5191
|
-
proc.on("error", () =>
|
|
5192
|
-
proc.on("exit", (code) =>
|
|
5397
|
+
proc.on("error", () => resolve6(false));
|
|
5398
|
+
proc.on("exit", (code) => resolve6(code === 0));
|
|
5193
5399
|
});
|
|
5194
5400
|
}
|
|
5195
5401
|
async function promptForUpdateOrLog() {
|
|
@@ -5224,13 +5430,13 @@ async function promptForUpdateOrLog() {
|
|
|
5224
5430
|
}
|
|
5225
5431
|
|
|
5226
5432
|
// src/cli/serve-command.ts
|
|
5227
|
-
import { resolve as
|
|
5228
|
-
import { stat as
|
|
5433
|
+
import { resolve as resolve4 } from "path";
|
|
5434
|
+
import { stat as stat5 } from "fs/promises";
|
|
5229
5435
|
async function serveCommand(rawPath) {
|
|
5230
|
-
const projectRoot =
|
|
5436
|
+
const projectRoot = resolve4(rawPath);
|
|
5231
5437
|
const paths = resolvePaths(projectRoot);
|
|
5232
5438
|
try {
|
|
5233
|
-
await
|
|
5439
|
+
await stat5(paths.infoGraph);
|
|
5234
5440
|
} catch {
|
|
5235
5441
|
log.error(`no graph found at ${paths.infoGraph}`);
|
|
5236
5442
|
log.error("run `syn scan` in this project first.");
|
|
@@ -5254,11 +5460,11 @@ async function serveCommand(rawPath) {
|
|
|
5254
5460
|
}
|
|
5255
5461
|
|
|
5256
5462
|
// src/cli/start-claude.ts
|
|
5257
|
-
import
|
|
5463
|
+
import spawn3 from "cross-spawn";
|
|
5258
5464
|
var MCP_NAME = "synthra";
|
|
5259
5465
|
function runClaude(bin, args, cwd, stdio = "pipe") {
|
|
5260
|
-
return new Promise((
|
|
5261
|
-
const proc =
|
|
5466
|
+
return new Promise((resolve6) => {
|
|
5467
|
+
const proc = spawn3(bin, args, {
|
|
5262
5468
|
cwd,
|
|
5263
5469
|
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
5264
5470
|
});
|
|
@@ -5266,8 +5472,8 @@ function runClaude(bin, args, cwd, stdio = "pipe") {
|
|
|
5266
5472
|
let stderr = "";
|
|
5267
5473
|
proc.stdout?.on("data", (c) => stdout += String(c));
|
|
5268
5474
|
proc.stderr?.on("data", (c) => stderr += String(c));
|
|
5269
|
-
proc.on("error", () =>
|
|
5270
|
-
proc.on("exit", (code) =>
|
|
5475
|
+
proc.on("error", () => resolve6({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
|
|
5476
|
+
proc.on("exit", (code) => resolve6({ code: code ?? 0, stdout, stderr }));
|
|
5271
5477
|
});
|
|
5272
5478
|
}
|
|
5273
5479
|
async function registerMcp(bin, mcpPort, cwd) {
|
|
@@ -5323,11 +5529,11 @@ function printReadyBanner(info) {
|
|
|
5323
5529
|
log.info("");
|
|
5324
5530
|
}
|
|
5325
5531
|
function waitForSignal() {
|
|
5326
|
-
return new Promise((
|
|
5532
|
+
return new Promise((resolve6) => {
|
|
5327
5533
|
const handler = (sig) => {
|
|
5328
5534
|
process.off("SIGINT", handler);
|
|
5329
5535
|
process.off("SIGTERM", handler);
|
|
5330
|
-
|
|
5536
|
+
resolve6(sig);
|
|
5331
5537
|
};
|
|
5332
5538
|
process.on("SIGINT", handler);
|
|
5333
5539
|
process.on("SIGTERM", handler);
|
|
@@ -5335,7 +5541,7 @@ function waitForSignal() {
|
|
|
5335
5541
|
}
|
|
5336
5542
|
async function defaultFlow(rawPath, opts) {
|
|
5337
5543
|
const launchCli = opts["launch-cli"] === true;
|
|
5338
|
-
const projectRoot =
|
|
5544
|
+
const projectRoot = resolve5(rawPath);
|
|
5339
5545
|
const paths = resolvePaths(projectRoot);
|
|
5340
5546
|
const cfg = loadConfig();
|
|
5341
5547
|
await runStartupChangelogCheck();
|
|
@@ -5402,6 +5608,9 @@ function buildProgram() {
|
|
|
5402
5608
|
prog.command("dashboard [path]", "Run the token dashboard server (localhost:8901).").action(async (path) => {
|
|
5403
5609
|
await dashboardCommand(path ?? ".");
|
|
5404
5610
|
});
|
|
5611
|
+
prog.command("doctor [path]", "Diagnose this project's Synthra setup + environment.").action(async (path) => {
|
|
5612
|
+
await doctorCommand(path ?? ".");
|
|
5613
|
+
});
|
|
5405
5614
|
return prog;
|
|
5406
5615
|
}
|
|
5407
5616
|
async function main(argv) {
|