sigrank 0.13.1 → 0.14.3

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/README.md CHANGED
@@ -35,9 +35,12 @@ sigrank enroll
35
35
 
36
36
  # 3. Submit your cascade to the board
37
37
  sigrank submit
38
+
39
+ # (cautious? see exactly what would be sent — four counts + a signature — sending nothing)
40
+ sigrank submit --dry-run
38
41
  ```
39
42
 
40
- That's it. The agent reads your local AI session logs on-device, derives your token cascade (Υ Yield, Leverage, Velocity, 10xDEV), and publishes to [signalaf.com](https://signalaf.com). No paste, no transcript content — only the four token counts leave your machine.
43
+ That's it. sigrank reads your local AI session logs on-device, derives your token cascade (Υ Yield, Leverage, Velocity, 10xDEV), and publishes to [signalaf.com](https://signalaf.com). No paste, no transcript content — only the four token counts leave your machine.
41
44
 
42
45
  Or just explore without signing in:
43
46
 
@@ -71,7 +74,7 @@ sigrank
71
74
  ## Commands
72
75
 
73
76
  ```
74
- ⊙ SigRank CLI v0.13.0
77
+ ⊙ SigRank CLI v0.14.2
75
78
 
76
79
  Default (no args)
77
80
  sigrank unified dashboard: cascade + token pillars + board
@@ -82,8 +85,6 @@ Commands
82
85
  board live leaderboard (refreshes every 30s)
83
86
  board --window 7d board for a specific window (7d, 30d, 90d, all)
84
87
  board --once print once and exit
85
- me single-platform cascade (claude by default)
86
- me --platform codex use a different platform adapter
87
88
  compare raw pillar audit: tokenpull vs ccusage vs token-dash vs tokscale
88
89
  compare --platform codex compare for a specific platform
89
90
  tui full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect
@@ -107,7 +108,6 @@ Examples
107
108
  sigrank board # live leaderboard
108
109
  sigrank compare # pillar audit (claude)
109
110
  sigrank compare --platform codex
110
- sigrank me --platform codex
111
111
  sigrank watch --window 7d --refresh 60
112
112
  sigrank board --window all --once
113
113
  ```
@@ -136,6 +136,7 @@ Six tabs. Keys: `1`-`6` or `←` `→` to switch · `R` refresh · `Q` quit.
136
136
  ```bash
137
137
  sigrank enroll # sign in: paste a connect code (get one at signalaf.com → Settings)
138
138
  sigrank submit # publish your verified runs to the board (sign in first)
139
+ sigrank submit --dry-run # inspect the exact signed payload without sending anything
139
140
  ```
140
141
 
141
142
  Or do it inside the TUI on the **Connect** tab (`6`), then press `[S]` to submit.
@@ -180,7 +181,9 @@ Or if installed globally:
180
181
  | `tokenpull_submit(codename, window?)` | `{codename?, window?}` | `tokenpull` → publish to board. Omit codename for preview |
181
182
  | `tokenpull_compare(platform?)` | `{platform?}` | All four sources side-by-side: tokenpull + ccusage + token-dash + tokscale. Returns pillars, cascade metrics, and delta % vs tokenpull per window |
182
183
  | `rank_windows` | `{platform?, window?}` | Multi-window cascade from local logs |
183
- | `watch_tokenpull` | `{platform?, interval_s?}` | Streaming cascade snapshots |
184
+ | `watch_tokenpull` | `{platform?, interval_s?}` | One cascade snapshot per call (interval_s advisory) |
185
+ | `submit_verified` | `{window?, platform?, dry_run?}` | THE ranked path: builds + ed25519-signs Schema 1.0 snapshots and POSTs them. `platform:'multi'` sums all active platforms. `dry_run:true` returns the exact payload unsent |
186
+ | `enroll` | `{code, device_label?}` | Bind this device with a connect code from signalaf.com → Settings |
184
187
 
185
188
  ---
186
189
 
@@ -253,7 +256,8 @@ All adapters are token-only (no message content, no cost fields, no credentials)
253
256
  - **Token-only, always.** No message content is ever read, logged, or transmitted — only token counts (`input`, `output`, `cache_creation`, `cache_read`), message IDs, and timestamps.
254
257
  - **Local by default.** `tokenpull` reads only `~/.claude/projects` (Claude) or `~/.codex` (Codex) on your device. Numbers stay on your machine unless you explicitly submit with a codename.
255
258
  - **Background tooling excluded.** Memory plugins, observers, summarizers (e.g. `claude-mem`, `mem0`, `observer-sessions`) are filtered from both Claude and Codex reads. `subagents/` are kept — they represent real operator work.
256
- - **No auth required.** All board reads and the submit path are anonymous.
259
+ - **Board reads are anonymous.** No account needed to browse, compare, or watch.
260
+ - **Ranked submissions are signed, not trusted.** `sigrank submit` requires a one-time `enroll` (device-bound ed25519 key — the private key never leaves your machine). Verify what's sent with `sigrank submit --dry-run`: the payload is four token counts, ratios, and a signature.
257
261
 
258
262
  ---
259
263
 
@@ -291,7 +295,7 @@ Tests verify (29 assertions):
291
295
  | File | Responsibility |
292
296
  |---|---|
293
297
  | `index.mjs` | Entry point — TTY detection, routes to CLI or MCP server |
294
- | `cli.mjs` | CLI commands: board, me, compare, watch, enroll, submit, help |
298
+ | `cli.mjs` | CLI commands: board, compare, watch, enroll, submit, help |
295
299
  | `tui.mjs` | Full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect |
296
300
  | `cascade.mjs` | Pure cascade math (Υ, SNR, leverage, velocity, 10xDEV, class) |
297
301
  | `tokenpull.mjs` | On-device log scanner — Claude, Codex, multi-platform |
package/adapters.mjs CHANGED
@@ -35,10 +35,10 @@
35
35
  import { readdir, readFile } from 'node:fs/promises'
36
36
  import { join, extname } from 'node:path'
37
37
  import { homedir } from 'node:os'
38
- import { exec as execCb } from 'node:child_process'
38
+ import { execFile as execFileCb } from 'node:child_process'
39
39
  import { promisify } from 'node:util'
40
40
 
41
- const exec = promisify(execCb)
41
+ const execFileP = promisify(execFileCb)
42
42
  const DAY_MS = 86_400_000 // shared with tokenpull.mjs but kept local to avoid circular import
43
43
 
44
44
  // ── File-system helpers ───────────────────────────────────────────────────────
@@ -75,10 +75,12 @@ function* parseJsonl(text, filePath) {
75
75
  }
76
76
  }
77
77
 
78
- /** Shell out to sqlite3 and return parsed JSON rows, or [] on error/unavailability. */
78
+ /** Run sqlite3 -json and return parsed rows, or [] on error/unavailability.
79
+ * execFile with an args array — no shell, so dbPath/sql need no quoting/escaping
80
+ * (matches the execFile hardening used in tools.mjs / tokenpull.mjs). */
79
81
  async function sqliteJson(dbPath, sql) {
80
82
  try {
81
- const { stdout } = await exec(`sqlite3 -json "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 10_000 })
83
+ const { stdout } = await execFileP('sqlite3', ['-json', dbPath, sql], { timeout: 10_000 })
82
84
  return JSON.parse(stdout || '[]')
