sigrank 0.14.2 → 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
 
@@ -133,6 +136,7 @@ Six tabs. Keys: `1`-`6` or `←` `→` to switch · `R` refresh · `Q` quit.
133
136
  ```bash
134
137
  sigrank enroll # sign in: paste a connect code (get one at signalaf.com → Settings)
135
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
136
140
  ```
137
141
 
138
142
  Or do it inside the TUI on the **Connect** tab (`6`), then press `[S]` to submit.
@@ -177,7 +181,9 @@ Or if installed globally:
177
181
  | `tokenpull_submit(codename, window?)` | `{codename?, window?}` | `tokenpull` → publish to board. Omit codename for preview |
178
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 |
179
183
  | `rank_windows` | `{platform?, window?}` | Multi-window cascade from local logs |
180
- | `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 |
181
187
 
182
188
  ---
183
189
 
@@ -250,7 +256,8 @@ All adapters are token-only (no message content, no cost fields, no credentials)
250
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.
251
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.
252
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.
253
- - **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.
254
261
 
255
262
  ---
256
263
 
@@ -288,7 +295,7 @@ Tests verify (29 assertions):
288
295
  | File | Responsibility |
289
296
  |---|---|
290
297
  | `index.mjs` | Entry point — TTY detection, routes to CLI or MCP server |
291
- | `cli.mjs` | CLI commands: board, me, compare, watch, enroll, submit, help |
298
+ | `cli.mjs` | CLI commands: board, compare, watch, enroll, submit, help |
292
299
  | `tui.mjs` | Full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect |
293
300
  | `cascade.mjs` | Pure cascade math (Υ, SNR, leverage, velocity, 10xDEV, class) |
294
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
@@ -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.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 {
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,7 +303,9 @@ 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
 
@@ -348,6 +358,10 @@ export async function callTool(name, args, opts = {}) {
348
358
 
349
359
  if (name === 'rank_paste') {
350
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
+ }
351
365
  const pillars = parsePillars(args.text)
352
366
  const c = withParseWarnings(pillars, cascade(pillars))
353
367
  return { ...c, card: narrate(c) }
@@ -438,7 +452,7 @@ export async function callTool(name, args, opts = {}) {
438
452
  msgs += w.messages || 0
439
453
  }
440
454
  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 })
455
+ const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now, dryRun: !!args?.dry_run })
442
456
  out.push({ window: wk, pillars: sum, ...r })
443
457
  }
444
458
  return { platform: 'multi', codename: id.codename, operator_id: id.operator_id, sources: detected.map((d) => d.platform), windows: out }
@@ -453,6 +467,7 @@ export async function callTool(name, args, opts = {}) {
453
467
  fetchImpl: doFetch,
454
468
  platform: pulled.platform,
455
469
  now: opts.now,
470
+ dryRun: !!args?.dry_run,
456
471
  })
457
472
  out.push({ window: w.window, pillars: w.pillars, ...r })
458
473
  }
@@ -599,21 +614,23 @@ export async function callTool(name, args, opts = {}) {
599
614
  if (args?.submit === true) {
600
615
  const id = opts.identity || ensureIdentity()
601
616
  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.
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.
604
620
  const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
605
- const last = _lastWatchSubmitAt.get(watchWindow)
621
+ const cdKey = `${pulled.platform}:${watchWindow}`
622
+ const last = _lastWatchSubmitAt.get(cdKey)
606
623
  if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
607
624
  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 }
625
+ auth_submit = { status: 'cooldown', detail: `auto-submit for '${cdKey}' on cooldown — next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
609
626
  } else {
610
- _lastWatchSubmitAt.set(watchWindow, clockNow)
611
627
  auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
612
628
  apiBase,
613
629
  fetchImpl: doFetch,
614
630
  platform: pulled.platform,
615
631
  now: opts.now,
616
632
  })
633
+ if (auth_submit?.status !== 'error') _lastWatchSubmitAt.set(cdKey, clockNow)
617
634
  }
618
635
  } else {
619
636
  auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }