sigrank 0.14.2 โ†’ 0.15.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/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ > **๐Ÿ† SigRank is live: [signalaf.com](https://signalaf.com)** โ€” the leaderboard for how
2
+ > efficiently you use AI, not how much. See your projected rank in 60 seconds at
3
+ > [signalaf.com/score](https://signalaf.com/score). *Token counts only. Never your prompts.*
4
+
1
5
  <div align="center">
2
6
 
3
7
  <p><img src="./.github/assets/og-card.png" alt="SigRank โ€” the new standard in AI evaluation &amp; benchmarks" width="800"></p>
@@ -35,9 +39,12 @@ sigrank enroll
35
39
 
36
40
  # 3. Submit your cascade to the board
37
41
  sigrank submit
42
+
43
+ # (cautious? see exactly what would be sent โ€” four counts + a signature โ€” sending nothing)
44
+ sigrank submit --dry-run
38
45
  ```
39
46
 
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.
47
+ 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
48
 
42
49
  Or just explore without signing in:
43
50
 
@@ -133,6 +140,7 @@ Six tabs. Keys: `1`-`6` or `โ†` `โ†’` to switch ยท `R` refresh ยท `Q` quit.
133
140
  ```bash
134
141
  sigrank enroll # sign in: paste a connect code (get one at signalaf.com โ†’ Settings)
135
142
  sigrank submit # publish your verified runs to the board (sign in first)
143
+ sigrank submit --dry-run # inspect the exact signed payload without sending anything
136
144
  ```
137
145
 
138
146
  Or do it inside the TUI on the **Connect** tab (`6`), then press `[S]` to submit.
@@ -177,7 +185,9 @@ Or if installed globally:
177
185
  | `tokenpull_submit(codename, window?)` | `{codename?, window?}` | `tokenpull` โ†’ publish to board. Omit codename for preview |
178
186
  | `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 |
179
187
  | `rank_windows` | `{platform?, window?}` | Multi-window cascade from local logs |
180
- | `watch_tokenpull` | `{platform?, interval_s?}` | Streaming cascade snapshots |
188
+ | `watch_tokenpull` | `{platform?, interval_s?}` | One cascade snapshot per call (interval_s advisory) |
189
+ | `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 |
190
+ | `enroll` | `{code, device_label?}` | Bind this device with a connect code from signalaf.com โ†’ Settings |
181
191
 
182
192
  ---
183
193
 
@@ -250,7 +260,8 @@ All adapters are token-only (no message content, no cost fields, no credentials)
250
260
  - **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.
251
261
  - **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.
252
262
  - **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.
253
- - **No auth required.** All board reads and the submit path are anonymous.
263
+ - **Board reads are anonymous.** No account needed to browse, compare, or watch.
264
+ - **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.
254
265
 
255
266
  ---
256
267
 
@@ -288,7 +299,7 @@ Tests verify (29 assertions):
288
299
  | File | Responsibility |
289
300
  |---|---|
290
301
  | `index.mjs` | Entry point โ€” TTY detection, routes to CLI or MCP server |
291
- | `cli.mjs` | CLI commands: board, me, compare, watch, enroll, submit, help |
302
+ | `cli.mjs` | CLI commands: board, compare, watch, enroll, submit, help |
292
303
  | `tui.mjs` | Full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect |
293
304
  | `cascade.mjs` | Pure cascade math (ฮฅ, SNR, leverage, velocity, 10xDEV, class) |
294
305
  | `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
@@ -1309,7 +1309,7 @@ async function runSubmit({ platform = 'claude', window } = {}) {
1309
1309
 
1310
1310
  let out
1311
1311
  try {
1312
- 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') })
1313
1313
  } catch (e) {
1314
1314
  writeln(red(` โœ— ${e.message}`))
1315
1315
  process.exitCode = 1
@@ -1326,7 +1326,9 @@ async function runSubmit({ platform = 'claude', window } = {}) {
1326
1326
  let anyReceivedNotRanked = false
1327
1327
  for (const w of out.windows || []) {
1328
1328
  const label = String(w.window).padEnd(8)
1329
- if (w.ranked) {
1329
+ if (w.status === 'dry_run') {
1330
+ writeln(` ${gold('โ—‡')} ${bold(label)} ${gold('DRY RUN')} ${dim('ยท nothing sent')}`)
1331
+ } else if (w.ranked) {
1330
1332
  anyRanked = true
1331
1333
  writeln(` ${green('โœ“')} ${bold(label)} ${green('RANKED')} ${dim('ยท live on the board')}`)
1332
1334
  } else if (w.status === 'received') {
@@ -1350,7 +1352,7 @@ export async function runCli(argv) {
1350
1352
 
1351
1353
  // parse --key value AND --key=value flags. E4: validate against the known set and warn
1352
1354
  // (don't crash) on anything unknown, so a typo'd flag is visible instead of silently ignored.
1353
- 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'])
1354
1356
  const flags = {}
1355
1357
  for (let i = 1; i < args.length; i++) {
1356
1358
  const a = args[i]
@@ -1412,8 +1414,11 @@ export async function runCli(argv) {
1412
1414
  // default: full unified view
1413
1415
  await runSigRank()
1414
1416
  } else {
1415
- // 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}`))
1416
1420
  await showHelp()
1421
+ process.exit(2)
1417
1422
  }
1418
1423
  } catch (e) {
1419
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', '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.14.2",
3
+ "version": "0.15.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",
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 {
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,15 +285,41 @@ 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
  },
298
+ {
299
+ name: 'simulate_change',
300
+ description:
301
+ "The first PRESCRIPTIVE SigRank tool โ€” 'what if I changed my token mix?' Takes your current 4 pillars (input/output/cacheCreate/cacheRead) and one or more proposed changes, runs the canonical cascade on BOTH the current and simulated values, and returns the exact ฮฅ Yield delta, class change, and per-metric diffs. This is the 'show me the payoff before I do the work' primitive: no network, no submission, pure local math. Use it to answer 'would increasing my cache-read by 50k tokens actually move my class?' before you change your workflow. Accepts the current pillars as JSON or 4 numbers (same as rank_paste) plus a `changes` object with any of the 4 pillar names mapped to new absolute values OR relative deltas (e.g. {cacheRead: '+50000'} or {input: 800000}).",
302
+ inputSchema: {
303
+ type: 'object',
304
+ properties: {
305
+ text: {
306
+ type: 'string',
307
+ description: 'Current token pillars โ€” ccusage JSON or "input output cacheCreate cacheRead" (same format as rank_paste).',
308
+ },
309
+ changes: {
310
+ type: 'object',
311
+ description: 'Proposed changes to apply. Keys: input, output, cacheCreate, cacheRead. Values are either absolute numbers (replace) or strings starting with +/- for relative deltas (add/subtract). Omitted pillars are unchanged.',
312
+ properties: {
313
+ input: { type: ['number', 'string'], description: 'new input token count (absolute) or "+/-N" for a relative delta' },
314
+ output: { type: ['number', 'string'], description: 'new output token count (absolute) or "+/-N" for a relative delta' },
315
+ cacheCreate: { type: ['number', 'string'], description: 'new cache-create token count (absolute) or "+/-N" for a relative delta' },
316
+ cacheRead: { type: ['number', 'string'], description: 'new cache-read token count (absolute) or "+/-N" for a relative delta' },
317
+ },
318
+ },
319
+ },
320
+ required: ['text', 'changes'],
321
+ },
322
+ },
290
323
  ]
291
324
 
292
325
  // tokenpull window key โ†’ the board's window_type enum.
@@ -295,7 +328,9 @@ const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
295
328
  // E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
296
329
  // identical snapshots (exact hash โ†’ 422), but a noisy poll loop with submit:true could still
297
330
  // 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.
331
+ // per platform+window (in-memory, per process). Keyed by platform:window so different
332
+ // platforms/windows don't block each other, and only armed on a non-error submit so a
333
+ // network failure doesn't lock out retries for 5 minutes.
299
334
  const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000
300
335
  const _lastWatchSubmitAt = new Map()
301
336
 
@@ -348,6 +383,10 @@ export async function callTool(name, args, opts = {}) {
348
383
 
349
384
  if (name === 'rank_paste') {
350
385
  if (!args?.text) throw new Error('rank_paste requires a non-empty `text` argument.')
386
+ // E2: reject oversized pastes before parsing (parity with submit_paste / rank_windows).
387
+ if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
388
+ 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.` }
389
+ }
351
390
  const pillars = parsePillars(args.text)
352
391
  const c = withParseWarnings(pillars, cascade(pillars))
353
392
  return { ...c, card: narrate(c) }
@@ -438,7 +477,7 @@ export async function callTool(name, args, opts = {}) {
438
477
  msgs += w.messages || 0
439
478
  }
440
479
  if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0) continue // skip empty window
441
- const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now })
480
+ const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now, dryRun: !!args?.dry_run })
442
481
  out.push({ window: wk, pillars: sum, ...r })
443
482
  }
444
483
  return { platform: 'multi', codename: id.codename, operator_id: id.operator_id, sources: detected.map((d) => d.platform), windows: out }
@@ -453,6 +492,7 @@ export async function callTool(name, args, opts = {}) {
453
492
  fetchImpl: doFetch,
454
493
  platform: pulled.platform,
455
494
  now: opts.now,
495
+ dryRun: !!args?.dry_run,
456
496
  })
457
497
  out.push({ window: w.window, pillars: w.pillars, ...r })
458
498
  }
@@ -599,21 +639,23 @@ export async function callTool(name, args, opts = {}) {
599
639
  if (args?.submit === true) {
600
640
  const id = opts.identity || ensureIdentity()
601
641
  if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
602
- // E3: client-side cooldown โ€” at most one auto-submit per window per 5 min. Prevents a
603
- // fast poll loop from churning the board even before the server's hash-dedup kicks in.
642
+ // E3: client-side cooldown โ€” at most one auto-submit per platform+window per 5 min.
643
+ // Prevents a fast poll loop from churning the board even before the server's
644
+ // hash-dedup kicks in. Armed only on a non-error outcome so failed submits retry.
604
645
  const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
605
- const last = _lastWatchSubmitAt.get(watchWindow)
646
+ const cdKey = `${pulled.platform}:${watchWindow}`
647
+ const last = _lastWatchSubmitAt.get(cdKey)
606
648
  if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
607
649
  const waitS = Math.ceil((WATCH_SUBMIT_COOLDOWN_MS - (clockNow - last)) / 1000)
608
- auth_submit = { status: 'cooldown', detail: `auto-submit for '${watchWindow}' on cooldown โ€” next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
650
+ auth_submit = { status: 'cooldown', detail: `auto-submit for '${cdKey}' on cooldown โ€” next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
609
651
  } else {
610
- _lastWatchSubmitAt.set(watchWindow, clockNow)
611
652
  auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
612
653
  apiBase,
613
654
  fetchImpl: doFetch,
614
655
  platform: pulled.platform,
615
656
  now: opts.now,
616
657
  })
658
+ if (auth_submit?.status !== 'error') _lastWatchSubmitAt.set(cdKey, clockNow)
617
659
  }
618
660
  } else {
619
661
  auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
@@ -701,5 +743,116 @@ export async function callTool(name, args, opts = {}) {
701
743
  }
702
744
  }
703
745
 
746
+ if (name === 'simulate_change') {
747
+ // The first prescriptive tool โ€” "what if I changed my token mix?"
748
+ // Pure local math: parse current pillars, apply proposed changes, run the
749
+ // cascade on both, return the delta. No network, no submission.
750
+ if (!args?.text) throw new Error('simulate_change requires a non-empty `text` argument (current pillars).')
751
+ if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
752
+ return { status: 'error', reason: 'input_too_large', detail: `text exceeds ${MAX_INPUT} chars.` }
753
+ }
754
+ if (!args?.changes || typeof args.changes !== 'object') {
755
+ throw new Error('simulate_change requires a `changes` object with at least one pillar change.')
756
+ }
757
+
758
+ const currentPillars = parsePillars(args.text)
759
+ const current = withParseWarnings(currentPillars, cascade(currentPillars))
760
+
761
+ // Apply changes: each pillar is either an absolute number (replace) or a
762
+ // string starting with +/- (relative delta). Omitted pillars are unchanged.
763
+ const PILLAR_KEYS = ['input', 'output', 'cacheCreate', 'cacheRead']
764
+ const simulated = { ...currentPillars }
765
+ const appliedChanges = {}
766
+
767
+ for (const key of PILLAR_KEYS) {
768
+ if (args.changes[key] == null) continue
769
+ const raw = args.changes[key]
770
+ let newVal
771
+
772
+ if (typeof raw === 'number') {
773
+ newVal = raw
774
+ } else if (typeof raw === 'string') {
775
+ const trimmed = raw.trim()
776
+ if (trimmed.startsWith('+') || trimmed.startsWith('-')) {
777
+ // Relative delta: add/subtract from current value
778
+ const delta = Number(trimmed)
779
+ if (!Number.isFinite(delta)) {
780
+ return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: "${raw}" is not a valid relative delta.` }
781
+ }
782
+ newVal = currentPillars[key] + delta
783
+ } else {
784
+ // Absolute value as a string
785
+ newVal = Number(trimmed)
786
+ }
787
+ } else {
788
+ return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: expected number or string, got ${typeof raw}.` }
789
+ }
790
+
791
+ if (!Number.isFinite(newVal)) {
792
+ return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: result is not a finite number.` }
793
+ }
794
+ // Clamp to non-negative โ€” token counts can't be negative
795
+ if (newVal < 0) {
796
+ return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: result ${newVal} is negative โ€” token counts must be >= 0.` }
797
+ }
798
+
799
+ simulated[key] = newVal
800
+ appliedChanges[key] = {
801
+ from: currentPillars[key],
802
+ to: newVal,
803
+ delta: newVal - currentPillars[key],
804
+ }
805
+ }
806
+
807
+ if (Object.keys(appliedChanges).length === 0) {
808
+ return { status: 'error', reason: 'no_changes', detail: 'No pillar changes specified in the `changes` object.' }
809
+ }
810
+
811
+ const simulatedResult = cascade(simulated)
812
+
813
+ // Compute deltas for every cascade metric
814
+ const metricDelta = (curr, sim) => {
815
+ if (curr == null && sim == null) return null
816
+ if (curr == null) return { from: null, to: sim, delta: null }
817
+ if (sim == null) return { from: curr, to: null, delta: null }
818
+ return { from: curr, to: sim, delta: Number((sim - curr).toFixed(4)) }
819
+ }
820
+
821
+ const classChanged = current.class !== simulatedResult.class
822
+
823
+ return {
824
+ current: {
825
+ pillars: currentPillars,
826
+ yield: current.yield,
827
+ snr: current.snr,
828
+ leverage: current.leverage,
829
+ velocity: current.velocity,
830
+ dev10x: current.dev10x,
831
+ class: current.class,
832
+ },
833
+ simulated: {
834
+ pillars: { input: simulated.input, output: simulated.output, cacheCreate: simulated.cacheCreate, cacheRead: simulated.cacheRead },
835
+ yield: simulatedResult.yield,
836
+ snr: simulatedResult.snr,
837
+ leverage: simulatedResult.leverage,
838
+ velocity: simulatedResult.velocity,
839
+ dev10x: simulatedResult.dev10x,
840
+ class: simulatedResult.class,
841
+ },
842
+ changes: appliedChanges,
843
+ deltas: {
844
+ yield: metricDelta(current.yield, simulatedResult.yield),
845
+ snr: metricDelta(current.snr, simulatedResult.snr),
846
+ leverage: metricDelta(current.leverage, simulatedResult.leverage),
847
+ velocity: metricDelta(current.velocity, simulatedResult.velocity),
848
+ dev10x: metricDelta(current.dev10x, simulatedResult.dev10x),
849
+ },
850
+ class_changed: classChanged,
851
+ ...(classChanged ? { class_transition: `${current.class} โ†’ ${simulatedResult.class}` } : {}),
852
+ ...(simulatedResult.warnings ? { simulated_warnings: simulatedResult.warnings } : {}),
853
+ note: 'Local simulation only โ€” no submission. The actual score depends on server-side RS.xx weights and class thresholds.',
854
+ }
855
+ }
856
+
704
857
  throw new Error(`Unknown tool: ${name}`)
705
858
  }