83
85
  } catch { return [] }
84
86
  }
package/cli.mjs CHANGED
@@ -9,9 +9,7 @@
9
9
  * npx sigrank board live leaderboard, refreshes every 30s
10
10
  * npx sigrank board --window 7d board for a specific window
11
11
  * npx sigrank board --once print once and exit (no live refresh)
12
- * npx sigrank me your cascade across all 4 windows
13
- * npx sigrank me --platform amp use a different platform adapter
14
- * npx sigrank me --compare raw pillar comparison: ccusage vs tokenpull vs token-dashboard
12
+ * npx sigrank compare raw pillar comparison: ccusage vs tokenpull vs token-dashboard
15
13
  * npx sigrank watch RT tune meter — ALL active platforms × all windows
16
14
  * npx sigrank watch --platform X watch one platform only (optional filter)
17
15
  * npx sigrank watch --window 7d watch one window only (optional filter)
@@ -24,7 +22,7 @@
24
22
  * green = positive movement
25
23
  */
26
24
 
27
- import { callTool, DEFAULT_API_BASE } from './tools.mjs'
25
+ import { callTool, DEFAULT_API_BASE, pullActivePlatforms } from './tools.mjs'
28
26
  import { classify } from './cascade.mjs'
29
27
  import { ensureIdentity, keystorePath } from './keystore.mjs'
30
28
  import { submitSignedWindow } from './submit.mjs'
@@ -314,107 +312,6 @@ async function runBoard({ window = '30d', once = false, refresh = 30 } = {}) {
314
312
  }
315
313
  }
316
314
 
317
- // ── ME command ───────────────────────────────────────────────────────────────
318
-
319
- async function runMe({ platform = 'claude', compare = false } = {}) {
320
- if (compare) return runCompare({ platform })
321
-
322
- write(HIDE_CURSOR)
323
- writeln(` ${dim('reading local token logs…')}`)
324
-
325
- let pulled
326
- try {
327
- pulled = await callTool('tokenpull', { platform })
328
- } catch (e) {
329
- write(SHOW_CURSOR)
330
- write(CURSOR_UP(1) + ERASE_LINE)
331
- writeln(red(` ✗ ${e.message}`))
332
- process.exit(1)
333
- }
334
-
335
- // clear the "reading…" line
336
- write(CURSOR_UP(1) + ERASE_LINE)
337
-
338
- const w = termWidth()
339
- writeln()
340
- writeln(` ${gold('⊙ SigRank')} ${bold('Your Cascade')} ${dim(`platform: ${pulled.platform ?? platform}`)}`)
341
- if (pulled.estimated) writeln(` ${dim('⚠ estimated values (cache data unavailable for this platform)')}`)
342
- writeln(` ${dim('─'.repeat(w - 4))}`)
343
-
344
- // column headers
345
- const cols_h = [
346
- padEnd(dim('Window'), 8),
347
- padStart(dim('Υ Yield'), 10),
348
- padStart(dim('SNR'), 7),
349
- padStart(dim('Leverage'), 9),
350
- padStart(dim('Velocity'), 9),
351
- padStart(dim('10xDEV'), 8),
352
- padStart(dim('Class'), 13),
353
- padStart(dim('Tokens'), 8),
354
- ]
355
- writeln(` ${cols_h.join(' ')}`)
356
- writeln(` ${dim('·'.repeat(w - 4))}`)
357
-
358
- const windows = pulled.windows ?? []
359
- for (const win of windows) {
360
- const cas = win.cascade
361
- const isAll = win.window === 'all'
362
- const wLabel = isAll ? bold('all-time') : win.window
363
- const yVal = cas?.yield != null ? fmtYield(cas.yield) : '—'
364
- const snrVal = cas?.snr != null ? fmtSNR(cas.snr) : '—'
365
- const levVal = cas?.leverage != null ? `${fmtLev(cas.leverage)}×` : '—'
366
- const velVal = cas?.velocity != null ? cas.velocity.toFixed(2) : '—'
367
- const devVal = cas?.dev10x != null ? cas.dev10x.toFixed(2) : '—'
368
- const cls = colorClass(cas?.class ?? '—')
369
- const tok = fmtTokens(win.pillars?.total ?? (
370
- (win.pillars?.input ?? 0) + (win.pillars?.output ?? 0) +
371
- (win.pillars?.cacheCreate ?? 0) + (win.pillars?.cacheRead ?? 0)
372
- ))
373
-
374
- const row = [
375
- padEnd(wLabel, 8),
376
- padStart(isAll ? gold(yVal) : yVal, 10),
377
- padStart(snrVal, 7),
378
- padStart(levVal, 9),
379
- padStart(velVal, 9),
380
- padStart(devVal, 8),
381
- padEnd(cls, 13),
382
- padStart(tok, 8),
383
- ]
384
- writeln(` ${row.join(' ')}`)
385
- }
386
-
387
- writeln(` ${dim('─'.repeat(w - 4))}`)
388
-
389
- // card for the best window (all-time if present, else first)
390
- const best = windows.find(w => w.window === 'all') ?? windows[0]
391
- if (best?.card) {
392
- writeln()
393
- writeln(` ${dim('cascade read:')}`)
394
- // wrap card text at terminal width
395
- const cardText = best.card
396
- const maxW = w - 6
397
- const words = cardText.split(' ')
398
- let line = ''
399
- for (const word of words) {
400
- if (line.length + word.length + 1 > maxW) {
401
- writeln(` ${line}`)
402
- line = word
403
- } else {
404
- line = line ? `${line} ${word}` : word
405
- }
406
- }
407
- if (line) writeln(` ${line}`)
408
- }
409
-
410
- writeln()
411
-
412
- // submit hint
413
- writeln(` ${dim('to publish:')} ${cyan('npx sigrank submit')} ${dim('(sign in first:')} ${cyan('npx sigrank enroll')}${dim(' — get a code at signalaf.com → Settings)')}`)
414
- writeln()
415
- write(SHOW_CURSOR)
416
- }
417
-
418
315
  // ── COMPARE command ───────────────────────────────────────────────────────────
419
316
  // Side-by-side: ccusage (JSON) vs tokenpull vs token-dashboard (SQLite)
420
317
  //
@@ -756,16 +653,8 @@ const WATCH_WINDOWS = ['7d', '30d', '90d', 'all']
756
653
  // Detect which platforms have real local data (input+output > 0 in any window) via tokenpull.
757
654
  // Returns an array of pull results (the same shape tokenpullAny yields), one per active platform.
758
655
  async function detectActivePulls() {
759
- const { tokenpullAny } = await import('./tokenpull.mjs')
760
- const settled = await Promise.allSettled(ALL_PLATFORMS.map((p) => tokenpullAny(p)))
761
- const active = []
762
- for (const r of settled) {
763
- if (r.status !== 'fulfilled' || !r.value) continue
764
- const d = r.value
765
- const live = (d.windows || []).some((w) => (w.pillars.input + w.pillars.output) > 0)
766
- if (live) active.push(d)
767
- }
768
- return active
656
+ // Unified: one loader for me / watch / the TUI Dashboard (was a separate tokenpullAny loop).
657
+ return pullActivePlatforms()
769
658
  }
770
659
 
