@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/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.23",
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 resolve4 } from "path";
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((resolve5) => {
133
+ return new Promise((resolve6) => {
134
134
  const s = createServer();
135
- s.once("error", () => resolve5(false));
136
- s.once("listening", () => s.close(() => resolve5(true)));
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((resolve5, reject) => {
1371
- nodeServer.close((err2) => err2 ? reject(err2) : resolve5());
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\r
1410
- # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns\r
1411
- # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.\r
1412
- # Always exits 0; server failures leave Claude untouched.\r
1413
- \r
1414
- set +e\r
1415
- \r
1416
- PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1417
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1418
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1419
- if [ -z "$PORT" ]; then exit 0; fi\r
1420
- \r
1421
- INPUT=$(cat 2>/dev/null)\r
1422
- if [ -z "$INPUT" ]; then exit 0; fi\r
1423
- \r
1424
- RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1425
- --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)\r
1426
- \r
1427
- case "$RESP" in\r
1428
- *'"decision":"block"'*)\r
1429
- REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')\r
1430
- cat <<EOF\r
1431
- {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}\r
1432
- EOF\r
1433
- ;;\r
1434
- esac\r
1435
- exit 0\r
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.\r
1492
- # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
1493
- # usage.* token counts across all assistant turns since the last offset, and\r
1494
- # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid\r
1495
- # double-counting on session resume.\r
1496
- \r
1497
- $ErrorActionPreference = "SilentlyContinue"\r
1498
- \r
1499
- $raw = [Console]::In.ReadToEnd()\r
1500
- if (-not $raw) { exit 0 }\r
1501
- try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }\r
1502
- \r
1503
- $transcript = $hookInput.transcript_path\r
1504
- if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }\r
1505
- \r
1506
- $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\r
1507
- if (-not (Test-Path $portFile)) { exit 0 }\r
1508
- $port = (Get-Content -Path $portFile -Raw).Trim()\r
1509
- if (-not $port) { exit 0 }\r
1510
- \r
1511
- $offsetFile = "$transcript.stopoffset"\r
1512
- $startOffset = 0\r
1513
- if (Test-Path $offsetFile) {\r
1514
- $val = (Get-Content -Path $offsetFile -Raw).Trim()\r
1515
- if ($val -match '^\\d+$') { $startOffset = [int]$val }\r
1516
- }\r
1517
- \r
1518
- $lines = Get-Content -Path $transcript\r
1519
- $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""\r
1520
- $lineNum = 0\r
1521
- foreach ($line in $lines) {\r
1522
- $lineNum++\r
1523
- if ($lineNum -le $startOffset) { continue }\r
1524
- if (-not $line) { continue }\r
1525
- try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }\r
1526
- $usage = $e.message.usage\r
1527
- if (-not $usage) { continue }\r
1528
- $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1529
- $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1530
- $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1531
- $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1532
- if ($e.message.model) { $model = $e.message.model }\r
1533
- }\r
1534
- \r
1535
- Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII\r
1536
- \r
1537
- if ($inT -eq 0 -and $outT -eq 0) { exit 0 }\r
1538
- \r
1539
- $payload = @{\r
1540
- input_tokens = $inT\r
1541
- output_tokens = $outT\r
1542
- cache_creation_input_tokens = $cc\r
1543
- cache_read_input_tokens = $cr\r
1544
- model = $model\r
1545
- description = "synthra-stop-hook"\r
1546
- project = $PWD.Path\r
1547
- } | ConvertTo-Json -Compress\r
1548
- \r
1549
- try {\r
1550
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`\r
1551
- -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1552
- } catch {\r
1553
- # silent\r
1554
- }\r
1555
- \r
1556
- # Refresh CONTEXT.md from the branch-scoped store.\r
1557
- $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress\r
1558
- try {\r
1559
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`\r
1560
- -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1561
- } catch {\r
1562
- # silent\r
1563
- }\r
1564
- exit 0\r
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\r
1569
- # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,\r
1570
- # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.\r
1571
- # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.\r
1572
- \r
1573
- set +e\r
1574
- \r
1575
- INPUT=$(cat 2>/dev/null)\r
1576
- if [ -z "$INPUT" ]; then exit 0; fi\r
1577
- \r
1578
- # jq is required for the parsing below \u2014 bail early (silent no-op) if it's absent.\r
1579
- if ! command -v jq >/dev/null 2>&1; then exit 0; fi\r
1580
- \r
1581
- # Extract transcript_path with jq, not sed. A greedy sed capture (\\(.*\\)") grabs the\r
1582
- # trailing JSON fields after transcript_path and yields a path that doesn't exist, so\r
1583
- # the -f check below always failed and totals were never POSTed to /log. (issue #1)\r
1584
- TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)\r
1585
- if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi\r
1586
- \r
1587
- PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1588
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1589
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1590
- if [ -z "$PORT" ]; then exit 0; fi\r
1591
- \r
1592
- OFFSET_FILE="\${TRANSCRIPT}.stopoffset"\r
1593
- START_OFFSET=0\r
1594
- if [ -f "$OFFSET_FILE" ]; then\r
1595
- START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')\r
1596
- case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac\r
1597
- fi\r
1598
- \r
1599
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')\r
1600
- TOTAL_LINES=\${TOTAL_LINES:-0}\r
1601
- \r
1602
- if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi\r
1603
- \r
1604
- USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\\r
1605
- | jq -s '\r
1606
- map(select(.message.usage != null) | .message)\r
1607
- | reduce .[] as $m (\r
1608
- {in:0, out:0, cc:0, cr:0, model:""};\r
1609
- .in += ($m.usage.input_tokens // 0)\r
1610
- | .out += ($m.usage.output_tokens // 0)\r
1611
- | .cc += ($m.usage.cache_creation_input_tokens // 0)\r
1612
- | .cr += ($m.usage.cache_read_input_tokens // 0)\r
1613
- | .model = ($m.model // .model)\r
1614
- )\r
1615
- ' 2>/dev/null)\r
1616
- \r
1617
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"\r
1618
- \r
1619
- IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')\r
1620
- OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')\r
1621
- CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')\r
1622
- CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')\r
1623
- MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')\r
1624
- \r
1625
- if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi\r
1626
- \r
1627
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1628
- --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\\r
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}')" \\\r
1630
- "http://127.0.0.1:$PORT/log" >/dev/null 2>&1\r
1631
- \r
1632
- # Refresh CONTEXT.md from the branch-scoped store.\r
1633
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1634
- --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\\r
1635
- "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1\r
1636
- \r
1637
- exit 0\r
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 appendFile2, mkdir as mkdir8 } from "fs/promises";
4633
- import { dirname as dirname9 } from "path";
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 mkdir8(dirname9(ctx.paths.gateLog), { recursive: true });
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 appendFile2(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
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 appendFile3, mkdir as mkdir9 } from "fs/promises";
4762
- import { dirname as dirname10 } from "path";
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 mkdir9(dirname10(ctx.paths.tokenLog), { recursive: true });
4770
- await appendFile3(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
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((resolve5, reject) => {
4938
- nodeServer.close((err2) => err2 ? reject(err2) : resolve5());
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 mkdir10, readFile as readFile14, writeFile as writeFile9 } from "fs/promises";
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 join10 } from "path";
5250
+ import { join as join11 } from "path";
5045
5251
  import { createInterface } from "readline/promises";
5046
- import spawn from "cross-spawn";
5252
+ import spawn2 from "cross-spawn";
5047
5253
  var PKG_NAME = "@jefuriiij/synthra";
5048
- var SYNTHRA_DIR = join10(homedir3(), ".synthra");
5049
- var LAST_SEEN_PATH = join10(SYNTHRA_DIR, "last-seen-version.json");
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 readFile14(LAST_SEEN_PATH, "utf8");
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 mkdir10(SYNTHRA_DIR, { recursive: true });
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((resolve5) => {
5322
+ return new Promise((resolve6) => {
5117
5323
  const chunks = [];
5118
- const proc = spawn("npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
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", () => resolve5(null));
5326
+ proc.on("error", () => resolve6(null));
5121
5327
  proc.on("exit", (code) => {
5122
- if (code !== 0) return resolve5(null);
5328
+ if (code !== 0) return resolve6(null);
5123
5329
  const out = Buffer.concat(chunks).toString("utf8").trim();
5124
- resolve5(out || null);
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 readFile14(join10(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
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((resolve5) => {
5188
- const proc = spawn("npm", ["install", "-g", PKG_NAME + "@latest"], {
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", () => resolve5(false));
5192
- proc.on("exit", (code) => resolve5(code === 0));
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 resolve3 } from "path";
5228
- import { stat as stat4 } from "fs/promises";
5433
+ import { resolve as resolve4 } from "path";
5434
+ import { stat as stat5 } from "fs/promises";
5229
5435
  async function serveCommand(rawPath) {
5230
- const projectRoot = resolve3(rawPath);
5436
+ const projectRoot = resolve4(rawPath);
5231
5437
  const paths = resolvePaths(projectRoot);
5232
5438
  try {
5233
- await stat4(paths.infoGraph);
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 spawn2 from "cross-spawn";
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((resolve5) => {
5261
- const proc = spawn2(bin, args, {
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", () => resolve5({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
5270
- proc.on("exit", (code) => resolve5({ code: code ?? 0, stdout, stderr }));
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((resolve5) => {
5532
+ return new Promise((resolve6) => {
5327
5533
  const handler = (sig) => {
5328
5534
  process.off("SIGINT", handler);
5329
5535
  process.off("SIGTERM", handler);
5330
- resolve5(sig);
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 = resolve4(rawPath);
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) {