sigrank 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs CHANGED
@@ -12,8 +12,9 @@
12
12
  * npx sigrank me your cascade across all 4 windows
13
13
  * npx sigrank me --platform amp use a different platform adapter
14
14
  * npx sigrank me --compare raw pillar comparison: ccusage vs tokenpull vs token-dashboard
15
- * npx sigrank watch RT tune meter — local cascade, refreshes
16
- * npx sigrank watch --window 7d watch a specific window
15
+ * npx sigrank watch RT tune meter — ALL active platforms × all windows
16
+ * npx sigrank watch --platform X watch one platform only (optional filter)
17
+ * npx sigrank watch --window 7d watch one window only (optional filter)
17
18
  *
18
19
  * Color palette mirrors the SigRank web dark theme:
19
20
  * gold = class TRANSMITTER headline + rank #1
@@ -684,37 +685,50 @@ async function runCompare({ platform = 'claude' } = {}) {
684
685
  }
685
686
 
686
687
  // ── WATCH command ─────────────────────────────────────────────────────────────
688
+ //
689
+ // A5: by default (no --platform / --window) the watcher auto-loads EVERYTHING — every
690
+ // active platform (input+output > 0) × every window (7d/30d/90d/all), refreshed each tick.
691
+ // --platform / --window remain OPTIONAL filters for back-compat (focus one cell). The
692
+ // refresh loop + --submit behavior are preserved; submit publishes whichever windows are
693
+ // shown whenever their Υ changes.
694
+
695
+ const WATCH_WINDOWS = ['7d', '30d', '90d', 'all']
696
+
697
+ // Detect which platforms have real local data (input+output > 0 in any window) via tokenpull.
698
+ // Returns an array of pull results (the same shape tokenpullAny yields), one per active platform.
699
+ async function detectActivePulls() {
700
+ const { tokenpullAny } = await import('./tokenpull.mjs')
701
+ const settled = await Promise.allSettled(ALL_PLATFORMS.map((p) => tokenpullAny(p)))
702
+ const active = []
703
+ for (const r of settled) {
704
+ if (r.status !== 'fulfilled' || !r.value) continue
705
+ const d = r.value
706
+ const live = (d.windows || []).some((w) => (w.pillars.input + w.pillars.output) > 0)
707
+ if (live) active.push(d)
708
+ }
709
+ return active
710
+ }
687
711
 