771
660
  async function runWatch({ platform, window: win, refresh = 30, submit = false } = {}) {
@@ -1320,8 +1209,6 @@ async function showHelp() {
1320
1209
  writeln(` ${cyan('board')} live leaderboard (refreshes every 30s)`)
1321
1210
  writeln(` ${cyan('board --window 7d')} board for a specific window (7d, 30d, 90d, all)`)
1322
1211
  writeln(` ${cyan('board --once')} print once and exit`)
1323
- writeln(` ${cyan('me')} single-platform cascade (claude by default)`)
1324
- writeln(` ${cyan('me --platform codex')} use a different platform adapter`)
1325
1212
  writeln(` ${cyan('compare')} raw pillar audit: tokenpull vs ccusage vs token-dash vs tokscale`)
1326
1213
  writeln(` ${cyan('compare --platform codex')} compare for a specific platform`)
1327
1214
  writeln(` ${cyan('tui')} full tabbed TUI: Dashboard / Compare / Board / Watch`)
@@ -1346,7 +1233,6 @@ async function showHelp() {
1346
1233
  writeln(` ${dim('sigrank board')} # live leaderboard`)
1347
1234
  writeln(` ${dim('sigrank compare')} # pillar audit (claude)`)
1348
1235
  writeln(` ${dim('sigrank compare --platform codex')}`)
1349
- writeln(` ${dim('sigrank me --platform codex')}`)
1350
1236
  writeln(` ${dim('sigrank watch --window 7d --refresh 60')}`)
1351
1237
  writeln(` ${dim('sigrank board --window all --once')}`)
1352
1238
  writeln()
@@ -1423,7 +1309,7 @@ async function runSubmit({ platform = 'claude', window } = {}) {
1423
1309
 
1424
1310
  let out
1425
1311
  try {
1426
- out = await callTool('submit_verified', { platform, window: typeof window === 'string' ? window : undefined })
1312
+ out = await callTool('submit_verified', { platform, window: typeof window === 'string' ? window : undefined, dry_run: process.argv.includes('--dry-run') })
1427
1313
  } catch (e) {
1428
1314
  writeln(red(` ✗ ${e.message}`))
1429
1315
  process.exitCode = 1
@@ -1436,15 +1322,28 @@ async function runSubmit({ platform = 'claude', window } = {}) {
1436
1322
  }
1437
1323
 
1438
1324
  writeln()
1325
+ let anyRanked = false
1326
+ let anyReceivedNotRanked = false
1439
1327
  for (const w of out.windows || []) {
1440
1328
  const label = String(w.window).padEnd(8)
1441
- if (w.status === 'received') {
1442
- writeln(` ${green('✓')} ${bold(label)} ${dim(`tier=${w.verification_tier || '—'}${w.persisted ? ' · persisted' : ''}`)}`)
1329
+ if (w.status === 'dry_run') {
1330
+ writeln(` ${gold('◇')} ${bold(label)} ${gold('DRY RUN')} ${dim('· nothing sent')}`)
1331
+ } else if (w.ranked) {
1332
+ anyRanked = true
1333
+ writeln(` ${green('✓')} ${bold(label)} ${green('RANKED')} ${dim('· live on the board')}`)
1334
+ } else if (w.status === 'received') {
1335
+ anyReceivedNotRanked = true
1336
+ writeln(` ${gold('⚠')} ${bold(label)} ${gold('received · NOT ranked')} ${dim(`(tier=${w.verification_tier || '—'})`)}`)
1443
1337
  } else {
1444
1338
  writeln(` ${red('✗')} ${bold(label)} ${dim(w.reason || w.status || 'failed')}`)
1445
1339
  }
1446
1340
  }
1447
- writeln(` ${dim(`Reload your board row: signalaf.com/user/${id.codename}`)}`)
1341
+ writeln()
1342
+ if (anyRanked) {
1343
+ writeln(` ${dim('Your board row:')} ${cyan(`signalaf.com/user/${id.codename}`)}`)
1344
+ } else if (anyReceivedNotRanked) {
1345
+ writeln(` ${gold('Received, but NOT ranked')} ${dim('— this device is unenrolled or revoked. Re-enroll to rank:')} ${cyan('npx sigrank enroll')}`)
1346
+ }
1448
1347
  }
1449
1348
 
1450
1349
  export async function runCli(argv) {
@@ -1453,7 +1352,7 @@ export async function runCli(argv) {
1453
1352
 
1454
1353
  // parse --key value AND --key=value flags. E4: validate against the known set and warn
1455
1354
  // (don't crash) on anything unknown, so a typo'd flag is visible instead of silently ignored.
1456
- const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label'])
1355
+ const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label', 'dry-run'])
1457
1356
  const flags = {}
1458
1357
  for (let i = 1; i < args.length; i++) {
1459
1358
  const a = args[i]
@@ -1485,8 +1384,6 @@ export async function runCli(argv) {
1485
1384
  once: flags.once === true || flags.once === 'true',
1486
1385
  refresh: Number(flags.refresh) || 30,
1487
1386
  })
1488
- } else if (cmd === 'me') {
1489
- await runMe({ platform: flags.platform ?? 'claude', compare: flags.compare === true || flags.compare === 'true' })
1490
1387
  } else if (cmd === 'compare') {
1491
1388
  await runCompare({ platform: flags.platform ?? 'claude' })
1492
1389
  } else if (cmd === 'tui') {
@@ -1517,8 +1414,11 @@ export async function runCli(argv) {
1517
1414
  // default: full unified view
1518
1415
  await runSigRank()
1519
1416
  } else {
1520
- // unknown command: show help
1417
+ // unknown command: show help + exit non-zero so typos fail loudly (index.mjs now
1418
+ // routes ALL arg'd invocations here instead of falling through to the MCP server)
1419
+ writeln(red(`\n ✗ unknown command: ${cmd}`))
1521
1420
  await showHelp()
1421
+ process.exit(2)
1522
1422
  }
1523
1423
  } catch (e) {
1524
1424
  write(SHOW_CURSOR)
package/index.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  * CLI shortcuts (optional — the TUI never needs them):
11
11
  * npx sigrank enroll sign in: redeem a connect code from signalaf.com
12
12
  * npx sigrank submit publish your verified runs to the board
13
- * npx sigrank board | me | compare | watch read / publish helpers
13
+ * npx sigrank board | compare | watch read / publish helpers
14
14
  * npx sigrank --help full reference
15
15
  *
16
16
  * For AI clients (NOT human commands): in a piped/non-TTY context this starts an MCP
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
25
25
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
26
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
26
+ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
27
27
  import { TOOLS, callTool } from './tools.mjs'
28
28
  import { runCli } from './cli.mjs'
29
29
  import { readFileSync } from 'node:fs'
@@ -55,6 +55,11 @@ async function startMcpServer() {
55
55
  const server = new Server({ name: 'sigrank', version: serverVersion() }, { capabilities: { tools: {} } })
56
56
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
57
57
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
58
+ // Unknown tool name = a client/host bug → JSON-RPC -32602 protocol error, per the MCP
59
+ // spec. isError results (below) are reserved for tools that ran and failed.
60
+ if (!TOOLS.some((t) => t.name === req.params.name)) {
61
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`)
62
+ }
58
63
  try {
59
64
  const out = await callTool(req.params.name, req.params.arguments)
60
65
  return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
@@ -66,14 +71,14 @@ async function startMcpServer() {
66
71
  }
67
72
 
68
73
  // Route:
69
- // any CLI command arg → terminal UI / CLI command
74
+ // any arg → the CLI (runCli shows help + exits non-zero on unknown commands,
75
+ // so a typo never silently starts a hung MCP stdio server on a TTY)
70
76
  // no args + TTY stdout → full tabbed TUI (interactive use)
71
77
  // no args + piped → MCP stdio server (AI client use)
72
78
  const cliArgs = process.argv.slice(2)
73
- const CLI_COMMANDS = new Set(['board', 'me', 'compare', 'tui', 'watch', 'enroll', 'submit', 'help', '--help', '-h', '--version', '-v'])
74
- if (cliArgs.length > 0 && (CLI_COMMANDS.has(cliArgs[0]) || cliArgs[0].startsWith('--'))) {
79
+ if (cliArgs.length > 0) {
75
80
  runCli(process.argv)
76
- } else if (cliArgs.length === 0 && process.stdout.isTTY) {
81
+ } else if (process.stdout.isTTY) {
77
82
  // Interactive terminal — launch the full tabbed TUI
78
83
  const { runTui } = await import('./tui.mjs')
79
84
  runTui()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.13.1",
3
+ "version": "0.14.3",
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",
package/submit.mjs CHANGED
@@ -130,6 +130,20 @@ export async function submitSignedWindow(windowKey, pillars, messages, identity,
130
130
  const payload = buildPayload(windowKey, pillars, messages, identity, opts.platform || 'claude', opts)
131
131
  const signature = signPayload(payload, identity.private_key_pkcs8_b64)
132
132
 
133
+ // Dry run: build + sign exactly as a real publish, then STOP before the POST.
134
+ // Returns the exact payload that would be sent — the privacy proof ("token
135
+ // counts only") is inspectable, and nothing touches the network.
136
+ if (opts.dryRun) {
137
+ return {
138
+ status: 'dry_run',
139
+ window: WINDOW_TYPE[windowKey] || windowKey,
140
+ would_post: `${apiBase}/api/v1/snapshots`,
141
+ payload,
142
+ signature,
143
+ detail: 'Nothing sent. Re-run without dry_run to publish.',
144
+ }
145
+ }
146
+
133
147
  let res
134
148
  let ack
135
149
  try {
@@ -157,6 +171,9 @@ export async function submitSignedWindow(windowKey, pillars, messages, identity,
157
171
  window: WINDOW_TYPE[windowKey] || windowKey,
158
172
  verification_tier: ack.verification_tier ?? null,
159
173
  persisted: ack.persisted ?? null,
174
+ // ranked = actually on the board (verified + written), not just "received".
175
+ // An unenrolled/revoked device gets HTTP 202 received but is NEVER ranked.
176
+ ranked: !!(res.ok && ack.verification_tier === 'verified' && ack.persisted === true),
160
177
  snapshot_hash: payload.agent.snapshot_hash,
161
178
  reason: res.ok ? null : ack.reason || ack.status || `http_${res.status}`,
162
179
  detail: ack.detail ?? null,
package/tokenpull.mjs CHANGED
@@ -41,7 +41,9 @@ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
41
41
  // (ccusage, tokscale) are found even when sigrank isn't globally installed.
42
42
  function execFileAsync(cmd, args, timeoutMs) {
43
43
  return new Promise((resolve, reject) => {
44
- execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
44
+ // NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
45
+ // stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
46
+ execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
45
47
  if (err) reject(err)
46
48
  else resolve(stdout.toString())
47
49
  })
@@ -347,7 +349,10 @@ async function _freshCcusage(platform = 'claude') {
347
349
  }
348
350
  let i = 0, o = 0, cw = 0, cr = 0
349
351
  for (const r of rows) {
350
- i += r.inputTokens ?? 0; o += r.outputTokens ?? 0; cw += r.cacheCreationTokens ?? 0; cr += r.cacheReadTokens ?? 0
352
+ i += r.inputTokens ?? r.input_tokens ?? 0
353
+ o += r.outputTokens ?? r.output_tokens ?? 0
354
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
355
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
351
356
  }
352
357
  result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
353
358
  return result
package/tools.mjs CHANGED
@@ -34,7 +34,9 @@ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
34
34
  // are found even when sigrank isn't globally installed.
35
35
  function execFileAsync(cmd, args, timeoutMs) {
36
36
  return new Promise((resolve, reject) => {
37
- execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
37
+ // NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
38
+ // stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
39
+ execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
38
40
  if (err) reject(err)
39
41
  else resolve(stdout.toString())
40
42
  })
@@ -71,7 +73,10 @@ async function _ccusagePillars(platform = 'claude') {
71
73
  }
72
74
  let i=0,o=0,cw=0,cr=0
73
75
  for (const r of rows) {
74
- i+=r.inputTokens??0; o+=r.outputTokens??0; cw+=r.cacheCreationTokens??0; cr+=r.cacheReadTokens??0
76
+ i += r.inputTokens ?? r.input_tokens ?? 0
77
+ o += r.outputTokens ?? r.output_tokens ?? 0
78
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
79
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
75
80
  }
76
81
  result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
77
82
  return result
@@ -231,17 +236,19 @@ export const TOOLS = [
231
236
  all: { type: 'string', description: 'ccusage/tokscale paste or JSON for the all-time window (optional)' },
232
237
  source_tool: { type: 'string', enum: ['ccusage', 'tokscale', 'claude_max', 'token_dashboard', 'other'], description: 'which token reader produced the paste (for cross-tool variance tracking)' },
233
238
  },
239
+ // at least one window paste is required (runtime check backs this up)
240
+ anyOf: [{ required: ['7d'] }, { required: ['30d'] }, { required: ['90d'] }, { required: ['all'] }],
234
241
  },
235
242
  },
236
243
  {
237
244
  name: 'watch_tokenpull',
238
245
  description:
239
- 'Watch your local token logs and re-derive your cascade whenever new sessions are written — a live tune meter. Polls at a configurable interval (default 60s) and returns the updated cascade. With submit:true (and an enrolled device) it also signs + publishes the watched window to the board each poll; default is preview-only (no submit).',
246
+ 'One poll per call: pulls your local token logs and returns the current cascade for the watched window — the tool never blocks or loops. Re-call at your desired cadence to watch for changes (interval_s is advisory only and echoed back as poll_interval_s). With submit:true (and an enrolled device) each call may also sign + publish the watched window to the board, rate-limited to once per 5 min per platform+window; default is preview-only (no submit).',
240
247
  inputSchema: {
241
248
  type: 'object',
242
249
  properties: {
243
250
  platform: { type: 'string', enum: ALL_PLATFORMS, description: 'platform to watch (default: claude)' },
244
- interval_s: { type: 'number', description: 'poll interval in seconds (default: 60, min: 10)' },
251
+ interval_s: { type: 'number', description: 'advisory poll cadence in seconds (default: 60, min: 10) — echoed back as poll_interval_s; does not make the call block or loop' },
245
252
  window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'which window to watch (default: 7d — most sensitive to recent activity)' },
246
253
  submit: { type: 'boolean', description: 'auto-submit the watched window to the board as a VERIFIED operator each poll (requires `enroll`; default false = preview only)' },
247
254
  },
@@ -278,12 +285,13 @@ export const TOOLS = [
278
285
  {
279
286
  name: 'submit_verified',
280
287
  description:
281
- 'Publish your LOCAL token runs to the SigRank board as a VERIFIED operator — the enrolled, signed path. Reads your pillars (tokenpull), builds the canonical Schema 1.0 snapshot per window, ed25519-signs it with your device key, and POSTs to /api/v1/snapshots. Requires `npx sigrank-mcp enroll` first (a bound device). Only signed submissions from a trusted device rank on the board. Token-only; the private key never leaves your machine.',
288
+ 'Publish your LOCAL token runs to the SigRank board as a VERIFIED operator — the enrolled, signed path. Reads your pillars (tokenpull), builds the canonical Schema 1.0 snapshot per window, ed25519-signs it with your device key, and POSTs to /api/v1/snapshots. Requires `npx sigrank-mcp enroll` first (a bound device). Only signed submissions from a trusted device rank on the board. Token-only; the private key never leaves your machine. Pass dry_run:true to inspect the exact signed payload without publishing.',
282
289
  inputSchema: {
283
290
  type: 'object',
284
291
  properties: {
285
292
  window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'submit only this window (default: all 4)' },
286
- platform: { type: 'string', enum: ALL_PLATFORMS, description: 'source platform (default: claude)' },
293
+ platform: { type: 'string', enum: [...ALL_PLATFORMS, 'multi'], description: "source platform (default: claude). 'multi' = combined cascade summed across all locally-detected platforms (needs 2+ active); empty windows are skipped." },
294
+ dry_run: { type: 'boolean', description: 'build + sign but do NOT publish — returns the exact payload that would be POSTed (token counts only), so you can inspect before submitting' },
287
295
  },
288
296
  },
289
297
  },
@@ -295,10 +303,30 @@ const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
295
303
  // E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
296
304
  // identical snapshots (exact hash → 422), but a noisy poll loop with submit:true could still
297
305
  // churn the network/board with near-identical rows. Cap auto-submit to once per WATCH_SUBMIT_COOLDOWN_MS
298
- // per window (in-memory, per process). Keyed by window so different windows don't block each other.
306
+ // per platform+window (in-memory, per process). Keyed by platform:window so different
307
+ // platforms/windows don't block each other, and only armed on a non-error submit so a
308
+ // network failure doesn't lock out retries for 5 minutes.
299
309
  const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000
300
310
  const _lastWatchSubmitAt = new Map()
301
311
 
312
+ // ── Shared active-platform loader ───────────────────────────────────────────
313
+ // THE single data path for "show my cascade across platforms" — used by `me`,
314
+ // `watch`, and the TUI Dashboard so they can't drift apart again. Pulls each
315
+ // target platform via the tokenpull tool (enriched: pillars + cascade + card),
316
+ // keeps only platforms with real local data, and sorts claude → codex → rest.
317
+ // Pass `platforms` to scope it (e.g. ['claude'] for a fast first paint).
318
+ export async function pullActivePlatforms({ platforms } = {}, opts = {}) {
319
+ const targets = platforms && platforms.length ? platforms : ALL_PLATFORMS
320
+ const settled = await Promise.allSettled(targets.map((p) => callTool('tokenpull', { platform: p }, opts)))
321
+ const active = settled
322
+ .filter((r) => r.status === 'fulfilled' && r.value)
323
+ .map((r) => r.value)
324
+ .filter((d) => (d.windows || []).some((w) => ((w.pillars?.input ?? 0) + (w.pillars?.output ?? 0)) > 0))
325
+ const rank = (p) => (p === 'claude' ? -2 : p === 'codex' ? -1 : ALL_PLATFORMS.indexOf(p))
326
+ active.sort((a, b) => rank(a.platform) - rank(b.platform))
327
+ return active
328
+ }
329
+
302
330
  export async function callTool(name, args, opts = {}) {
303
331
  const apiBase = opts.apiBase || DEFAULT_API_BASE
304
332
  const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT
@@ -330,6 +358,10 @@ export async function callTool(name, args, opts = {}) {
330
358
 
331
359
  if (name === 'rank_paste') {
332
360
  if (!args?.text) throw new Error('rank_paste requires a non-empty `text` argument.')
361
+ // E2: reject oversized pastes before parsing (parity with submit_paste / rank_windows).
362
+ if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
363
+ 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.` }
364
+ }
333
365
  const pillars = parsePillars(args.text)
334
366
  const c = withParseWarnings(pillars, cascade(pillars))
335
367
  return { ...c, card: narrate(c) }
@@ -420,7 +452,7 @@ export async function callTool(name, args, opts = {}) {
420
452
  msgs += w.messages || 0
421
453
  }
422
454
  if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0) continue // skip empty window
423
- const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now })
455
+ const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now, dryRun: !!args?.dry_run })
424
456
  out.push({ window: wk, pillars: sum, ...r })
425
457
  }
426
458
  return { platform: 'multi', codename: id.codename, operator_id: id.operator_id, sources: detected.map((d) => d.platform), windows: out }
@@ -435,6 +467,7 @@ export async function callTool(name, args, opts = {}) {
435
467
  fetchImpl: doFetch,
436
468
  platform: pulled.platform,
437
469
  now: opts.now,
470
+ dryRun: !!args?.dry_run,
438
471
  })
