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 +11 -4
- package/adapters.mjs +6 -4
- package/cli.mjs +9 -4
- package/index.mjs +11 -6
- package/package.json +1 -1
- package/submit.mjs +14 -0
- package/tokenpull.mjs +7 -2
- package/tools.mjs +30 -13
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.
|
|
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?}` |
|
|
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
|
-
- **
|
|
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,
|
|
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 {
|
|
38
|
+
import { execFile as execFileCb } from 'node:child_process'
|
|
39
39
|
import { promisify } from 'node:util'
|
|
40
40
|
|
|
41
|
-
const
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
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
|
-
|
|
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
|
|
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
|
-
|
|
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??
|
|
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
|
-
'
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
603
|
-
// fast poll loop from churning the board even before the server's
|
|
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
|
|
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 '${
|
|
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.' }
|