688
- async function runWatch({ platform = 'claude', window: win = '7d', refresh = 30, submit = false } = {}) {
689
- let prev = null
712
+ async function runWatch({ platform, window: win, refresh = 30, submit = false } = {}) {
690
713
  let lines = 0
691
- let lastSubmit = null
692
714
  const id = submit ? ensureIdentity() : null
693
715
  const enrolled = !!(id && id.codename && id.operator_id && id.private_key_pkcs8_b64)
694
716
 
695
- write(HIDE_CURSOR)
717
+ // Filters: when provided, narrow the grid; when absent, watch all active × all windows.
718
+ const winFilter = typeof win === 'string' && win ? win : null
719
+ const platFilter = typeof platform === 'string' && platform ? platform : null
720
+ const windows = winFilter ? [winFilter] : WATCH_WINDOWS
696
721
 
697
- const draw = async () => {
698
- let result
699
- try {
700
- result = await callTool('watch_tokenpull', { platform, window: win })
701
- } catch (e) {
702
- writeln(red(` ✗ ${e.message}`))
703
- return
704
- }
722
+ // Per-cell Υ memory (key = `${platform}|${window}`) so each cell tracks its own change.
723
+ const prevY = new Map()
724
+ const lastSubmitMsg = new Map()
705
725
 
706
- const cas = result.cascade
707
- const changed = prev !== null && prev !== cas?.yield
726
+ write(HIDE_CURSOR)
708
727
 
709
- // --submit (D7 §7): publish the verified window on first observation + whenever Υ changes.
710
- if (submit && enrolled && (prev === null || changed)) {
711
- try {
712
- const r = await submitSignedWindow(win, result.pillars, result.messages, id, { platform: result.platform })
713
- lastSubmit = r.status === 'received' ? green(`✓ submitted · tier=${r.verification_tier || '—'}`) : red(`✗ ${r.reason || r.status}`)
714
- } catch (e) {
715
- lastSubmit = red(`✗ ${e.message}`)
716
- }
717
- }
728
+ const draw = async () => {
729
+ // Re-detect each tick so a platform that just started writing logs appears automatically.
730
+ let pulls = await detectActivePulls().catch(() => [])
731
+ if (platFilter) pulls = pulls.filter((d) => d.platform === platFilter)
718
732
 
719
733
  if (lines > 0) write(CURSOR_UP(lines))
720
734
 
@@ -723,31 +737,64 @@ async function runWatch({ platform = 'claude', window: win = '7d', refresh = 30,
723
737
  const w = termWidth()
724
738
  const ts = new Date().toLocaleTimeString('en-US', { hour12: false })
725
739
 
740
+ const scopeLabel = (platFilter || 'all active platforms') + ' · ' + (winFilter ? `window: ${winFilter}` : 'all windows')
741
+
726
742
  push()
727
- push(` ${gold('⊙ SigRank')} ${bold('Watch')} ${dim(`${platform} · window: ${win} · ${ts}`)}`)
743
+ push(` ${gold('⊙ SigRank')} ${bold('Watch')} ${dim(`${scopeLabel} · ${ts}`)}`)
728
744
  push(` ${dim('─'.repeat(w - 4))}`)
729
- push()
730
745
 
731
- const yDisplay = cas?.yield != null ? fmtYield(cas.yield) : '—'
732
- const indicator = changed ? green(' ▲ updated') : dim(' · no change')
746
+ if (pulls.length === 0) {
747
+ push()
748
+ push(` ${dim('no active platforms detected — run some sessions, this will pick them up automatically')}`)
749
+ push()
750
+ }
733
751
 
734
- push(` ${bold('Υ Yield')} ${cas?.yield != null ? gold(yDisplay) : '—'}${indicator}`)
735
- push(` ${bold('SNR')} ${fmtSNR(cas?.snr)}`)
736
- push(` ${bold('Leverage')} ${cas?.leverage != null ? `${fmtLev(cas.leverage)}×` : '—'}`)
737
- push(` ${bold('Velocity')} ${cas?.velocity != null ? cas.velocity.toFixed(2) : '—'}`)
738
- push(` ${bold('10xDEV')} ${cas?.dev10x != null ? cas.dev10x.toFixed(2) : '—'}`)
739
- push(` ${bold('Class')} ${colorClass(cas?.class ?? '—')}`)
740
- push()
741
- if (submit) {
742
- push(` ${dim('submit:')} ${enrolled ? (lastSubmit || dim('waiting for a change…')) : red('not enrolled — run `npx sigrank enroll`')}`)
752
+ for (const d of pulls) {
753
+ const plat = d.platform
754
+ const byWin = {}
755
+ for (const ww of (d.windows || [])) byWin[ww.window] = ww
756
+ push()
757
+ push(` ${cyan(plat)}${d.estimated ? dim(' (est)') : ''}`)
758
+ for (const wk of windows) {
759
+ const ww = byWin[wk]
760
+ const cas = ww ? cascadeFromPillars(ww.pillars) : null
761
+ const key = `${plat}|${wk}`
762
+ const prev = prevY.get(key)
763
+ const yNow = cas?.yield ?? null
764
+ const changed = prev !== undefined && prev !== yNow
765
+
766
+ // --submit: publish this cell on first observation + whenever its Υ changes.
767
+ if (submit && enrolled && ww && yNow != null && (prev === undefined || changed)) {
768
+ try {
769
+ const r = await submitSignedWindow(wk, ww.pillars, ww.messages, id, { platform: plat })
770
+ lastSubmitMsg.set(key, r.status === 'received' ? green(`✓ tier=${r.verification_tier || '—'}`) : red(`✗ ${r.reason || r.status}`))
771
+ } catch (e) {
772
+ lastSubmitMsg.set(key, red(`✗ ${e.message}`))
773
+ }
774
+ }
775
+ prevY.set(key, yNow)
776
+
777
+ const winLabel = wk === 'all' ? 'all-time' : wk
778
+ const yDisplay = cas?.yield != null ? gold(fmtYield(cas.yield)) : dim('—')
779
+ const indicator = changed ? green(' ▲') : dim(' ·')
780
+ const metrics = cas
781
+ ? `${dim('SNR')} ${fmtSNR(cas.snr)} ${dim('Lev')} ${cas.leverage != null ? fmtLev(cas.leverage) + '×' : '—'} ${dim('Vel')} ${cas.velocity != null ? cas.velocity.toFixed(2) : '—'} ${colorClass(cas.class ?? '—')}`
782
+ : dim('no data')
783
+ const submitNote = (submit && enrolled) ? ' ' + (lastSubmitMsg.get(key) || dim('…')) : ''
784
+ push(` ${padEnd(winLabel, 8)} ${bold('Υ')} ${yDisplay}${indicator} ${metrics}${submitNote}`)
785
+ }
743
786
  }
787
+
788
+ push()
744
789
  push(` ${dim('─'.repeat(w - 4))}`)
790
+ if (submit && !enrolled) {
791
+ push(` ${red('not enrolled — run `npx sigrank enroll` to auto-submit')}`)
792
+ }
745
793
  push(` ${dim(`polling every ${refresh}s · ${submit ? 'auto-submit ON · ' : ''}tokens stay on your machine · ctrl+c to exit`)}`)
746
794
  push()
747
795
 
748
796
  write(out.join('\n'))
749
797
  lines = out.length
750
- prev = cas?.yield ?? null
751
798
  }
752
799
 
753
800
  try {
@@ -1213,11 +1260,12 @@ async function showHelp() {
1213
1260
  writeln(` ${cyan('compare --platform codex')} compare for a specific platform`)
1214
1261
  writeln(` ${cyan('tui')} full tabbed TUI: Dashboard / Compare / Board / Watch`)
1215
1262
  writeln(` ${cyan('tui --platform codex')} TUI with a different default platform`)
1216
- writeln(` ${cyan('watch')} live tune meter — re-reads local logs every 30s`)
1217
- writeln(` ${cyan('watch --window 7d')} watch a specific window`)
1263
+ writeln(` ${cyan('watch')} live tune meter — ALL active platforms × all windows, every 30s`)
1264
+ writeln(` ${cyan('watch --platform codex')} watch only one platform (optional filter)`)
1265
+ writeln(` ${cyan('watch --window 7d')} watch only one window (optional filter)`)
1218
1266
  writeln()
1219
1267
  writeln(` ${bold('Options')}`)
1220
- writeln(` ${dim('--window')} 7d · 30d · 90d · all (default: 30d for board, 7d for watch)`)
1268
+ writeln(` ${dim('--window')} 7d · 30d · 90d · all (default: 30d for board; all windows for watch)`)
1221
1269
  writeln(` ${dim('--platform')} claude · codex · amp · gemini · opencode · goose · …`)
1222
1270
  writeln(` ${dim('--refresh')} poll interval in seconds (default: 30)`)
1223
1271
  writeln(` ${dim('--once')} print once and exit (board only)`)
@@ -1337,14 +1385,31 @@ export async function runCli(argv) {
1337
1385
  const args = argv.slice(2) // strip 'node' + script path
1338
1386
  const cmd = args[0]
1339
1387
 
1340
- // parse --key value flags
1388
+ // parse --key value AND --key=value flags. E4: validate against the known set and warn
1389
+ // (don't crash) on anything unknown, so a typo'd flag is visible instead of silently ignored.
1390
+ const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label'])
1341
1391
  const flags = {}
1342
1392
  for (let i = 1; i < args.length; i++) {
1343
- if (args[i].startsWith('--')) {
1344
- const key = args[i].slice(2)
1345
- const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true
1346
- flags[key] = val
1393
+ const a = args[i]
1394
+ if (!a.startsWith('--')) continue
1395
+ const body = a.slice(2)
1396
+ let key, val
1397
+ const eq = body.indexOf('=')
1398
+ if (eq !== -1) {
1399
+ // --flag=value form
1400
+ key = body.slice(0, eq)
1401
+ val = body.slice(eq + 1)
1402
+ } else {
1403
+ // --flag [value] form: consume the next token as the value unless it's another flag
1404
+ key = body
1405
+ val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true
1406
+ }
1407
+ if (!key) continue
1408
+ if (!KNOWN_FLAGS.has(key)) {
1409
+ writeln(dim(` (ignoring unknown flag --${key}; known: ${[...KNOWN_FLAGS].join(', ')})`))
1410
+ continue
1347
1411
  }
1412
+ flags[key] = val
1348
1413
  }
1349
1414
 
1350
1415
  try {
@@ -1362,9 +1427,12 @@ export async function runCli(argv) {
1362
1427
  const { runTui } = await import('./tui.mjs')
1363
1428
  await runTui({ platform: flags.platform ?? 'claude', window: flags.window ?? '7d' })
1364
1429
  } else if (cmd === 'watch') {
1430
+ // A5: no --platform/--window → watch ALL active platforms × ALL windows. Pass the raw
1431
+ // flags through (undefined = no filter) so runWatch's auto-all mode engages by default;
1432
+ // a provided flag narrows the grid (back-compat).
1365
1433
  await runWatch({
1366
- platform: flags.platform ?? 'claude',
1367
- window: flags.window ?? '7d',
1434
+ platform: typeof flags.platform === 'string' ? flags.platform : undefined,
1435
+ window: typeof flags.window === 'string' ? flags.window : undefined,
1368
1436
  refresh: Number(flags.refresh) || 30,
1369
1437
  submit: flags.submit === true || flags.submit === 'true',
1370
1438
  })
package/connect.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // connect.mjs — pure predicates for the TUI consolidation (the TUI is the whole app).
2
+ // No fs, no I/O, no side effects — unit-testable in isolation. Used by tui.mjs to
3
+ // decide sign-in state and to classify keystrokes while the Connect code field is focused.
4
+
5
+ /** True iff this device identity is signed in (bound to an operator). */
6
+ export function isSignedIn(id) {
7
+ return !!(id && id.codename && id.operator_id)
8
+ }
9
+
10
+ /** True iff `s` is a single character a connect code can contain ([A-Za-z0-9-]). */
11
+ export function isCodeChar(s) {
12
+ return typeof s === 'string' && /^[A-Za-z0-9-]$/.test(s)
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "sign.mjs",
37
37
  "keystore.mjs",
38
38
  "submit.mjs",
39
+ "connect.mjs",
39
40
  "README.md",
40
41
  "LICENSE"
41
42
  ],
package/tokenpull.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { readdir, readFile, lstat } from 'node:fs/promises'
19
+ import { existsSync } from 'node:fs'
20
+ import { execSync } from 'node:child_process'
19
21
  import { join } from 'node:path'
20
22
  import { homedir } from 'node:os'
21
23
  import { ADAPTERS } from './adapters.mjs'
@@ -280,3 +282,119 @@ export async function tokenpullAny(platform, opts = {}) {
280
282
  if (adapter.setupNote) result.setupNote = adapter.setupNote
281
283
  return result
282
284
  }
285
+
286
+ // ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
287
+ // The owner-requested FRESH pull of the three external verifier sources. Unlike the
288
+ // stale snapshot readers (a 3-day-old token-dashboard.db, a static tokscale_report.json),
289
+ // these RUN the source tool on every call so the numbers are live. Token-only: each tool
290
+ // reports usage counts, never transcript content. Every external call is wrapped so a
291
+ // missing binary / parse error / timeout returns null and NEVER throws — a verifier the
292
+ // machine doesn't have simply doesn't appear.
293
+ //
294
+ // Returns { ccusage, tokscale, tokendash } where each value is either null or an object
295
+ // keyed by window: { '7d':{...}, '30d':{...}, '90d':{...}, all:{...} } (windows that can't
296
+ // be derived from a source are omitted; 'all' is present whenever the source has data).
297
+
298
+ // ccusage: run `ccusage <platform> daily --json` and bucket the daily rows into the four
299
+ // windows by date. Logic copied from tools.mjs `_ccusagePillars` (do NOT import private fns).
300
+ function _freshCcusage(platform = 'claude') {
301
+ try {
302
+ const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
303
+ const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
304
+ if (!Array.isArray(rows)) return null
305
+ const now = Date.now()
306
+ const result = {}
307
+ for (const [win, days] of Object.entries({ '7d': 7, '30d': 30, '90d': 90 })) {
308
+ const since = new Date(now - days * DAY_MS)
309
+ let i = 0, o = 0, cw = 0, cr = 0
310
+ for (const r of rows) {
311
+ if (new Date(r.date ?? r.day ?? '1970') >= since) {
312
+ i += r.inputTokens ?? r.input_tokens ?? 0
313
+ o += r.outputTokens ?? r.output_tokens ?? 0
314
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
315
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
316
+ }
317
+ }
318
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
319
+ }
320
+ let i = 0, o = 0, cw = 0, cr = 0
321
+ for (const r of rows) {
322
+ i += r.inputTokens ?? 0; o += r.outputTokens ?? 0; cw += r.cacheCreationTokens ?? 0; cr += r.cacheReadTokens ?? 0
323
+ }
324
+ result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
325
+ return result
326
+ } catch { return null }
327
+ }
328
+
329
+ // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
330
+ // the live db via sqlite3. Claude only (the db only holds Claude Code sessions). The scan is
331
+ // best-effort: if it fails (tool missing, slow), we still read whatever the db currently has.
332
+ function _freshTokendash(platform = 'claude') {
333
+ if (platform !== 'claude') return null
334
+ const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
335
+ const scanCli = join(homedir(), 'Desktop', 'token-dashboard', 'cli.py')
336
+ // Step 1 — refresh the db (best-effort; failures must not abort the read).
337
+ if (existsSync(scanCli)) {
338
+ try {
339
+ execSync(`python3 "${scanCli}" scan`, { timeout: 90000, stdio: ['ignore', 'ignore', 'ignore'] })
340
+ } catch { /* continue: read whatever the db currently has */ }
341
+ }
342
+ // Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
343
+ if (!existsSync(dbPath)) return null
344
+ try {
345
+ const raw = execSync(
346
+ `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
347
+ { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
348
+ ).toString().trim()
349
+ const [i, o, cw, cr] = raw.split('|').map(Number)
350
+ if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
351
+ return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
352
+ } catch { return null }
353
+ }
354
+
355
+ // tokscale: run `bunx tokscale@latest models --json` and sum the per-(client,model) rows for
356
+ // the requested platform. The JSON shape (verified live) is:
357
+ // { entries: [ { client, model, input, output, cacheRead, cacheWrite, ... }, ... ], ... }
358
+ // `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
359
+ // carry no real usage). all-time only — tokscale's models view is not windowed. If no row
360
+ // matches the platform, return null gracefully.
361
+ function _freshTokscale(platform = 'claude') {
362
+ try {
363
+ const raw = execSync(`bunx tokscale@latest models --json`, { timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
364
+ const data = JSON.parse(raw)
365
+ const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
366
+ const rows = entries.filter((e) =>
367
+ e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' &&
368
+ ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
369
+ )
370
+ if (!rows.length) return null
371
+ const acc = rows.reduce((a, e) => ({
372
+ input: a.input + (Number(e.input) || 0),
373
+ output: a.output + (Number(e.output) || 0),
374
+ cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
375
+ cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
376
+ }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
377
+ return { all: acc }
378
+ } catch { return null }
379
+ }
380
+
381
+ /**
382
+ * Fresh-pull all three external verifier sources for a platform. Each is run live (no stale
383
+ * snapshot) and returned as { window → {input,output,cacheCreate,cacheRead} } or null when the
384
+ * source is unavailable / has no data for the platform. Never throws — a failing source is null.
385
+ *
386
+ * - ccusage : `ccusage <platform> daily --json` → 7d/30d/90d/all
387
+ * - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
388
+ * - tokscale : `bunx tokscale@latest models --json` → all
389
+ */
390
+ export async function freshVerifierPillars(platform = 'claude') {
391
+ const p = platform || 'claude'
392
+ // Each call is internally try/catch → null; await Promise.all of resolved values keeps the
393
+ // helper async (per the contract) without any individual failure rejecting the batch.
394
+ const [ccusage, tokendash, tokscale] = await Promise.all([
395
+ Promise.resolve().then(() => _freshCcusage(p)).catch(() => null),
396
+ Promise.resolve().then(() => _freshTokendash(p)).catch(() => null),
397
+ Promise.resolve().then(() => _freshTokscale(p)).catch(() => null),
398
+ ])
399
+ return { ccusage, tokscale, tokendash }
400
+ }
package/tools.mjs CHANGED
@@ -113,6 +113,9 @@ async function pullByPlatform(platform, opts = {}) {
113
113
  export const DEFAULT_API_BASE = process.env.SIGRANK_API_BASE || 'https://signalaf.com'
114
114
  /** Default network timeout in ms (override via opts.fetchTimeout or SIGRANK_FETCH_TIMEOUT). */
115
115
  export const DEFAULT_FETCH_TIMEOUT = Number(process.env.SIGRANK_FETCH_TIMEOUT) || 10_000
116
+ /** Max accepted length for a single paste/string arg (chars). Token counts are tiny; anything
117
+ * past this is malformed or abusive — reject cleanly before parsing/POSTing (E2 hardening). */
118
+ const MAX_INPUT = 1_000_000
116
119
 
117
120
  export const TOOLS = [
118
121
  {
@@ -246,6 +249,13 @@ export const TOOLS = [
246
249
  // tokenpull window key → the board's window_type enum.
247
250
  const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
248
251
 
252
+ // E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
253
+ // identical snapshots (exact hash → 422), but a noisy poll loop with submit:true could still
254
+ // churn the network/board with near-identical rows. Cap auto-submit to once per WATCH_SUBMIT_COOLDOWN_MS
255
+ // per window (in-memory, per process). Keyed by window so different windows don't block each other.
256
+ const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000
257
+ const _lastWatchSubmitAt = new Map()
258
+
249
259
  export async function callTool(name, args, opts = {}) {
250
260
  const apiBase = opts.apiBase || DEFAULT_API_BASE
251
261
  const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT
@@ -390,6 +400,9 @@ export async function callTool(name, args, opts = {}) {
390
400
 
391
401
  if (name === 'submit_paste') {
392
402
  if (!args?.text) throw new Error('submit_paste requires a non-empty `text` argument.')
403
+ if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
404
+ return { status: 'error', reason: 'input_too_large', detail: `text exceeds ${MAX_INPUT} chars (${args.text.length}). Paste only the token-count table, not full output.` }
405
+ }
393
406
  // Local preview first — also validates the paste is parseable before any POST.
394
407
  const pillars = parsePillars(args.text)
395
408
  const c = withParseWarnings(pillars, cascade(pillars))
@@ -469,6 +482,13 @@ export async function callTool(name, args, opts = {}) {
469
482
  // in the same shape as tokenpull output for easy follow-up with tokenpull_submit.
470
483
  const WINDOW_KEYS = ['7d', '30d', '90d', 'all']
471
484
  const sourceTool = args?.source_tool || null
485
+ // E2: reject any oversized window paste up front (token tables are tiny).
486
+ for (const wk of WINDOW_KEYS) {
487
+ const v = args?.[wk]
488
+ if (typeof v === 'string' && v.length > MAX_INPUT) {
489
+ return { status: 'error', reason: 'input_too_large', detail: `window '${wk}' exceeds ${MAX_INPUT} chars (${v.length}). Paste only the token-count table.` }
490
+ }
491
+ }
472
492
  const windows = []
473
493
  for (const wk of WINDOW_KEYS) {
474
494
  const text = args?.[wk]
@@ -514,12 +534,22 @@ export async function callTool(name, args, opts = {}) {
514
534
  if (args?.submit === true) {
515
535
  const id = opts.identity || ensureIdentity()
516
536
  if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
517
- auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
518
- apiBase,
519
- fetchImpl: doFetch,
520
- platform: pulled.platform,
521
- now: opts.now,
522
- })
537
+ // E3: client-side cooldown — at most one auto-submit per window per 5 min. Prevents a
538
+ // fast poll loop from churning the board even before the server's hash-dedup kicks in.
539
+ const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
540
+ const last = _lastWatchSubmitAt.get(watchWindow)
541
+ if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
542
+ const waitS = Math.ceil((WATCH_SUBMIT_COOLDOWN_MS - (clockNow - last)) / 1000)
543
+ auth_submit = { status: 'cooldown', detail: `auto-submit for '${watchWindow}' on cooldown — next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
544
+ } else {
545
+ _lastWatchSubmitAt.set(watchWindow, clockNow)
546
+ auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
547
+ apiBase,
548
+ fetchImpl: doFetch,
549
+ platform: pulled.platform,
550
+ now: opts.now,
551
+ })
552
+ }
523
553
  } else {
524
554
  auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
525
555
  }
package/tui.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { callTool, DEFAULT_API_BASE } from './tools.mjs'
16
+ import { freshVerifierPillars } from './tokenpull.mjs'
16
17
  import { isSignedIn, isCodeChar } from './connect.mjs'
17
18
  import { loadIdentity, clearIdentity } from './keystore.mjs'
18
19
  import { execSync } from 'child_process'
@@ -301,10 +302,7 @@ async function loadDashboardData() {
301
302
  // then fill the other 13 as they resolve. The caller passes an onFirst callback
302
303
  // that fires when claude is ready so the user sees their cascade within ~1 read,
303
304
  // not after a 14-platform barrier.
304
- const [claudeResult] = await Promise.all([
305
- tokenpullAny('claude').catch(() => null),
306
- Promise.resolve(tokenDashPillars()),
307
- ])
305
+ const claudeResult = await tokenpullAny('claude').catch(() => null)
308
306
 
309
307
  const active = []
310
308
  if (claudeResult) {
@@ -312,13 +310,13 @@ async function loadDashboardData() {
312
310
  if (all && (all.pillars.input ?? 0) + (all.pillars.output ?? 0) > 0) active.push(claudeResult)
313
311
  }
314
312
 
315
- const verifierMap = {}
316
- for (const platform of ALL_PLATFORMS) {
317
- verifierMap[platform] = { cc: ccusagePillars(platform), ts: tokscalePillars(platform) }
318
- }
319
-
313
+ // FIX A1 (2026-06-27): removed the 14× SYNCHRONOUS ccusage `verifierMap` build (measured 39,816 ms)
314
+ // that blocked the very first Dashboard paint — and the render never read `verifierMap` or `tdPillars`.
315
+ // (Also dropped the discarded second `tokenDashPillars()` call above.) Verifier comparison
316
+ // (ccusage / tokscale / token-dashboard) now runs FRESH + on-demand in the Compare tab, scoped to
317
+ // active platforms — see loadCompareData. Dashboard paints from tokenpull in ~2s.
320
318
  const boardData = await boardPromise
321
- return { active, verifierMap, tdPillars: tokenDashPillars(), boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
319
+ return { active, verifierMap: {}, tdPillars: null, boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
322
320
  }
323
321
 
324
322
  /** FIX 0: fill the remaining platforms after the primary render. Mutates dashData.active. */
@@ -342,20 +340,42 @@ async function fillDashboardRest(dashData) {
342
340
  return true
343
341
  }
344
342
 
343
+ // #9 / C1 (2026-06-27): Compare pulls the three external verifier sources FRESH + on-demand
344
+ // (ccusage + tokscale models + a token-dashboard rescan), via tokenpull.freshVerifierPillars —
345
+ // NOT the old stale snapshot readers (static tokscale_report.json / 3-day-old db). This runs ONLY
346
+ // when the Compare tab opens (it's a 5–60s scan), never on the Dashboard load path. Scoped to the
347
+ // current platform. tokenpull (the canonical board/cascade source) stays the fast/fresh tpData.
345
348
  async function loadCompareData(platform = 'claude') {
346
349
  const tpData = await callTool('tokenpull', { platform }).catch(() => null)
347
- const cc = ccusagePillars(platform)
348
- const ts = tokscalePillars(platform)
349
- const td = platform === 'claude' ? tokenDashPillars() : null
350
- return { tpData, cc, ts, td, platform }
350
+ // Fresh pull — { ccusage, tokscale, tokendash }, each null or window-keyed pillars.
351
+ const fresh = await freshVerifierPillars(platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null }))
352
+ return { tpData, cc: fresh.ccusage, ts: fresh.tokscale, td: fresh.tokendash, platform }
351
353
  }
352
354
 
355
+ // #8 (2026-06-27): the Board tab shows ALL SUBMISSIONS ranked (raw submission rows), not operator
356
+ // aggregates. Try the NEW GET /api/v1/submissions first; if it's not deployed yet (non-200 / 404),
357
+ // FALL BACK to the existing /api/v1/leaderboard so nothing breaks pre-deploy. The return is tagged
358
+ // with `_source` ('submissions' | 'leaderboard') so renderBoard picks the right column layout.
353
359
  async function loadBoardData(window = '30d') {
354
- const res = await fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=yield_`, {
355
- headers: { accept: 'application/json' }
356
- })
357
- if (!res.ok) return null
358
- return res.json()
360
+ // Primary: submissions endpoint (ranked submission rows).
361
+ try {
362
+ const res = await fetch(`${DEFAULT_API_BASE}/api/v1/submissions?window=${window}&metric=yield_`, {
363
+ headers: { accept: 'application/json' },
364
+ })
365
+ if (res.ok) {
366
+ const data = await res.json()
367
+ return { ...data, _source: 'submissions' }
368
+ }
369
+ } catch { /* fall through to leaderboard */ }
370
+ // Fallback: existing leaderboard endpoint + current render (pre-deploy safety).
371
+ try {
372
+ const res = await fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=yield_`, {
373
+ headers: { accept: 'application/json' },
374
+ })
375
+ if (!res.ok) return null
376
+ const data = await res.json()
377
+ return { ...data, _source: 'leaderboard' }
378
+ } catch { return null }
359
379
  }
360
380
 
361
381
  // ── TAB BAR ──────────────────────────────────────────────────────────────────
@@ -455,13 +475,26 @@ function yieldSpark(d) {
455
475
 
456
476
  // ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
457
477
  function renderDashboard(data, status = '') {
458
- const { active, verifierMap, tdPillars, boardData } = data
459
478
  const w = W()
460
479
  const budget = H() - 4 // 2 tab bar + 2 footer
461
480
  const WINS = ['7d', '30d', '90d', 'all']
462
481
  let used = 0
463
482
  const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
464
483
 
484
+ // A2 (2026-06-27): tolerate null/empty dashData so the FIRST paint can happen BEFORE the
485
+ // initial load resolves (loadAll renders an empty frame + "reading your cascade…" immediately,
486
+ // then re-renders with real data). Guard the destructure: no data → header + status + return.
487
+ if (!data || !Array.isArray(data.active)) {
488
+ emit()
489
+ emit(` ${bold('Your Cascade')} ${dim('all platforms · all windows')}`)
490
+ emit()
491
+ emit(` ${dim(' reading token logs… (press [R] to refresh)')}`)
492
+ if (status && used < budget) emit(` ${dim(status)}`)
493
+ return
494
+ }
495
+
496
+ const { active } = data
497
+
465
498
  // Responsive: the full 12-col table is ~124 wide. On terminals narrower than
466
499
  // that, drop the two derived columns (Vel, 10x) and tighten the inter-column
467
500
  // gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
@@ -521,6 +554,15 @@ function renderDashboard(data, status = '') {
521
554
  const sectionsBelow = barLines + boardLines
522
555
  const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
523
556
 
557
+ // A3 (2026-06-27): count the cascade rows we WANT to show (every window with data across all
558
+ // active platforms) so we can tell the user how many were dropped by the height budget, instead
559
+ // of silently truncating them. (Fit logic below is unchanged — this is observation only.)
560
+ const totalCascadeRowsWanted = active.reduce((n, d) =>
561
+ n + WINS.filter(wk => {
562
+ const wd = d.windows?.find(x => x.window === wk)
563
+ return wd && (wd.pillars.input + wd.pillars.output) > 0
564
+ }).length, 0)
565
+
524
566
  let firstWin = {}
525
567
  let cascadeRowCount = 0
526
568
  for (const d of active) {
@@ -538,6 +580,10 @@ function renderDashboard(data, status = '') {
538
580
  emit()
539
581
  }
540
582
 
583
+ // A3: if any cascade rows were dropped (height budget / row cap), say so instead of hiding them.
584
+ const cascadeDropped = totalCascadeRowsWanted - cascadeRowCount
585
+ if (cascadeDropped > 0) emit(` ${dim(` +${cascadeDropped} more — resize / scroll`)}`)
586
+
541
587
  // combined
542
588
  if (active.length > 1 && used < budget - sectionsBelow) {
543
589
  const lbl = active.map(d => d.platform).join('+')
@@ -663,11 +709,14 @@ const TREND_METRICS = [
663
709
  { label: '10xDEV', pick: (c) => c.dev10x, fmt: (v) => v.toFixed(2) },
664
710
  { label: 'Eff', pick: (c) => c.yield != null ? c.yield / AA_EFF_BASELINE : null, fmt: (v) => v.toFixed(1) + '×' },
665
711
  { label: '§IGNA', pick: () => null, fmt: () => '—', stub: 'composite calibrating (needs user volume)' },
666
- { label: 'Op Ratio', pick: (c, p) => (p.input ?? 0) > 0 ? { cacheRead: p.cacheRead ?? 0, input: p.input ?? 0, output: p.output ?? 0 } : null, fmt: (v) => `${fmtTok(v.cacheRead)}:${fmtTok(v.input)}:${fmtTok(v.output)}` },
712
+ // FIX A4 (2026-06-27): operating ratio is INPUT-NORMALIZED `cache : 1 : output` (matches web canon —
713
+ // ThreeDegreesChart / OperatingRatioBar, e.g. "3.5 : 1 : 0.50"), not raw token counts (which also
714
+ // overflowed the Trends columns).
715
+ { label: 'Op Ratio', pick: (c, p) => (p.input ?? 0) > 0 ? { cacheRead: p.cacheRead ?? 0, input: p.input ?? 0, output: p.output ?? 0 } : null, fmt: (v) => { const i = v.input || 1; return `${(v.cacheRead / i).toFixed(1)} : 1 : ${(v.output / i).toFixed(2)}` } },
667
716
  { label: '$/1M', pick: () => null, fmt: () => '—', stub: 'cost data wires post-ingest' },
668
717
  ]
669
718
  function renderTrends(data, subIdx = 0) {
670
- const { active } = data
719
+ const { active } = data ?? {}
671
720
  const budget = H() - 4
672
721
  let used = 0
673
722
  const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
@@ -683,16 +732,23 @@ function renderTrends(data, subIdx = 0) {
683
732
 
684
733
  if (sub === 'You' || sub === 'Platform') {
685
734
  const rows = sub === 'You'
686
- ? (active.length ? [{ platform: 'you', windows: active[0]?.windows }] : []) // primary cascade
687
- : active
735
+ ? ((active?.length) ? [{ platform: 'you', windows: active[0]?.windows }] : []) // primary cascade
736
+ : (active ?? [])
688
737
  if (!rows.length) { emit(` ${dim(' reading your cascade… (press [R] to refresh)')}`); }
689
- for (const d of rows) {
690
- if (used >= budget - 2) break
738
+ // A3 (2026-06-27): count metric/rank rows dropped by the height budget so we can show a
739
+ // "+N more — resize / scroll" note instead of silently truncating. Each rendered block wants
740
+ // TREND_METRICS.length metric rows + 1 rank row. (Fit logic below unchanged — observe only.)
741
+ let trendsDropped = 0
742
+ for (let ri = 0; ri < rows.length; ri++) {
743
+ const d = rows[ri]
744
+ if (used >= budget - 2) { trendsDropped += (rows.length - ri) * (TREND_METRICS.length + 1); break }
691
745
  emit(` ${bold(sub === 'You' ? 'Your trajectory' : cyan(d.platform))}`)
692
746
  // FIX G1: window column headers (mirror renderCompare) — one header row, then
693
747
  // values in fixed-width columns below. No per-value `w:` prefix.
694
748
  emit(` ${trendHeader()}`)
695
- for (const m of TREND_METRICS) {
749
+ let mi = 0
750
+ for (; mi < TREND_METRICS.length; mi++) {
751
+ const m = TREND_METRICS[mi]
696
752
  if (used >= budget - 2) break
697
753
  if (m.stub) {
698
754
  emit(` ${padEnd(dim(m.label), 10)} ${dim('▁▁▁▁ calculating… (' + m.stub + ')')}`)
@@ -700,10 +756,14 @@ function renderTrends(data, subIdx = 0) {
700
756
  emit(` ${padEnd(cyan(m.label), 10)} ${metricSpark(d, m.pick, m.fmt)}`)
701
757
  }
702
758
  }
759
+ trendsDropped += (TREND_METRICS.length - mi) // metrics this block couldn't fit
703
760
  // rank trend — wired later; honest placeholder for now
704
761
  if (used < budget - 1) emit(` ${padEnd(dim('Rank'), 10)} ${dim('▁▁▁▁ calculating… (rank history wires post-ingest)')}`)
762
+ else trendsDropped += 1
705
763
  emit()
706
764
  }
765
+ // A3: surface the dropped count instead of hiding it.
766
+ if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize / scroll`)}`)
707
767
  } else { // Field
708
768
  emit(` ${dim(' Field-wide trends — calculating…')}`)
709
769
  emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
@@ -807,12 +867,95 @@ function renderCompare(data) {
807
867
  }
808
868
  }
809
869
 
870
+ // ── TAB 3: BOARD — submissions view (#8) ──────────────────────────────────────
871
+ // Renders ALL submissions ranked (raw submission rows from GET /api/v1/submissions), not operator
872
+ // aggregates. Columns: rank · codename · platform · window · Υ Yield · op_ratio · class · tokens.
873
+ // Field names are read defensively (the endpoint is new) so a slightly different server shape still
874
+ // renders. [Y] "just me" filtering + the you-highlight carry over from the leaderboard view.
875
+ function renderSubmissions(boardData, window = '30d', filterCodename = null, highlightCodename = null) {
876
+ const rows = boardData?.submissions ?? boardData?.entries ?? boardData?.rows ??
877
+ (Array.isArray(boardData) ? boardData : [])
878
+ const w = W()
879
+ const budget = H() - 4
880
+ let used = 0
881
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
882
+
883
+ // Defensive field readers (server shape is new / not yet pinned).
884
+ const gName = (e) => e.codename ?? e.operator_codename ?? '—'
885
+ const gPlat = (e) => e.platform ?? e.platform_primary ?? '—'
886
+ const gWin = (e) => e.window ?? e.window_type ?? '—'
887
+ const gYield = (e) => e.yield_ ?? e.yield ?? e.y ?? null
888
+ const gOpRatio = (e) => e.op_ratio ?? e.operating_ratio ?? e.opRatio ?? null
889
+ const gClass = (e) => e.class_tier ?? e.class ?? e.class_ ?? '—'
890
+ const gTokens = (e) => e.tokens ?? e.total_tokens ?? e.token_total ?? null
891
+
892
+ const modeLabel = filterCodename ? `${dim(' · just you')}` : highlightCodename ? `${dim(' · you highlighted')}` : ''
893
+ emit()
894
+ emit(` ${bold('Submissions')} ${dim(`window: ${window} · all submissions ranked by Υ Yield · signalaf.com`)}${modeLabel}`)
895
+ emit()
896
+
897
+ if (!Array.isArray(rows) || rows.length === 0) {
898
+ emit(` ${dim(' no submissions for this window')}`)
899
+ return
900
+ }
901
+
902
+ let sorted = [...rows].sort((a, b) => (gYield(b) ?? 0) - (gYield(a) ?? 0))
903
+ if (filterCodename) {
904
+ sorted = sorted.filter(e => (gName(e) ?? '').toLowerCase() === filterCodename.toLowerCase())
905
+ if (sorted.length === 0) {
906
+ emit(` ${dim(` you have no submissions in the ${window} window yet`)}`)
907
+ emit(` ${dim(' press [S] from a read tab to submit your cascade')}`)
908
+ return
909
+ }
910
+ }
911
+
912
+ // op_ratio may arrive as a preformatted string ("3.5 : 1 : 0.50") or a number — render either.
913
+ const fmtOp = (v) => v == null ? '—' : (typeof v === 'string' ? v : (typeof v === 'number' ? v.toFixed(2) : String(v)))
914
+
915
+ const SH = [
916
+ padStart(hdr('#'), 4), padEnd(hdr('Codename'), 20), padEnd(hdr('Platform'), 10),
917
+ padEnd(hdr('Win'), 6), padStart(hdr('Υ Yield'), 9), padEnd(hdr('Op Ratio'), 18),
918
+ padEnd(hdr('Class'), 12), padStart(hdr('Tokens'), 9),
919
+ ]
920
+ emit(` ${SH.join(' ')}`)
921
+ emit(` ${dim('·'.repeat(Math.min(w - 4, 100)))}`)
922
+
923
+ let shown = 0
924
+ for (let idx = 0; idx < sorted.length; idx++) {
925
+ if (used >= budget) break
926
+ const e = sorted[idx]
927
+ const isYou = highlightCodename && (gName(e) ?? '').toLowerCase() === highlightCodename.toLowerCase()
928
+ const rk = idx === 0 ? gold(`#${idx+1}`) : idx < 3 ? cyan(`#${idx+1}`) : `#${idx+1}`
929
+ const nmRaw = trunc(gName(e), 20)
930
+ const nm = isYou
931
+ ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), 18)} `, 20)}${c.reset}`
932
+ : padEnd(nmRaw, 20)
933
+ const yv = gYield(e)
934
+ const yld = padStart(yv != null ? (yv > 10000 ? gold(fmtY(yv)) : fmtY(yv)) : '—', 9)
935
+ const cls = padEnd(colorCls(gClass(e)), 12)
936
+ const tk = gTokens(e)
937
+ const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : ''
938
+ emit(` ${padStart(rk, 4)} ${nm} ${padEnd(cyan(trunc(gPlat(e), 10)), 10)} ${padEnd(dim(trunc(gWin(e), 6)), 6)} ${yld} ${padEnd(dim(fmtOp(gOpRatio(e))), 18)} ${cls} ${padStart(tk != null ? fmtTok(tk) : '—', 9)}${youMark}`)
939
+ shown++
940
+ }
941
+ // A3-style: if the height budget dropped rows, say so instead of silently truncating.
942
+ const dropped = sorted.length - shown
943
+ if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize / scroll`)}`)
944
+ }
945
+
810
946
  // ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
811
947
  // FIX I2: hybrid board model. `filterCodename` (non-null = "just me" mode) shows
812
948
  // ONLY the signed-in operator's rows. `highlightCodename` (non-null = global mode)
813
949
  // highlights the signed-in operator's row in the global board. Both come from the
814
950
  // signed-in identity's codename; the [Y] key toggles between the two modes.
815
951
  function renderBoard(boardData, window = '30d', filterCodename = null, highlightCodename = null) {
952
+ // #8 (2026-06-27): when the data came from the NEW /api/v1/submissions endpoint, render ALL
953
+ // submissions ranked (raw rows). Otherwise fall through to the existing leaderboard render
954
+ // (pre-deploy fallback). loadBoardData tags the payload with `_source`.
955
+ if (boardData?._source === 'submissions') {
956
+ renderSubmissions(boardData, window, filterCodename, highlightCodename)
957
+ return
958
+ }
816
959
  const entries = boardData?.entries ?? boardData?.operators ?? boardData ?? []
817
960
  const w = W()
818
961
  const budget = H() - 4
@@ -897,13 +1040,20 @@ function renderBoard(boardData, window = '30d', filterCodename = null, highlight
897
1040
  // Explains what the live watcher does and why it's the point of the agent: it
898
1041
  // re-reads your local logs on an interval and feeds your verified cascade to the
899
1042
  // leaderboard. Launched (in its own window) with [Enter]; interval is tunable.
1043
+ // A5 (2026-06-27): Watch defaults to ALL active platforms × ALL windows (mirror the cli.mjs watch
1044
+ // change). `platform`/`win` are OPTIONAL focus filters — null/'all' means "everything". [P]/[W]
1045
+ // cycle the focus (including back to "all"); the spawned watcher + this copy show everything by default.
900
1046
  function renderWatchInfo(platform, win, refresh) {
1047
+ const platLabel = (!platform || platform === 'all') ? 'all active platforms' : platform
1048
+ const winLabel = (!win || win === 'all-windows') ? 'all windows' : win
1049
+ const allDefault = platLabel === 'all active platforms' && winLabel === 'all windows'
901
1050
  writeln()
902
1051
  writeln(` ${bold('Live Watch')} ${dim('the agent that keeps your rank current')}`)
903
1052
  writeln()
904
1053
  writeln(` ${dim('What it does')}`)
905
- writeln(` Re-reads your local ${cyan(platform)} token logs every ${gold(refresh + 's')} and recomputes`)
906
- writeln(` your cascade (Υ Yield · SNR · Leverage · class) live, on this machine.`)
1054
+ writeln(` Re-reads your local token logs for ${cyan(platLabel)} across ${cyan(winLabel)} every`)
1055
+ writeln(` ${gold(refresh + 's')} and recomputes each cascade (Υ Yield · SNR · Leverage · class) live, on`)
1056
+ writeln(` this machine. ${dim('By default it watches everything — every platform you actually use.')}`)
907
1057
  writeln()
908
1058
  writeln(` ${dim('Why it matters')}`)
909
1059
  writeln(` Watch is how the board stays ${bold('current')}: each refresh submits your latest`)
@@ -911,9 +1061,10 @@ function renderWatchInfo(platform, win, refresh) {
911
1061
  writeln(` no manual re-submit. Tokens never leave your machine; only the metrics post.`)
912
1062
  writeln()
913
1063
  writeln(` ${dim('Settings')}`)
914
- writeln(` ${dim('Platform')} ${cyan(platform)} ${dim('Window')} ${cyan(win)} ${dim('Refresh')} ${gold(refresh + 's')}`)
1064
+ writeln(` ${dim('Platform')} ${cyan(platLabel)} ${dim('Window')} ${cyan(winLabel)} ${dim('Refresh')} ${gold(refresh + 's')}`)
1065
+ writeln(` ${dim(allDefault ? 'watching everything (default)' : 'focused — press [P]/[W] to widen back to all')}`)
915
1066
  writeln()
916
- writeln(` ${dim('[Enter]')} launch watcher (new window) ${dim('[+]/[-]')} refresh ±5s ${dim('[P]')} platform ${dim('[W]')} window`)
1067
+ writeln(` ${dim('[Enter]')} launch watcher (new window) ${dim('[+]/[-]')} refresh ±5s ${dim('[P]')} platform focus ${dim('[W]')} window focus`)
917
1068
  writeln()
918
1069
  }
919
1070
 
@@ -946,74 +1097,66 @@ function renderConnect(id, codeBuf = '', msg = '') {
946
1097
  if (msg) { writeln(); writeln(` ${msg}`) }
947
1098
  }
948
1099
 
949
- // ── TAB 4: WATCH ─────────────────────────────────────────────────────────────
950
- async function renderWatch(platform = 'claude', win = '7d') {
1100
+ // ── TAB 4: WATCH (live grid) ──────────────────────────────────────────────────
1101
+ // A5 (2026-06-27): renders ALL active platforms × ALL windows by default (mirror the cli.mjs
1102
+ // watch grid), instead of a single platform/window. `platform`/`win` are OPTIONAL focus filters —
1103
+ // null/'all'/'all-windows' means "everything". Each row is a (platform, window) cascade cell:
1104
+ // Υ Yield + SNR/Lev/Vel/class. Iterates active platforms (input+output > 0 in any window).
1105
+ async function renderWatch(platform = 'all', win = 'all-windows') {
951
1106
  const { tokenpullAny } = await import('./tokenpull.mjs')
952
- const d = await tokenpullAny(platform).catch(() => null)
953
- if (!d) { writeln(` ${dim(`no data for ${platform}`)}`); return }
954
-
955
- const wdata = d.windows?.find(x => x.window === win)
956
- if (!wdata) { writeln(` ${dim(`no data for ${win} window`)}`); return }
957
-
958
- const p = wdata.pillars
959
- const cas = cascadeFrom(p)
960
- const w = W()
961
-
962
- writeln()
963
- writeln(` ${bold('Live Watch')} ${dim(`${platform} · ${win} window · ${d.files} files · ${wdata.messages?.toLocaleString()} msgs`)}`)
964
- writeln()
965
-
966
- if (!cas) { writeln(` ${dim(' insufficient data to compute cascade')}`); return }
967
-
968
- const clsFn = CLS[cas.class] ?? ((s) => s)
969
-
970
- // big numbers
971
- const metrics = [
972
- { label: 'Υ Yield', val: fmtY(cas.yield), color: cas.yield > 1000 ? c.boldGold : c.boldCyan },
973
- { label: 'Class', val: cas.class, color: CLS[cas.class] ? c.boldGold : c.reset },
974
- { label: 'SNR', val: fmtSNR(cas.snr), color: c.green },
975
- { label: 'Leverage', val: fmtLev(cas.leverage)+'×', color: c.cyan },
976
- { label: 'Velocity', val: cas.velocity?.toFixed(2), color: c.white },
977
- { label: '10xDEV', val: cas.dev10x?.toFixed(2), color: c.magenta },
1107
+ const ALL_PLATFORMS = [
1108
+ 'claude','codex','amp','gemini','kimi','qwen','goose','kilo',
1109
+ 'hermes','droid','codebuff','copilot','openclaw','pi',
978
1110
  ]
1111
+ const platFilter = (platform && platform !== 'all') ? platform : null
1112
+ const winFilter = (win && win !== 'all-windows') ? win : null
1113
+ const WINS = winFilter ? [winFilter] : ['7d', '30d', '90d', 'all']
1114
+ const w = W()
1115
+ const budget = H() - 4
1116
+ let used = 0
1117
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
979
1118
 
980
- for (const m of metrics) {
981
- writeln(` ${padEnd(dim(m.label), 12)} ${paint(m.color, m.val ?? '—')}`)
1119
+ // Detect active platforms (any window with input+output > 0), respecting an optional [P] focus.
1120
+ const candidates = platFilter ? [platFilter] : ALL_PLATFORMS
1121
+ const settled = await Promise.allSettled(candidates.map(p => tokenpullAny(p)))
1122
+ const active = []
1123
+ for (const r of settled) {
1124
+ if (r.status !== 'fulfilled' || !r.value) continue
1125
+ const d = r.value
1126
+ if ((d.windows || []).some(ww => (ww.pillars.input + ww.pillars.output) > 0)) active.push(d)
982
1127
  }
983
- writeln()
1128
+ // stable display order
1129
+ active.sort((a, b) => ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform))
984
1130
 
985
- // token pillars
986
- writeln(` ${hr()}`)
987
- writeln()
988
- writeln(` ${bold('Token Pillars')} ${dim(`${win} window`)}`)
989
- writeln()
1131
+ const scopeLabel = (platFilter || 'all active platforms') + ' · ' + (winFilter ? `${winFilter} window` : 'all windows')
1132
+ emit()
1133
+ emit(` ${bold('Live Watch')} ${dim(scopeLabel)}`)
1134
+ emit(` ${dim('·'.repeat(Math.min(w - 4, 80)))}`)
990
1135
 
991
- const pillarsData = [
992
- { label: 'Input', val: p.input, color: c.cyan, est: d.estimated },
993
- { label: 'Output', val: p.output, color: c.green, est: false },
994
- { label: 'Cache Write', val: p.cacheCreate, color: c.blue, est: d.estimated },
995
- { label: 'Cache Read', val: p.cacheRead, color: c.boldGold,est: false },
996
- ]
997
- // Log scale: token pillars span 2–3 orders of magnitude (input ~8M, cacheRead ~1.9B).
998
- // Linear scale crushes input/output bars to near-invisible. Log10 gives equal visual
999
- // weight per order-of-magnitude step.
1000
- const maxLog = Math.log10(Math.max(...pillarsData.map(x => x.val ?? 0), 10))
1001
- for (const item of pillarsData) {
1002
- const v = item.val ?? 0
1003
- const { bar } = logBar(v, maxLog, 40, item.color)
1004
- const est = item.est ? dim('~') : ''
1005
- writeln(` ${padEnd(dim(item.label), 12)} ${padEnd(bar, 40)} ${est}${fmtTok(v)}`)
1136
+ if (active.length === 0) {
1137
+ emit()
1138
+ emit(` ${dim(' no active platforms detected — run some sessions, this picks them up automatically')}`)
1139
+ return
1006
1140
  }
1007
- writeln(` ${dim('log₁₀ scale — each step = 10× · absolute values right')}`)
1008
- writeln()
1009
1141
 
1010
- // trend across windows
1011
- writeln(` ${hr()}`)
1012
- writeln()
1013
- writeln(` ${bold('Υ Trend')} ${dim('7d → 30d → 90d → all')}`)
1014
- writeln()
1015
- writeln(` ${yieldSpark(d)}`)
1016
- writeln()
1142
+ for (const d of active) {
1143
+ if (used >= budget - 1) break
1144
+ const byWin = {}
1145
+ for (const ww of (d.windows || [])) byWin[ww.window] = ww
1146
+ emit()
1147
+ emit(` ${cyan(d.platform)}${d.estimated ? dim(' (est)') : ''}`)
1148
+ for (const wk of WINS) {
1149
+ if (used >= budget - 1) break
1150
+ const ww = byWin[wk]
1151
+ const cas = ww ? cascadeFrom(ww.pillars) : null
1152
+ const winLabel = wk === 'all' ? 'all-time' : wk
1153
+ if (!cas) { emit(` ${padEnd(dim(winLabel), 9)} ${dim('no data')}`); continue }
1154
+ const clsFn = CLS[cas.class] ?? ((s) => s)
1155
+ const yDisplay = cas.yield > 1000 ? gold(fmtY(cas.yield)) : cyan(fmtY(cas.yield))
1156
+ const metrics = `${dim('SNR')} ${fmtSNR(cas.snr)} ${dim('Lev')} ${cas.leverage != null ? fmtLev(cas.leverage) + '×' : '—'} ${dim('Vel')} ${cas.velocity != null ? cas.velocity.toFixed(2) : '—'} ${clsFn(cas.class)}`
1157
+ emit(` ${padEnd(dim(winLabel), 9)} ${bold('Υ')} ${yDisplay} ${metrics}`)
1158
+ }
1159
+ }
1017
1160
  }
1018
1161
 
1019
1162
  // ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
@@ -1070,6 +1213,7 @@ async function renderOnce(tabIdx = 0) {
1070
1213
  if (tabIdx === 0) renderDashboard(data, 'debug')
1071
1214
  else if (tabIdx === 1) renderCompare(data)
1072
1215
  else if (tabIdx === 2) renderBoard(data, '30d')
1216
+ else if (tabIdx === 4) await renderWatch('all', 'all-windows')
1073
1217
  else if (tabIdx === 5) renderConnect(loadIdentity(), '', '')
1074
1218
  const lines = _screenBuf || []
1075
1219
  _screenBuf = null; _footerBuf = null
@@ -1201,8 +1345,11 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1201
1345
  let compareData = null
1202
1346
  let boardData = null
1203
1347
  let boardWindow = '30d'
1204
- let watchPlatform = platform
1205
- let watchWindow = win
1348
+ // A5 (2026-06-27): Watch defaults to "all" focus — all active platforms × all windows. [P]/[W]
1349
+ // optionally narrow the focus and cycle back to 'all'. ('all-windows' is the window sentinel so
1350
+ // it never collides with the real 'all' window bucket in the cli watcher's optional --window.)
1351
+ let watchPlatform = 'all'
1352
+ let watchWindow = 'all-windows'
1206
1353
  let watchRefresh = 30 // [5] Watch poll interval (seconds) — [+]/[-] adjust
1207
1354
  let trendSub = 0 // [2] Trends sub-view index (You/Platform/Field) — [T] cycles
1208
1355
  let loading = true
@@ -1251,7 +1398,11 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1251
1398
  setFooter(readFooter(`${hint} ${dim('[T]')} view${submitHint}`))
1252
1399
  } else if (activeTab === 2) { // Compare
1253
1400
  if (!compareData) {
1254
- writeln(`\n ${dim(`loading compare (${platform})…`)}`)
1401
+ // #9 (2026-06-27): Compare runs a FRESH on-demand pull of the external verifier sources
1402
+ // (ccusage + tokscale + token-dashboard rescan) — it can take several seconds. Show a clear
1403
+ // spinner line while it runs, instead of a bare "loading…".
1404
+ writeln(`\n ${bold('Source Comparison')} ${dim(`platform: ${platform}`)}`)
1405
+ writeln(`\n ${gold('◇')} ${dim(`pulling fresh sources… (ccusage · tokscale · token-dash — this can take several seconds)`)}`)
1255
1406
  } else {
1256
1407
  renderCompare(compareData)
1257
1408
  }
@@ -1282,22 +1433,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1282
1433
 
1283
1434
  // ── Initial data load
1284
1435
  const loadAll = async () => {
1285
- status = 'loading…'
1436
+ // A2 (2026-06-27): paint the frame + a clear "reading your cascade…" status IMMEDIATELY,
1437
+ // BEFORE the await — so the very first thing on screen is the table header + a live status,
1438
+ // not a blank "loading dashboard…". renderDashboard tolerates this empty dashData (guarded).
1439
+ status = 'reading your cascade…'
1440
+ dashData = { active: [], _loading: true }
1441
+ await redraw()
1286
1442
  // FIX 0: progressive Dashboard load — claude first (fast), render it, THEN fill
1287
1443
  // the other 13 platforms in the background + redraw. The user sees their cascade
1288
1444
  // within ~1 read instead of a 7s blank "loading dashboard…".
1289
1445
  dashData = await loadDashboardData().catch(e => { status = `dashboard error: ${e.message}`; return null })
1290
1446
  status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1291
1447
  await redraw()
1292
- // Kick off the rest + compare/board in parallel
1293
- ;[compareData, boardData] = await Promise.all([
1294
- loadCompareData(platform).catch(() => null),
1295
- loadBoardData(boardWindow).catch(() => null),
1296
- ])
1448
+ // Keep a "filling other platforms…" status visible while fillDashboardRest runs.
1449
+ // #9 (2026-06-27): Compare is NO LONGER eagerly loaded here — loadCompareData now runs the
1450
+ // FRESH verifier pull (ccusage + tokscale + token-dash rescan, 5–60s), which would re-introduce
1451
+ // a slow Dashboard load path. Compare loads lazily ON-DEMAND when the Compare tab is opened.
1452
+ // Board still preloads (fast, cached) in parallel with the platform fill.
1297
1453
  if (dashData?._remaining) {
1298
- await fillDashboardRest(dashData)
1454
+ status = 'claude ready · filling other platforms…'
1299
1455
  await redraw()
1300
1456
  }
1457
+ ;[, boardData] = await Promise.all([
1458
+ dashData?._remaining ? fillDashboardRest(dashData) : Promise.resolve(false),
1459
+ loadBoardData(boardWindow).catch(() => null),
1460
+ ])
1461
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1462
+ await redraw()
1301
1463
  }
1302
1464
 
1303
1465
  // Always restore terminal on unexpected exit
@@ -1481,6 +1643,17 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1481
1643
  if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
1482
1644
  if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
1483
1645
 
1646
+ // #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
1647
+ // (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
1648
+ // sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
1649
+ if (switched && activeTab === 2 && !compareData) {
1650
+ submitMsg = ''
1651
+ await redraw() // show the spinner line before the (slow) fresh pull
1652
+ compareData = await loadCompareData(platform).catch(() => null)
1653
+ await redraw()
1654
+ return
1655
+ }
1656
+
1484
1657
  // Trends tab: [T] cycles the sub-view (You · Platform · Field)
1485
1658
  if (activeTab === 1 && k === 't') { trendSub = (trendSub + 1) % 3; await redraw(); return }
1486
1659
 
@@ -1492,36 +1665,48 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1492
1665
  // Compare reloads compareData for the new platform; Watch just re-renders.
1493
1666
  const CYCLE_PLATFORMS = ['claude','codex','amp','gemini','kimi','qwen','goose','kilo','hermes','droid','codebuff','copilot','openclaw','pi']
1494
1667
  if (k === 'p' && (activeTab === 2 || activeTab === 4)) {
1495
- const cur = activeTab === 2 ? platform : watchPlatform
1496
- const idx = CYCLE_PLATFORMS.indexOf(cur)
1497
- const next = CYCLE_PLATFORMS[(idx + 1) % CYCLE_PLATFORMS.length]
1498
1668
  if (activeTab === 2) {
1499
- platform = next
1669
+ // Compare: real platforms only (no 'all' — Compare is per-platform by design).
1670
+ const idx = CYCLE_PLATFORMS.indexOf(platform)
1671
+ platform = CYCLE_PLATFORMS[(idx + 1) % CYCLE_PLATFORMS.length]
1672
+ // #9: clearing compareData makes redraw show the "pulling fresh sources…" spinner
1673
+ // for the new platform while the fresh on-demand verifier pull runs.
1674
+ compareData = null
1500
1675
  status = `loading ${platform}…`
1501
1676
  await redraw()
1502
1677
  compareData = await loadCompareData(platform).catch(() => null)
1503
1678
  status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1504
1679
  } else {
1505
- watchPlatform = next
1680
+ // A5: Watch [P] cycles 'all' → claude → codex → … → 'all' (optional focus; default 'all').
1681
+ const WATCH_PLAT_CYCLE = ['all', ...CYCLE_PLATFORMS]
1682
+ const idx = WATCH_PLAT_CYCLE.indexOf(watchPlatform)
1683
+ watchPlatform = WATCH_PLAT_CYCLE[(idx + 1) % WATCH_PLAT_CYCLE.length]
1506
1684
  }
1507
1685
  await redraw(); return
1508
1686
  }
1509
1687
 
1510
- // FIX J: [W] cycles window on Watch tab (was a dead hinted key). Board tab's
1511
- // [W] (activeTab === 3) is handled below — it reloads boardData.
1688
+ // FIX J / A5: [W] cycles window FOCUS on the Watch tab. 'all-windows' (default) → 7d → 30d →
1689
+ // 90d → all → back to 'all-windows'. Board tab's [W] (activeTab === 3) is handled below.
1512
1690
  if (k === 'w' && activeTab === 4) {
1513
- const windows = ['7d', '30d', '90d', 'all']
1514
- const idx = windows.indexOf(watchWindow)
1515
- watchWindow = windows[(idx + 1) % windows.length]
1691
+ const WATCH_WIN_CYCLE = ['all-windows', '7d', '30d', '90d', 'all']
1692
+ const idx = WATCH_WIN_CYCLE.indexOf(watchWindow)
1693
+ watchWindow = WATCH_WIN_CYCLE[(idx + 1) % WATCH_WIN_CYCLE.length]
1516
1694
  await redraw(); return
1517
1695
  }
1518
1696
 
1519
1697
  if (activeTab === 4 && (key === '\r' || key === '\n')) {
1520
- // Launch the live watcher in its own window, passing the chosen refresh interval.
1698
+ // A5 (2026-06-27): launch the live watcher in its own window. By default (focus = 'all')
1699
+ // pass NO --platform/--window so the cli watcher auto-loads ALL active platforms × ALL
1700
+ // windows. Only add a filter flag when the user has set an explicit [P]/[W] focus.
1521
1701
  try {
1522
- const watchCmd = `sigrank watch --platform ${watchPlatform} --window ${watchWindow} --refresh ${watchRefresh}`
1702
+ let watchCmd = 'sigrank watch'
1703
+ if (watchPlatform && watchPlatform !== 'all') watchCmd += ` --platform ${watchPlatform}`
1704
+ if (watchWindow && watchWindow !== 'all-windows') watchCmd += ` --window ${watchWindow}`
1705
+ watchCmd += ` --refresh ${watchRefresh}`
1523
1706
  execSync(`osascript -e 'tell application "Terminal" to do script "${watchCmd}"'`, { stdio: 'ignore' })
1524
- status = `watcher launched (${watchRefresh}s) in a new window`
1707
+ const scope = (!watchPlatform || watchPlatform === 'all') && (!watchWindow || watchWindow === 'all-windows')
1708
+ ? 'all platforms × all windows' : 'focused'
1709
+ status = `watcher launched (${scope}, ${watchRefresh}s) in a new window`
1525
1710
  } catch { status = 'could not open Terminal.app — run: sigrank watch' }
1526
1711
  await redraw()
1527
1712
  return
@@ -1529,6 +1714,16 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1529
1714
 
1530
1715
  if (k === 'r') {
1531
1716
  status = 'refreshing…'
1717
+ // #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
1718
+ // (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
1719
+ if (activeTab === 2) {
1720
+ compareData = null
1721
+ await redraw()
1722
+ compareData = await loadCompareData(platform).catch(() => null)
1723
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1724
+ await redraw()
1725
+ return
1726
+ }
1532
1727
  await redraw()
1533
1728
  await loadAll()
1534
1729
  return