439
472
  out.push({ window: w.window, pillars: w.pillars, ...r })
440
473
  }
@@ -472,7 +505,8 @@ export async function callTool(name, args, opts = {}) {
472
505
  })
473
506
  let ack
474
507
  try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON response)` } }
475
- return { ...c, card, submission: { ...stamp, httpStatus: res.status, ...ack } }
508
+ const ranked = !!(res.ok && ack.verification_tier === 'verified' && ack.persisted === true)
509
+ return { ...c, card, ranked, submission: { ...stamp, httpStatus: res.status, ranked, ...ack } }
476
510
  }
477
511
 
478
512
  if (name === 'tokenpull') {
@@ -512,7 +546,10 @@ export async function callTool(name, args, opts = {}) {
512
546
  })
513
547
  let ack
514
548
  try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON)` } }
515
- out.push({ window: w.window, pillars: w.pillars, cascade: c, card, submission: { ...stamp, httpStatus: res.status, ...ack } })
549
+ // ranked = actually on the board (verified + persisted), not just received — parity
550
+ // with submit_verified (submit.mjs). An unenrolled/revoked device gets 202 but is NEVER ranked.
551
+ const ranked = !!(res.ok && ack.verification_tier === 'verified' && ack.persisted === true)
552
+ out.push({ window: w.window, pillars: w.pillars, cascade: c, card, ranked, submission: { ...stamp, httpStatus: res.status, ranked, ...ack } })
516
553
  }
517
554
  return { platform: pulled.platform, codename: codename || null, generatedAt: pulled.generatedAt, windows: out }
518
555
  }
@@ -577,21 +614,23 @@ export async function callTool(name, args, opts = {}) {
577
614
  if (args?.submit === true) {
578
615
  const id = opts.identity || ensureIdentity()
579
616
  if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
580
- // E3: client-side cooldown — at most one auto-submit per window per 5 min. Prevents a
581
- // fast poll loop from churning the board even before the server's hash-dedup kicks in.
617
+ // E3: client-side cooldown — at most one auto-submit per platform+window per 5 min.
618
+ // Prevents a fast poll loop from churning the board even before the server's
619
+ // hash-dedup kicks in. Armed only on a non-error outcome so failed submits retry.
582
620
  const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
583
- const last = _lastWatchSubmitAt.get(watchWindow)
621
+ const cdKey = `${pulled.platform}:${watchWindow}`
622
+ const last = _lastWatchSubmitAt.get(cdKey)
584
623
  if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
585
624
  const waitS = Math.ceil((WATCH_SUBMIT_COOLDOWN_MS - (clockNow - last)) / 1000)
586
- auth_submit = { status: 'cooldown', detail: `auto-submit for '${watchWindow}' on cooldown — next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
625
+ auth_submit = { status: 'cooldown', detail: `auto-submit for '${cdKey}' on cooldown — next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
587
626
  } else {
588
- _lastWatchSubmitAt.set(watchWindow, clockNow)
589
627
  auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
590
628
  apiBase,
591
629
  fetchImpl: doFetch,
592
630
  platform: pulled.platform,
593
631
  now: opts.now,
594
632
  })
633
+ if (auth_submit?.status !== 'error') _lastWatchSubmitAt.set(cdKey, clockNow)
595
634
  }
596
635
  } else {
597
636
  auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
package/tui.mjs CHANGED
@@ -12,7 +12,8 @@
12
12
  * - Dashboard + Compare load once, refresh on [R].
13
13
  */
14
14
 
15
- import { callTool, DEFAULT_API_BASE } from './tools.mjs'
15
+ import { callTool, DEFAULT_API_BASE, pullActivePlatforms } from './tools.mjs'
16
+ import { ALL_PLATFORMS } from './adapters.mjs'
16
17
  import { freshVerifierPillars } from './tokenpull.mjs'
17
18
  import { isSignedIn, isCodeChar } from './connect.mjs'
18
19
  import { loadIdentity, clearIdentity } from './keystore.mjs'
@@ -231,54 +232,33 @@ function sparkline(values) {
231
232
  // still used by the `tokenpull_compare` MCP tool.)
232
233
 
233
234
  async function loadDashboardData() {
234
- const { tokenpullAny } = await import('./tokenpull.mjs')
235
- const ALL_PLATFORMS = [
236
- 'claude','codex','amp','gemini','kimi','qwen','goose','kilo',
237
- 'hermes','droid','codebuff','copilot','openclaw','pi',
238
- ]
239
235
  const boardPromise = Promise.race([
240
236
  fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=30d&metric=yield_`, { headers: { accept: 'application/json' } })
241
237
  .then(r => r.ok ? r.json() : null).catch(() => null),
242
238
  new Promise(r => setTimeout(() => r(null), 5000)),
243
239
  ])
244
240
 
245
- // FIX 0: progressive load — render the primary platform (claude) FAST first,
246
- // then fill the other 13 as they resolve. The caller passes an onFirst callback
247
- // that fires when claude is ready so the user sees their cascade within ~1 read,
248
- // not after a 14-platform barrier.
249
- const claudeResult = await tokenpullAny('claude').catch(() => null)
241
+ // FIX 0 + unify (0.14): progressive load via the SHARED loader (pullActivePlatforms) — paint the
242
+ // primary platform (claude) FAST, then fill the rest in fillDashboardRest. This is the SAME loader
243
+ // `me` and `watch` use, so the three views can't drift apart. (Verifier comparison still runs
244
+ // FRESH + on-demand in the Compare tab — see loadCompareData — never on this Dashboard path.)
245
+ const active = await pullActivePlatforms({ platforms: ['claude'] }).catch(() => [])
250
246
 
251
- const active = []
252
- if (claudeResult) {
253
- const all = claudeResult.windows?.find(w => w.window === 'all')
254
- if (all && (all.pillars.input ?? 0) + (all.pillars.output ?? 0) > 0) active.push(claudeResult)
255
- }
256
-
257
- // FIX A1 (2026-06-27): removed the 14× SYNCHRONOUS ccusage `verifierMap` build (measured 39,816 ms)
258
- // that blocked the very first Dashboard paint — and the render never read `verifierMap` or `tdPillars`.
259
- // (Also dropped the discarded second `tokenDashPillars()` call above.) Verifier comparison
260
- // (ccusage / tokscale / token-dashboard) now runs FRESH + on-demand in the Compare tab, scoped to
261
- // active platforms — see loadCompareData. Dashboard paints from tokenpull in ~2s.
262
247
  const boardData = await boardPromise
263
- return { active, verifierMap: {}, tdPillars: null, boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
248
+ return { active, verifierMap: {}, tdPillars: null, boardData, _loading: true, _remaining: ALL_PLATFORMS.filter(p => p !== 'claude') }
264
249
  }
265
250
 
266
251
  /** FIX 0: fill the remaining platforms after the primary render. Mutates dashData.active. */
267
252
  async function fillDashboardRest(dashData) {
268
253
  if (!dashData?._remaining) return false
269
- const { tokenpullAny } = await import('./tokenpull.mjs')
270
- const rest = await Promise.allSettled(dashData._remaining.map(p => tokenpullAny(p)))
271
- for (const r of rest) {
272
- if (r.status !== 'fulfilled') continue
273
- const d = r.value
274
- const all = d.windows?.find(w => w.window === 'all')
275
- if (!all) continue
276
- if ((all.pillars.input ?? 0) + (all.pillars.output ?? 0) === 0) continue
254
+ // Unify: fill the remaining platforms through the SAME shared loader.
255
+ const rest = await pullActivePlatforms({ platforms: dashData._remaining }).catch(() => [])
256
+ for (const d of rest) {
277
257
  if (!dashData.active.find(a => a.platform === d.platform)) dashData.active.push(d)
278
258
  }
279
- // sort active by ALL_PLATFORMS order for stable display
280
- const order = ['claude','codex','amp','gemini','kimi','qwen','goose','kilo','hermes','droid','codebuff','copilot','openclaw','pi']
281
- dashData.active.sort((a, b) => order.indexOf(a.platform) - order.indexOf(b.platform))
259
+ // stable display order: claude → codex → rest (matches the shared loader)
260
+ const rank = (p) => (p === 'claude' ? -2 : p === 'codex' ? -1 : ALL_PLATFORMS.indexOf(p))
261
+ dashData.active.sort((a, b) => rank(a.platform) - rank(b.platform))
282
262
  dashData._loading = false
283
263
  dashData._remaining = null
284
264
  return true